diff --git a/SKILLS.md b/SKILLS.md index 23e1b33..742dbac 100644 --- a/SKILLS.md +++ b/SKILLS.md @@ -57,6 +57,19 @@ | 股东 | `GET /fin/free-holders` | 十大流通股东 | | 股东 | `GET /fin/holders` | 十大股东明细 | | 股东 | `GET /fin/holder-num` | 股东户数 | +| 资料 | `GET /fin/company-info` | 公司介绍(基本资料) | +| 资料 | `GET /fin/industry` | 所属行业 | +| 资料 | `GET /fin/equity` | 股本结构(全量历史) | +| 指数 | `GET /fin/index-classif` | 所属指数分类 | +| 指数 | `GET /fin/belong-index` | 纳入指数统计 | +| 指数 | `GET /fin/index-membership` | 指数成分权重 | +| 文本 | `GET /fin/mda` | 管理层讨论与分析(年报/中报索引) | +| 重大事项 | `GET /fin/acquisitions` | 并购事件 | +| 重大事项 | `GET /fin/equity-incentive` | 股权激励 | +| 重大事项 | `GET /fin/litigation` | 诉讼仲裁 | +| 重大事项 | `GET /fin/related-transaction` | 关联交易 | +| 重大事项 | `GET /fin/violation` | 违规处罚 | +| 重大事项 | `GET /fin/guarantee` | 对外担保 | ## 4. 接口详情 @@ -119,6 +132,34 @@ curl "http://127.0.0.1:8765/fin/holders?stocks=603233.SH&years=3" # 十 curl "http://127.0.0.1:8765/fin/holder-num?stocks=603233.SH&years=3" # 股东户数: HOLDER_TOTAL_NUM/户数增减 ``` +### 4.8 公司资料 / 股本 / 指数(无 years 参数,返回全量) + +```bash +curl "http://127.0.0.1:8765/fin/company-info?stocks=603233.SH" # 公司介绍(每只1行): ORG_NAME/CHAIRMAN/REG_CAPITAL +curl "http://127.0.0.1:8765/fin/industry?stocks=603233.SH" # 所属行业: INDUSTRY_NAME +curl "http://127.0.0.1:8765/fin/equity?stocks=603233.SH" # 股本结构: TOTAL_SHARES/CHANGE_REASON +curl "http://127.0.0.1:8765/fin/index-classif?stocks=603233.SH" # 指数分类: INDEX_CLASSIF +curl "http://127.0.0.1:8765/fin/belong-index?stocks=603233.SH" # 纳入指数统计: INDEX_NUM +curl "http://127.0.0.1:8765/fin/index-membership?stocks=603233.SH" # 指数成分权重: INDEX_NAME_ABBR/WEIGHT +``` + +### 4.9 管理层讨论与分析 + +```bash +curl "http://127.0.0.1:8765/fin/mda?stocks=603233.SH&years=5" # 年报/中报索引: REPORT_NAME/TITLE/PAGE +``` + +### 4.10 重大事项(并购 / 股权激励 / 诉讼 / 关联交易 / 违规 / 担保) + +```bash +curl "http://127.0.0.1:8765/fin/acquisitions?stocks=603233.SH" # 并购: NAME_OBJ/DEAL_AMT +curl "http://127.0.0.1:8765/fin/equity-incentive?stocks=603233.SH" # 股权激励(部分股票无记录→404) +curl "http://127.0.0.1:8765/fin/litigation?stocks=603233.SH" # 诉讼: CASE_NAME/CASE_AMOUNT +curl "http://127.0.0.1:8765/fin/related-transaction?stocks=603233.SH" # 关联交易: RELATED_PARTY/TRADE_AMT +curl "http://127.0.0.1:8765/fin/violation?stocks=603233.SH" # 违规: VIOLATE_TYPE/PUNISH_AMT +curl "http://127.0.0.1:8765/fin/guarantee?stocks=603233.SH" # 担保(部分股票无记录→404) +``` + ## 5. 使用注意事项 1. **只有 `/fin/summary` 需要 ORG_CODE**:格式 `代码:ORG_CODE`(如 `603233.SH:10500736`)。ORG_CODE 是东财内部机构编码,只传代码时服务会尝试自动查询但**不稳定**;如果 400 报错提示 ORG_CODE,请改用 `代码:ORG_CODE` 格式,或换用 `/fin/quarterly-summary`。 @@ -127,6 +168,8 @@ curl "http://127.0.0.1:8765/fin/holder-num?stocks=603233.SH&years=3" # 股 4. **列名不翻译**:`columns` 与上游东财接口一致(英文大写,如 `REPORT_DATE`、`TOTAL_ASSETS`),`data` 每行是键值对象,值可能为 `null`。 5. **报告期**:财务数据按报告期返回,`REPORT_DATE`/`END_DATE` 字段为 `YYYY-MM-DD` 格式(JSON 中是字符串)。 6. **频率**:接口为实时抓取,没有缓存;高频批量调用请控制节奏。 +7. **无 years 的接口**:`company-info` / `industry` / `equity` / `index-classif` / `belong-index` / `index-membership` 只收 `stocks`,返回全量。 +8. **部分股票可能查不到**:股权激励、担保等记录并非所有股票都有,此类接口对该股返回 `404`(`success:false`)是正常现象,不代表接口故障。 ## 6. 常见组合场景 @@ -137,3 +180,6 @@ curl "http://127.0.0.1:8765/fin/holder-num?stocks=603233.SH&years=3" # 股 | 成长性判断 | `growth` + `quarterly-summary` | | 分红投资 | `dividend-detail` + `dividend-statics` | | 股东结构/筹码分析 | `holders` + `free-holders` + `holder-num` | +| 公司底细 | `company-info` + `industry` + `equity` + `mda` | +| 风险排查 | `litigation` + `violation` + `guarantee` + `related-transaction` | +| 指数/权重 | `index-classif` + `belong-index` + `index-membership` | diff --git a/api.py b/api.py index 30f48c7..2038bc5 100644 --- a/api.py +++ b/api.py @@ -29,6 +29,18 @@ from utils.fin_dividend import get_dividend_detail, get_dividend_statics from utils.fin_free_holders import get_free_holders from utils.fin_holders import get_holders from utils.fin_holder_num import get_holder_num +from utils.fin_mda import get_mda +from utils.fin_equity import get_equity +from utils.fin_company import get_company_info, get_industry +from utils.fin_belong_index import get_index_classif, get_belong_index, get_index_membership +from utils.fin_major_events import ( + get_acquisitions, + get_equity_incentive, + get_litigation, + get_related_transaction, + get_violation, + get_guarantee, +) app = FastAPI( @@ -137,6 +149,19 @@ def root(): "GET /fin/free-holders": "拉取十大流通股东", "GET /fin/holders": "拉取十大股东明细", "GET /fin/holder-num": "拉取股东户数", + "GET /fin/mda": "拉取管理层讨论与分析(年报/中报)", + "GET /fin/equity": "拉取股本结构", + "GET /fin/company-info": "拉取公司介绍", + "GET /fin/industry": "拉取所属行业", + "GET /fin/index-classif": "拉取所属指数分类", + "GET /fin/belong-index": "拉取纳入指数统计", + "GET /fin/index-membership": "拉取指数成分权重", + "GET /fin/acquisitions": "拉取并购事件", + "GET /fin/equity-incentive": "拉取股权激励", + "GET /fin/litigation": "拉取诉讼仲裁", + "GET /fin/related-transaction": "拉取关联交易", + "GET /fin/violation": "拉取违规处罚", + "GET /fin/guarantee": "拉取对外担保", "GET /health": "健康检查", }, "usage": { @@ -145,6 +170,8 @@ def root(): "fin_main_business": "/fin/main-business?stocks=603233.SH&classify_type=产品&years=3", "fin_balance": "/fin/balance?stocks=603233.SH&years=3", "fin_dividend_detail": "/fin/dividend-detail?stocks=603233.SH&years=5", + "fin_mda": "/fin/mda?stocks=603233.SH&years=5", + "fin_company_info": "/fin/company-info?stocks=603233.SH", }, } @@ -564,6 +591,53 @@ def fin_holder_num( return payload +def _split_codes(stocks: str) -> list[str]: + """解析逗号分隔的股票代码参数.""" + codes = [code.strip() for code in stocks.split(",") if code.strip()] + if not codes: + raise HTTPException(400, detail="stocks 参数不能为空") + return codes + + +def _simple_endpoint(path: str, fetch, label: str, default_years: int, has_years: bool = True): + """生成统一的 GET 端点: 只有 stocks (+years) 参数的接口.""" + def endpoint( + stocks: str = Query(..., description="股票代码,逗号分隔,例如 '603233.SH'。仅支持代码,不需要 ORG_CODE。", examples=["603233.SH"]), + years: Optional[int] = Query(default_years if has_years else None, ge=1, le=20, description=f"拉取最近多少年的{label}"), + ): + codes = _split_codes(stocks) + df = fetch(codes) if years is None else fetch(codes, years=years) + if df.empty: + extra = {"years": years} if years is not None else {} + return _empty_response(f"无{label}数据, 请检查股票代码或筛选条件", codes, **extra) + payload = _df_to_json_payload(df) + payload["codes_requested"] = codes + if years is not None: + payload["years"] = years + return payload + + app.get(path)(endpoint) + return endpoint + + +# ---- 管理层讨论与分析 / 股本结构 (无 years) ---- +_simple_endpoint("/fin/mda", get_mda, "管理层讨论与分析", default_years=5) +_simple_endpoint("/fin/equity", get_equity, "股本结构", default_years=3, has_years=False) +_simple_endpoint("/fin/company-info", get_company_info, "公司介绍", default_years=3, has_years=False) +_simple_endpoint("/fin/industry", get_industry, "所属行业", default_years=3, has_years=False) +_simple_endpoint("/fin/index-classif", get_index_classif, "所属指数分类", default_years=3, has_years=False) +_simple_endpoint("/fin/belong-index", get_belong_index, "纳入指数统计", default_years=3, has_years=False) +_simple_endpoint("/fin/index-membership", get_index_membership, "指数成分权重", default_years=3, has_years=False) + +# ---- 重大事项六类 ---- +_simple_endpoint("/fin/acquisitions", get_acquisitions, "并购事件", default_years=3) +_simple_endpoint("/fin/equity-incentive", get_equity_incentive, "股权激励", default_years=3) +_simple_endpoint("/fin/litigation", get_litigation, "诉讼仲裁", default_years=3) +_simple_endpoint("/fin/related-transaction", get_related_transaction, "关联交易", default_years=3) +_simple_endpoint("/fin/violation", get_violation, "违规处罚", default_years=3) +_simple_endpoint("/fin/guarantee", get_guarantee, "对外担保", default_years=3) + + if __name__ == "__main__": import uvicorn uvicorn.run(app, host="0.0.0.0", port=8765, log_level="info") \ No newline at end of file diff --git a/docs/api_docs.md b/docs/api_docs.md index 1f83408..0f29b80 100644 --- a/docs/api_docs.md +++ b/docs/api_docs.md @@ -335,3 +335,249 @@ Sec-Fetch-Dest: empty Referer: https://emchoicew.eastmoney.com/ Accept-Encoding: gzip, deflate, br +## 管理层讨论与分析 +--- +GET /api/data/v1/get?source=CHOICE&reportName=RPT_BUSINESSANALYSIS&columns=ORG_CODE%2CSECUCODE%2CSECURITY_CODE%2CSECURITY_INNER_CODE%2CSECURITY_NAME_ABBR%2CREPORT_DATE%2CREPORT_NAME%2CNOTICE_DATE%2CSOURCE%2CRELINFOCODE%2CTITLE%2CPAGE"eColumns=&filter=(SECUCODE%3D%22603233.SH%22)(REPORT_NAME+in+(%22%E5%B9%B4%E6%8A%A5%22%2C%22%E4%B8%AD%E6%8A%A5%22))(REPORT_DATE%3C%3D%272026-08-15%27)(REPORT_DATE%3E%3D%272021-08-15%27)&pageNumber=1&pageSize=50&sortTypes=-1&sortColumns=REPORT_DATE&source=CHOICE&client=SW HTTP/1.1 +Host: datacenter-choice.eastmoney.com +Connection: keep-alive +sec-ch-ua: "Chromium";v="104" +Accept: application/json +sec-ch-ua-mobile: ?0 +User-Agent: Mozilla/5.0 (Windows NT 6.2; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/104.0.5112.102 Safari/537.36 +sec-ch-ua-platform: "Windows" +Origin: https://emchoicew.eastmoney.com +Accept-Language: zh-CN,zh;q=0.9 +Sec-Fetch-Site: same-site +Sec-Fetch-Mode: cors +Sec-Fetch-Dest: empty +Referer: https://emchoicew.eastmoney.com/ +Accept-Encoding: gzip, deflate, br + +## 股本结构 +--- +GET /api/data/v1/get?source=CHOICE&reportName=RPT_F10_EH_EQUITY&columns=CHOICE_F9_EH_EQUITY"eColumns=&pageNumber=1&pageSize=&sortColumns=END_DATE&client=SW&filter=(SECUCODE=%22603233.SH%22)(LISTING_DATE%3E=%271900-01-01%27)(LISTING_DATE%3C=%272026-12-31%27)&sortTypes=-1 HTTP/1.1 +Host: datacenter-choice.eastmoney.com +Connection: keep-alive +sec-ch-ua: "Chromium";v="104" +Accept: application/json +version: +sec-ch-ua-mobile: ?0 +User-Agent: Mozilla/5.0 (Windows NT 6.2; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/104.0.5112.102 Safari/537.36 +token: +sec-ch-ua-platform: "Windows" +Origin: https://emchoicew.eastmoney.com +Accept-Language: zh-CN,zh;q=0.9 +Sec-Fetch-Site: same-site +Sec-Fetch-Mode: cors +Sec-Fetch-Dest: empty +Referer: https://emchoicew.eastmoney.com/ +Accept-Encoding: gzip, deflate, br + +## 按地区分类 +--- +GET /stock/f9/ZYGC_AFLZS?SecurityCode=603233.SH&StartDate=2023-08-15&EndDate=2026-08-15&ReportPeriodType=%221%22%2C%225%22%2C%223%22%2C%226%22&Magnitude=4&ClassifyType=%E5%9C%B0%E5%8C%BA&ReportShowForm=0&ExchangeRate=1&Digits=2&Sort=-1&IsShowEmptyRow=true HTTP/2 +host: emchoicews2.eastmoney.com +sec-ch-ua: "Chromium";v="104" +accept: application/json +sec-ch-ua-mobile: ?0 +user-agent: Mozilla/5.0 (Windows NT 6.2; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/104.0.5112.102 Safari/537.36 +sec-ch-ua-platform: "Windows" +origin: https://emchoicew.eastmoney.com +accept-language: zh-CN,zh;q=0.9 +sec-fetch-site: same-site +sec-fetch-mode: cors +sec-fetch-dest: empty +referer: https://emchoicew.eastmoney.com/ +accept-encoding: gzip, deflate, br + +## 公司介绍 + 所属行业与指数 +--- +GET /api/data/v1/get?source=CHOICE&reportName=RPT_HSF9_BASIC_ORGINFO&columns=HSF9_ORGINFO"eColumns=&filter=(SECUCODE%3D%22603233.SH%22)&pageNumber=1&pageSize=200&client=SW HTTP/1.1 +Host: datacenter-choice.eastmoney.com +Connection: keep-alive +sec-ch-ua: "Chromium";v="104" +Accept: application/json +sec-ch-ua-mobile: ?0 +User-Agent: Mozilla/5.0 (Windows NT 6.2; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/104.0.5112.102 Safari/537.36 +sec-ch-ua-platform: "Windows" +Origin: https://emchoicew.eastmoney.com +Accept-Language: zh-CN,zh;q=0.9 +Sec-Fetch-Site: same-site +Sec-Fetch-Mode: cors +Sec-Fetch-Dest: empty +Referer: https://emchoicew.eastmoney.com/ +Accept-Encoding: gzip, deflate, br + +--- +GET /api/data/v1/get?reportName=RPT_STOCKF9_INDUSTRY&columns=SECUCODE%2CSECURITY_CODE%2CSECURITY_NAME_ABBR%2CINDUSTRY_TYPE_CODE%2CINDUSTRY_TYPE%2CINDUSTRY_NAME%2CINDUSTRY_CODE%2CSECURITY_INNER_CODE%2CINDUSTRY_NAME_MIN%2CINDUSTRY_CODE_MIN%2CENTRY_DATE%2COUT_DATE%2CSECURITY_TYPE_CODE%2CSTATE%2CSTATE_CODE%2CRN"eColumns=&filter=(SECUCODE%3D%22603233.SH%22)(STATE_CODE%3D%221%22)&pageNumber=&pageSize=&sortTypes=1&sortColumns=RN&source=CHOICE&client=SW HTTP/1.1 +Host: datacenter-choice.eastmoney.com +Connection: keep-alive +sec-ch-ua: "Chromium";v="104" +Accept: application/json +sec-ch-ua-mobile: ?0 +User-Agent: Mozilla/5.0 (Windows NT 6.2; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/104.0.5112.102 Safari/537.36 +sec-ch-ua-platform: "Windows" +Origin: https://emchoicew.eastmoney.com +Accept-Language: zh-CN,zh;q=0.9 +Sec-Fetch-Site: same-site +Sec-Fetch-Mode: cors +Sec-Fetch-Dest: empty +Referer: https://emchoicew.eastmoney.com/ +Accept-Encoding: gzip, deflate, br + +--- +GET /api/data/v1/get?reportName=RPT_HSF9_BELONGINDEX_CLASSIF&columns=INDEX_CLASSIF_CODE%2CINDEX_CLASSIF"eColumns=&filter=(SECUCODE%3D%22603233.SH%22)(STR_TYPE%3D%221%22)&distinct=INDEX_CLASSIF_CODE%2CINDEX_CLASSIF&pageNumber=&pageSize=&source=CHOICE&client=SW HTTP/1.1 +Host: datacenter-choice.eastmoney.com +Connection: keep-alive +sec-ch-ua: "Chromium";v="104" +Accept: application/json +sec-ch-ua-mobile: ?0 +User-Agent: Mozilla/5.0 (Windows NT 6.2; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/104.0.5112.102 Safari/537.36 +sec-ch-ua-platform: "Windows" +Origin: https://emchoicew.eastmoney.com +Accept-Language: zh-CN,zh;q=0.9 +Sec-Fetch-Site: same-site +Sec-Fetch-Mode: cors +Sec-Fetch-Dest: empty +Referer: https://emchoicew.eastmoney.com/ +Accept-Encoding: gzip, deflate, br + +--- +GET /api/data/v1/get?reportName=RPT_CUSTOM_HSF9_BELONG_INDEX&filter=(SECUCODE%3D%22603233.SH%22)&source=CHOICE&client=SW HTTP/1.1 +Host: datacenter-choice.eastmoney.com +Connection: keep-alive +sec-ch-ua: "Chromium";v="104" +Accept: application/json +sec-ch-ua-mobile: ?0 +User-Agent: Mozilla/5.0 (Windows NT 6.2; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/104.0.5112.102 Safari/537.36 +sec-ch-ua-platform: "Windows" +Origin: https://emchoicew.eastmoney.com +Accept-Language: zh-CN,zh;q=0.9 +Sec-Fetch-Site: same-site +Sec-Fetch-Mode: cors +Sec-Fetch-Dest: empty +Referer: https://emchoicew.eastmoney.com/ +Accept-Encoding: gzip, deflate, br + +--- +GET /api/data/v1/get?reportName=RPT_HSF9_BELONG_INDEX&columns=INDEX_CODE%2CINDEX_INNER_CODE%2CTRADE_DATE%2CSECUCODE%2CSECURITY_CODE%2CSECURITY_NAME_ABBR%2CINCLUDE_DATE%2CMAKER_NAME%2CINDEX_NAME_ABBR%2CSECURITY_INNER_CODE%2CINDEX_CLASSIF_CODE%2CINDEX_CLASSIF%2CWEIGHT%2CNUM%2CTOTAL_SCALE%2CWEIGHT_DATE%2CCURRENCY&filter=(SECUCODE%3D%22603233.SH%22)((INDEX_CLASSIF_CODE+like+%22%25001001%25%22)(%7CINDEX_CLASSIF_CODE+like+%22%25001002%25%22)(%7CINDEX_CLASSIF_CODE+like+%22%25001003%25%22)(%7CINDEX_CLASSIF_CODE+like+%22%25001004%25%22)(%7CINDEX_CLASSIF_CODE+like+%22%25001005%25%22)(%7CINDEX_CLASSIF_CODE+like+%22%25001007%25%22)(%7CINDEX_CLASSIF_CODE+like+%22%25001009%25%22)(%7CINDEX_CLASSIF_CODE+like+%22%25001010%25%22)(%7CINDEX_CLASSIF_CODE+like+%22%25001011%25%22)(%7CINDEX_CLASSIF_CODE+like+%22%25001017%25%22)(%7CINDEX_CLASSIF_CODE+like+%22%25001020%25%22)(%7CINDEX_CLASSIF_CODE+like+%22%25001022%25%22)(%7CINDEX_CLASSIF_CODE+like+%22%25001025%25%22)(%7CINDEX_CLASSIF_CODE+like+%22%25001030%25%22)(%7CINDEX_CLASSIF_CODE+like+%22%25001006%25%22)(%7CINDEX_CLASSIF_CODE+like+%22%25001024%25%22)(%7CINDEX_CLASSIF_CODE+like+%22%25013003%25%22)(%7CINDEX_CLASSIF_CODE+like+%22%25014%25%22))&sortColumns=NUM%2CINDEX_CODE&sortTypes=-1%2C1&pageNumber=1&pageSize=50&source=CHOICE&client=SW HTTP/1.1 +Host: datacenter-choice.eastmoney.com +Connection: keep-alive +sec-ch-ua: "Chromium";v="104" +Accept: application/json +sec-ch-ua-mobile: ?0 +User-Agent: Mozilla/5.0 (Windows NT 6.2; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/104.0.5112.102 Safari/537.36 +sec-ch-ua-platform: "Windows" +Origin: https://emchoicew.eastmoney.com +Accept-Language: zh-CN,zh;q=0.9 +Sec-Fetch-Site: same-site +Sec-Fetch-Mode: cors +Sec-Fetch-Dest: empty +Referer: https://emchoicew.eastmoney.com/ +Accept-Encoding: gzip, deflate, br + + + +## 重大事项(重组/激励/回购/担保/违规/诉讼) +---并购事件 +GET /api/data/v1/get?reportName=RPT_HS_ACQUISITIONS_EVENT&columns=SECUCODE%2CSECURITY_CODE%2CFIRST_NOTICE_DATE%2CNOTICE_DATE%2CPLAN_PROCESS%2CNAME_OBJ%2CNAME_BUY%2CMXID%2CNAME_SELL%2CDEAL_AMT%2CTRANSFER_RATIO%2CTRADTE_CURRENCY"eColumns=&filter=(SECUCODE%3D%22603233.SH%22)(FIRST_NOTICE_DATE%3E%3D%272023-08-15%27)(FIRST_NOTICE_DATE%3C%3D%272026-08-15%27)&sortTypes=-1%2C1&sortColumns=FIRST_NOTICE_DATE%2CMXID&requestParams=1&pageNumber=1&pageSize=50&client=SW HTTP/1.1 +Host: datacenter-choice.eastmoney.com +Connection: keep-alive +sec-ch-ua: "Chromium";v="104" +Accept: application/json +sec-ch-ua-mobile: ?0 +User-Agent: Mozilla/5.0 (Windows NT 6.2; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/104.0.5112.102 Safari/537.36 +sec-ch-ua-platform: "Windows" +Origin: https://emchoicew.eastmoney.com +Accept-Language: zh-CN,zh;q=0.9 +Sec-Fetch-Site: same-site +Sec-Fetch-Mode: cors +Sec-Fetch-Dest: empty +Referer: https://emchoicew.eastmoney.com/ +Accept-Encoding: gzip, deflate, br + +---股权激励 +GET /api/data/v1/get?reportName=RPT_DMSK_SH_EQUITYINCENTIVE&columns=SECUCODE%2CORG_CODE%2CPLAN_PROCESS%2CEI_TARGET%2CEI_WAY%2CTOTAL_INCENTIVE_SHARES%2CTOTAL_INCENTIVE_TSR%2CINITIAL_EXERCISE_PRICE%2CVALID_EXPIRE%2CPLAN_NOTICE_DATE%2CGMDECISION_NOTICE_DATE%2CFIRST_NOTICE_DATE%2CPLAN_EXPLAIN%2CPRICE_EXPLAIN%2CINCENTIVE_SELL_EXPLAIN%2CINCENTIVE_GRANT_CONDITION%2CEXERCISE_SPECIAL_CONDITION%2CNOTICE_DATE%2CSTR_YEAR%2CSECURITY_NAME_ABBR%2CSECURITY_CODE%2CINDUSTRYCSRC%2CEM_INDUSTRY%2CPLAN_NUM&filter=(SECUCODE%3D%22603233.SH%22)(PLAN_NOTICE_DATE%3E%3D%272023-08-15%27)(PLAN_NOTICE_DATE%3C%3D%272026-08-15%27)&sortTypes=-1&sortColumns=PLAN_NOTICE_DATE&source=CHOICE&client=SW HTTP/1.1 +Host: datacenter-choice.eastmoney.com +Connection: keep-alive +sec-ch-ua: "Chromium";v="104" +Accept: application/json +sec-ch-ua-mobile: ?0 +User-Agent: Mozilla/5.0 (Windows NT 6.2; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/104.0.5112.102 Safari/537.36 +sec-ch-ua-platform: "Windows" +Origin: https://emchoicew.eastmoney.com +Accept-Language: zh-CN,zh;q=0.9 +Sec-Fetch-Site: same-site +Sec-Fetch-Mode: cors +Sec-Fetch-Dest: empty +Referer: https://emchoicew.eastmoney.com/ +Accept-Encoding: gzip, deflate, br + +---诉讼仲裁 +GET /api/data/v1/get?source=CHOICE&reportName=RPT_LITIGATION_ARBITRATION_BSINFO&columns=ORG_CODE%2CSECUCODE%2CSECURITY_CODE%2CSECURITY_NAME_ABBR%2CNOTICE_DATE%2CCASE_NAME%2CCASE_PROFILE%2CPLAINTIFF%2CDEFENCE%2CLITIGATION_TYPE%2CFI_PROSECUTE_DATE%2CFI_ACCEPTANCE_COURT%2CCASE_AMOUNT%2CFI_JUDGMENT_DATE%2CFI_JUDGMENT_CONTENT%2CFI_IS_APPEAL%2CSI_APPELLEE%2CSI_ACCEPTANCE_COURT%2CSI_JUDGMENT_DATE%2CSI_JUDGMENT_CONTENT%2CEXECUTE_SITUATION%2CCURRENCY%2CNOTICE_YEAR"eColumns=&filter=(SECUCODE%3D%22603233.SH%22)(NOTICE_YEAR%3E%3D%221900%22)(NOTICE_YEAR%3C%3D%222026%22)&pageNumber=&pageSize=&sortTypes=-1&sortColumns=NOTICE_DATE&client=SW HTTP/1.1 +Host: datacenter-choice.eastmoney.com +Connection: keep-alive +sec-ch-ua: "Chromium";v="104" +Accept: application/json +sec-ch-ua-mobile: ?0 +User-Agent: Mozilla/5.0 (Windows NT 6.2; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/104.0.5112.102 Safari/537.36 +sec-ch-ua-platform: "Windows" +Origin: https://emchoicew.eastmoney.com +Accept-Language: zh-CN,zh;q=0.9 +Sec-Fetch-Site: same-site +Sec-Fetch-Mode: cors +Sec-Fetch-Dest: empty +Referer: https://emchoicew.eastmoney.com/ +Accept-Encoding: gzip, deflate, br + +---关联交易 +GET /api/data/v1/get?reportName=RPT_RELATED_TRANSACTION&columns=SECUCODE,SECURITY_CODE,SECURITY_NAME_ABBR,ORG_CODE,NOTICE_DATE,RELATED_PARTY,RELATED_RELATION,IS_CONTROL,TRADE_AMT,PAY_WAY,TRADE_PROFILE,PRICE_BASIS,TRADE_START_DATE,TRADE_END_DATE,CURRENCY,STR_YEAR"eColumns=&filter=(SECUCODE=%22603233.SH%22);(NOTICE_DATE%3E=%271900-01-01%27)(NOTICE_DATE%3C=%272026-12-31%27)&pageNumber=&pageSize=&sortTypes=-1&sortColumns=NOTICE_DATE&client=SW&source=CHOICE HTTP/1.1 +Host: datacenter-choice.eastmoney.com +Connection: keep-alive +sec-ch-ua: "Chromium";v="104" +Accept: application/json +sec-ch-ua-mobile: ?0 +User-Agent: Mozilla/5.0 (Windows NT 6.2; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/104.0.5112.102 Safari/537.36 +sec-ch-ua-platform: "Windows" +Origin: https://emchoicew.eastmoney.com +Accept-Language: zh-CN,zh;q=0.9 +Sec-Fetch-Site: same-site +Sec-Fetch-Mode: cors +Sec-Fetch-Dest: empty +Referer: https://emchoicew.eastmoney.com/ +Accept-Encoding: gzip, deflate, br + +---违规 +GET /api/data/v1/get?source=CHOICE&reportName=RPT_HSF9_OP_VIOLATION&columns=ORG_CODE%2CNOTICE_DATE%2CNOTICE_YEAR%2CCURRENCY%2CSECUCODE%2CSECURITY_CODE%2CSECURITY_NAME_ABBR%2CSOLVE_ORG%2CVIOLATE_TYPE%2CPUNISH_TYPE%2CPUNISH_AMT%2CVIOLATE_BEHAVIOUR%2CPUNISH_STEP%2CPUNISH_OBJECT"eColumns=&filter=(secucode%3D%22603233.SH%22)(NOTICE_DATE%3E%3D%271900-01-01%27)(NOTICE_DATE%3C%3D%272026-12-31%27)&pageNumber=1&pageSize=&sortTypes=-1&sortColumns=NOTICE_DATE&client=SW HTTP/1.1 +Host: datacenter-choice.eastmoney.com +Connection: keep-alive +sec-ch-ua: "Chromium";v="104" +Accept: application/json +sec-ch-ua-mobile: ?0 +User-Agent: Mozilla/5.0 (Windows NT 6.2; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/104.0.5112.102 Safari/537.36 +sec-ch-ua-platform: "Windows" +Origin: https://emchoicew.eastmoney.com +Accept-Language: zh-CN,zh;q=0.9 +Sec-Fetch-Site: same-site +Sec-Fetch-Mode: cors +Sec-Fetch-Dest: empty +Referer: https://emchoicew.eastmoney.com/ +Accept-Encoding: gzip, deflate, br + +---担保 +GET /api/data/v1/get?source=CHOICE&reportName=RPT_F9_GUARANTEE&columns=SECURITY_CODE%2CSECUCODE%2CSECURITY_NAME_ABBR%2CREPORT_DATE%2CGUAR_NAME%2CGUARANTEED_NAME%2CGUARANTEE_START_DATE%2CGUARANTEE_END_DATE%2CIS_PERFORM%2CTRADE_DATE%2CGUARANTEE_WAY%2CGUARANTEE_AMT%2CGUARANTEE_EXPIRE%2CIS_RELATED_TRADE%2CCURRENCY%2CREPORT_YEAR%2CHISTORYCHG%2CORG_CODE"eColumns=&filter=(SECUCODE%3D%22603233.SH%22)(REPORT_YEAR%3E%3D%221900%22)(REPORT_YEAR%3C%3D%222026%22)&sortTypes=-1&sortColumns=REPORT_DATE&client=SW HTTP/1.1 +Host: datacenter-choice.eastmoney.com +Connection: keep-alive +sec-ch-ua: "Chromium";v="104" +Accept: application/json +sec-ch-ua-mobile: ?0 +User-Agent: Mozilla/5.0 (Windows NT 6.2; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/104.0.5112.102 Safari/537.36 +sec-ch-ua-platform: "Windows" +Origin: https://emchoicew.eastmoney.com +Accept-Language: zh-CN,zh;q=0.9 +Sec-Fetch-Site: same-site +Sec-Fetch-Mode: cors +Sec-Fetch-Dest: empty +Referer: https://emchoicew.eastmoney.com/ +Accept-Encoding: gzip, deflate, br + diff --git a/docs/api_manual.md b/docs/api_manual.md index 2199b18..71a0e59 100644 --- a/docs/api_manual.md +++ b/docs/api_manual.md @@ -285,6 +285,110 @@ curl "http://127.0.0.1:8765/fin/holder-num?stocks=603233.SH&years=3" --- +## 9. 公司资料 / 股本 / 指数 + +以下接口均只需 `stocks` 参数(无 `years`,返回全量),公司介绍每只 1 行。 + +**示例** + +```bash +# 公司介绍 (基本资料: 注册资本/董事长/行业等) +curl "http://127.0.0.1:8765/fin/company-info?stocks=603233.SH" + +# 股本结构 (各报告期末股本变动全量历史) +curl "http://127.0.0.1:8765/fin/equity?stocks=603233.SH" + +# 所属行业 (当前生效的行业分类) +curl "http://127.0.0.1:8765/fin/industry?stocks=603233.SH" + +# 所属指数分类 +curl "http://127.0.0.1:8765/fin/index-classif?stocks=603233.SH" + +# 纳入指数统计 (指数/基金数量) +curl "http://127.0.0.1:8765/fin/belong-index?stocks=603233.SH" + +# 指数成分权重 (纳入主要指数的权重) +curl "http://127.0.0.1:8765/fin/index-membership?stocks=603233.SH" +``` + +**各接口常见列** + +| 接口 | 常见列 | +|---|---| +| 公司介绍 | `ORG_NAME`(公司全称)、`FOUND_DATE`、`REG_CAPITAL`、`LISTING_DATE`、`CHAIRMAN`、`PRESIDENT`、`INDUSTRYCSRC1` 等 | +| 股本结构 | `END_DATE`、`CHANGE_REASON`(变动原因)、`LIMITED_SHARES`(限售股)、`UNLIMITED_SHARES`、`TOTAL_SHARES` 等 | +| 所属行业 | `INDUSTRY_TYPE`(行业类型)、`INDUSTRY_NAME`、`INDUSTRY_CODE`、`ENTRY_DATE` 等 | +| 指数分类 | `INDEX_CLASSIF_CODE`、`INDEX_CLASSIF` | +| 纳入指数统计 | `INDEX_NUM`(指数数量)、`INDEX_FUND_NUM`、`FUND_NUM`、`FUND_TOTAL_SCALE` 等 | +| 指数成分权重 | `INDEX_NAME_ABBR`(指数名)、`INDEX_CLASSIF`、`WEIGHT`(权重)、`NUM`、`INCLUDE_DATE` 等 | + +--- + +## 10. 管理层讨论与分析(MD&A) + +年报/中报的董事会报告索引(含标题与页码),默认拉最近 5 年。 + +**参数** + +| 参数 | 类型 | 必填 | 默认 | 说明 | +|---|---|---|---|---| +| `stocks` | string | ✅ | — | 逗号分隔股票代码,如 `603233.SH` | +| `years` | int | | `5` | 拉取最近多少年的报告(1~20) | + +**示例** + +```bash +curl "http://127.0.0.1:8765/fin/mda?stocks=603233.SH&years=5" +``` + +**常见列**:`REPORT_DATE`、`REPORT_NAME`(年报/中报)、`NOTICE_DATE`、`TITLE`(标题)、`PAGE`(页码)、`RELINFOCODE`(报告关联编码)。 + +--- + +## 11. 重大事项(并购 / 股权激励 / 诉讼 / 关联交易 / 违规 / 担保) + +**公共参数** + +| 参数 | 类型 | 必填 | 默认 | 说明 | +|---|---|---|---|---| +| `stocks` | string | ✅ | — | 逗号分隔股票代码,如 `603233.SH`。仅需代码,不需要 ORG_CODE | +| `years` | int | | `3` | 拉取最近多少年的数据(1~20) | + +**示例** + +```bash +# 并购事件 +curl "http://127.0.0.1:8765/fin/acquisitions?stocks=603233.SH&years=3" + +# 股权激励 (部分股票可能无数据, 如无记录返回 404) +curl "http://127.0.0.1:8765/fin/equity-incentive?stocks=603233.SH&years=3" + +# 诉讼仲裁 +curl "http://127.0.0.1:8765/fin/litigation?stocks=603233.SH&years=3" + +# 关联交易 +curl "http://127.0.0.1:8765/fin/related-transaction?stocks=603233.SH&years=3" + +# 违规处罚 +curl "http://127.0.0.1:8765/fin/violation?stocks=603233.SH&years=3" + +# 对外担保 (部分股票可能无数据) +curl "http://127.0.0.1:8765/fin/guarantee?stocks=603233.SH&years=3" +``` + +**各接口常见列** + +| 接口 | 常见列 | +|---|---| +| 并购 | `PLAN_PROCESS`(进展)、`NAME_OBJ`(标的)、`NAME_BUY`(收购方)、`DEAL_AMT`(交易金额)、`TRANSFER_RATIO` 等 | +| 股权激励 | `PLAN_PROCESS`、`EI_TARGET`(对象)、`EI_WAY`(方式)、`TOTAL_INCENTIVE_SHARES`、`INITIAL_EXERCISE_PRICE`(行权价)等 | +| 诉讼仲裁 | `CASE_NAME`(案件名)、`CASE_PROFILE`、`PLAINTIFF`、`DEFENCE`、`CASE_AMOUNT`(涉诉金额)、`LITIGATION_TYPE` 等 | +| 关联交易 | `RELATED_PARTY`(关联方)、`RELATED_RELATION`(关系)、`TRADE_AMT`(金额)、`TRADE_PROFILE` 等 | +| 违规 | `VIOLATE_TYPE`(违规类型)、`PUNISH_TYPE`(处罚类型)、`PUNISH_AMT`、`SOLVE_ORG`(处理机构)等 | +| 担保 | `GUAR_NAME`(担保方)、`GUARANTEED_NAME`(被担保方)、`GUARANTEE_AMT`、`GUARANTEE_WAY`、`IS_RELATED_TRADE` 等 | + +--- + ## 接口一览 | 路径 | 功能 | 数据来源报表 | @@ -306,9 +410,22 @@ curl "http://127.0.0.1:8765/fin/holder-num?stocks=603233.SH&years=3" | `/fin/free-holders` | 十大流通股东 | `RPT_F10_EH_FREEHOLDERS` | | `/fin/holders` | 十大股东明细 | `RPT_DMSK_HOLDERS` | | `/fin/holder-num` | 股东户数 | `RPT_F10_EH_HOLDERNUM` | +| `/fin/mda` | 管理层讨论与分析 | `RPT_BUSINESSANALYSIS` | +| `/fin/equity` | 股本结构 | `RPT_F10_EH_EQUITY` | +| `/fin/company-info` | 公司介绍 | `RPT_HSF9_BASIC_ORGINFO` | +| `/fin/industry` | 所属行业 | `RPT_STOCKF9_INDUSTRY` | +| `/fin/index-classif` | 所属指数分类 | `RPT_HSF9_BELONGINDEX_CLASSIF` | +| `/fin/belong-index` | 纳入指数统计 | `RPT_CUSTOM_HSF9_BELONG_INDEX` | +| `/fin/index-membership` | 指数成分权重 | `RPT_HSF9_BELONG_INDEX` | +| `/fin/acquisitions` | 并购事件 | `RPT_HS_ACQUISITIONS_EVENT` | +| `/fin/equity-incentive` | 股权激励 | `RPT_DMSK_SH_EQUITYINCENTIVE` | +| `/fin/litigation` | 诉讼仲裁 | `RPT_LITIGATION_ARBITRATION_BSINFO` | +| `/fin/related-transaction` | 关联交易 | `RPT_RELATED_TRANSACTION` | +| `/fin/violation` | 违规处罚 | `RPT_HSF9_OP_VIOLATION` | +| `/fin/guarantee` | 对外担保 | `RPT_F9_GUARANTEE` | ## 测试 ```bash -uv run pytest tests/ -q # 冒烟测试: 连真实接口验证各模块能取到数据 (30 用例) +uv run pytest tests/ -q # 冒烟测试: 连真实接口验证各模块能取到数据 (56 用例) ``` diff --git a/docs/blueprint.md b/docs/blueprint.md index e622f2b..bd00282 100644 --- a/docs/blueprint.md +++ b/docs/blueprint.md @@ -39,6 +39,11 @@ EMWebApi/ │ ├── fin_free_holders.py # 十大流通股东 │ ├── fin_holders.py # 十大股东明细 │ ├── fin_holder_num.py # 股东户数 +│ ├── fin_mda.py # 管理层讨论与分析(MD&A) +│ ├── fin_equity.py # 股本结构 +│ ├── fin_company.py # 公司介绍 + 所属行业 +│ ├── fin_belong_index.py # 所属指数分类/统计/成分权重 +│ └── fin_major_events.py # 重大事项(并购/激励/诉讼/关联/违规/担保) ├── tests/ │ └── test_interfaces.py # 冒烟测试 (连真实接口) └── docs/ @@ -49,7 +54,8 @@ EMWebApi/ **依赖方向**: - `fin_quarterly.py` / `fin_main_business.py` 复用 `fin_summary.py` 里的 `HEADERS` / `TIMEOUT` / `BASE_URL`(顺带触发其模块级的 SSL 自动修复) -- 其余 9 个新模块复用 `_emchoice.py` 的 `get_data_url` / `fetch_report`(同样会触发 SSL 修复) +- 其余新模块复用 `_emchoice.py` 的 `get_data_url` / `fetch_report`(同样会触发 SSL 修复) +- `fin_major_events.py` / `fin_company.py` / `fin_belong_index.py` 内部有 `_run` / `_fetch_one` 复用,避免六接口重复样板 --- @@ -71,6 +77,19 @@ EMWebApi/ | 12 | 十大流通股东 | `RPT_F10_EH_FREEHOLDERS` | ✅ 已实现 | | 13 | 十大股东明细 | `RPT_DMSK_HOLDERS` | ✅ 已实现 | | 14 | 股东户数 | `RPT_F10_EH_HOLDERNUM` | ✅ 已实现 | +| 15 | 管理层讨论与分析 | `RPT_BUSINESSANALYSIS` | ✅ 已实现 | +| 16 | 股本结构 | `RPT_F10_EH_EQUITY` | ✅ 已实现 | +| 17 | 公司介绍 | `RPT_HSF9_BASIC_ORGINFO` | ✅ 已实现 | +| 18 | 所属行业 | `RPT_STOCKF9_INDUSTRY` | ✅ 已实现 | +| 19 | 指数分类 | `RPT_HSF9_BELONGINDEX_CLASSIF` | ✅ 已实现 | +| 20 | 纳入指数统计 | `RPT_CUSTOM_HSF9_BELONG_INDEX` | ✅ 已实现 | +| 21 | 指数成分权重 | `RPT_HSF9_BELONG_INDEX` | ✅ 已实现 | +| 22 | 并购事件 | `RPT_HS_ACQUISITIONS_EVENT` | ✅ 已实现 | +| 23 | 股权激励 | `RPT_DMSK_SH_EQUITYINCENTIVE` | ✅ 已实现 | +| 24 | 诉讼仲裁 | `RPT_LITIGATION_ARBITRATION_BSINFO` | ✅ 已实现 | +| 25 | 关联交易 | `RPT_RELATED_TRANSACTION` | ✅ 已实现 | +| 26 | 违规处罚 | `RPT_HSF9_OP_VIOLATION` | ✅ 已实现 | +| 27 | 对外担保 | `RPT_F9_GUARANTEE` | ✅ 已实现 | **文档外补充**:财务摘要(报告期) 用 `RPT_CUSTOM_ORGF9_FIN_SUMMARY`,需 ORG_CODE(不在 api_docs.md 里)。 @@ -78,10 +97,11 @@ EMWebApi/ ## 4. 实施进度 -### ✅ 已完成(11 类接口全部落地) +### ✅ 已完成(api_docs.md 全部 27 项落地) | 功能 | 核心文件 | HTTP 接口 | |------|----------|-----------| +| 健康检查 | `api.py` | `GET /health` | | 财务摘要(报告期) | `utils/fin_summary.py` | `GET /fin/summary` | | 财务摘要(单季度) | `utils/fin_quarterly.py` | `GET /fin/quarterly-summary` | | 主营构成(产品/地区) | `utils/fin_main_business.py` | `GET /fin/main-business` | @@ -93,11 +113,22 @@ EMWebApi/ | 营运能力 | `utils/fin_operate.py` | `GET /fin/operate-ability` | | 成长能力 | `utils/fin_growth.py` | `GET /fin/growth` | | 杜邦分析 | `utils/fin_dupont.py` | `GET /fin/dupont` | -| 分红明细 + 统计 | `utils/fin_dividend.py` | `GET /fin/dividend-detail` / `dividend-statics` | +| 分红明细 + 统计 | `utils/fin_dividend.py` | `GET /fin/dividend-detail` / `GET /fin/dividend-statics` | | 十大流通股东 | `utils/fin_free_holders.py` | `GET /fin/free-holders` | | 十大股东明细 | `utils/fin_holders.py` | `GET /fin/holders` | | 股东户数 | `utils/fin_holder_num.py` | `GET /fin/holder-num` | -| 测试 | `tests/test_interfaces.py` | `uv run pytest tests/ -q` (30 用例) | +| 管理层讨论与分析 | `utils/fin_mda.py` | `GET /fin/mda` | +| 股本结构 | `utils/fin_equity.py` | `GET /fin/equity` | +| 公司介绍 | `utils/fin_company.py` | `GET /fin/company-info` | +| 所属行业 | `utils/fin_company.py` | `GET /fin/industry` | +| 指数分类/统计/成分 | `utils/fin_belong_index.py` | `GET /fin/index-classif` / `GET /fin/belong-index` / `GET /fin/index-membership` | +| 并购事件 | `utils/fin_major_events.py` | `GET /fin/acquisitions` | +| 股权激励 | `utils/fin_major_events.py` | `GET /fin/equity-incentive` | +| 诉讼仲裁 | `utils/fin_major_events.py` | `GET /fin/litigation` | +| 关联交易 | `utils/fin_major_events.py` | `GET /fin/related-transaction` | +| 违规处罚 | `utils/fin_major_events.py` | `GET /fin/violation` | +| 对外担保 | `utils/fin_major_events.py` | `GET /fin/guarantee` | +| 测试 | `tests/test_interfaces.py` | `uv run pytest tests/ -q` (56 用例) | ### 🧹 待清理 @@ -112,8 +143,8 @@ EMWebApi/ 2. **结构**:`_build_*(参数构造)` → `_fetch_one(单只拉取)` → `get_<主题>(批量主入口)` 3. **共享**:`from utils.fin_summary import HEADERS, TIMEOUT`,不重复定义;不同 base_url 的接口在本文件内定义新常量;emws 跳转接口用 `_emchoice.get_data_url`,datacenter 报表用 `_emchoice.fetch_report` 4. **数据流**:内部统一 `pandas.DataFrame`,每行冗余 `SECUCODE` 列便于多股票合并 -5. **校验**:`payload.get("success")` 失败抛 `RuntimeError`;空数据返回空 DataFrame 而非报错 -6. **接口暴露**:api.py 中新增 `GET /fin/<主题>`,参数用 FastAPI `Query`,返回 `{success, count, columns, data, errors}` 风格,NaN/NaT/inf 转 None(复用 `_df_to_json_payload`),无数据统一走 `_empty_response` +5. **校验**:`fetch_report` 中 `success=False` 且 message 为"返回数据为空/暂无数据/无数据"时按空结果处理(返回空列表),其余失败抛 `RuntimeError`;空数据返回空 DataFrame 而非报错 +6. **接口暴露**:api.py 中新增 `GET /fin/<主题>`,参数用 FastAPI `Query`,返回 `{success, count, columns, data, errors}` 风格,NaN/NaT/inf 转 None(复用 `_df_to_json_payload`),无数据统一走 `_empty_response`;仅 `stocks`(+`years`) 参数的接口可用 `_simple_endpoint` 统一生成 7. **礼貌抓取**:批量循环中加随机延时(参考 `SLEEP_RANGE`) 8. **注释语言**:中文,解释"为什么"(如接口编码映射差异) 9. **测试**:新模块在 `tests/test_interfaces.py` 注册冒烟用例(连真实接口取 1 年数据,断言非空 + 关键列) diff --git a/docs/待优化-2026-08-15.md b/docs/待优化-2026-08-15.md index 8ebe1b1..7f5b2b3 100644 --- a/docs/待优化-2026-08-15.md +++ b/docs/待优化-2026-08-15.md @@ -20,7 +20,7 @@ |---|--------|---------|------|-----------------| | 5 | `/fin/cashflow-quarterly` | 单季度现金流量表 | 季度经营性现金流 | 在现有 `fin/cashflow` 上加 `reportType` 参数 | | 6 | `/fin/operating-data` | 经营数据(药房专属) | 门店数、同店增长、新开/关闭 | `RPT_F10_OPDATA`(按行业走分表) | -| 7 | `/fin/notice` | 重大事项 | 重组/激励/回购/担保 | `RPT_F10_NOTICE` 或事件流 | +| 7 | ~~`/fin/notice`~~ 重大事项 | ~~重组/激励/回购/担保~~ | ✅ 已实现 | 已拆为 6 个端点:`/fin/acquisitions`(并购)、`/fin/equity-incentive`(股权激励)、`/fin/litigation`(诉讼)、`/fin/related-transaction`(关联交易)、`/fin/violation`(违规)、`/fin/guarantee`(担保),模块 `utils/fin_major_events.py` | ### 🟢 低价值(看心情) diff --git a/tests/test_interfaces.py b/tests/test_interfaces.py index 7edc08d..f41825f 100644 --- a/tests/test_interfaces.py +++ b/tests/test_interfaces.py @@ -3,25 +3,58 @@ 运行: uv run pytest tests/ -q 注意: 依赖外网可达东财接口, 网络不通时用例会失败. """ +import inspect + import pandas as pd import pytest from utils.fin_balance import get_balance +from utils.fin_belong_index import get_belong_index, get_index_classif, get_index_membership from utils.fin_capital_structure import get_capital_structure from utils.fin_cashflow import get_cashflow +from utils.fin_company import get_company_info, get_industry from utils.fin_dividend import get_dividend_detail, get_dividend_statics from utils.fin_dupont import get_dupont +from utils.fin_equity import get_equity from utils.fin_free_holders import get_free_holders from utils.fin_growth import get_growth from utils.fin_holder_num import get_holder_num from utils.fin_holders import get_holders from utils.fin_income import get_income from utils.fin_main_business import get_main_business +from utils.fin_major_events import ( + get_acquisitions, + get_equity_incentive, + get_guarantee, + get_litigation, + get_related_transaction, + get_violation, +) +from utils.fin_mda import get_mda from utils.fin_operate import get_operate_ability from utils.fin_profitability import get_profitability STOCK = "603233.SH" +# 部分接口需要更多年份或其它股票才能取到数据 (否则按 1 年 + 默认股票) +CASE_OVERRIDES = { + "get_mda": {"years": 5}, + "get_litigation": {"years": 3}, + "get_violation": {"years": 3}, + "get_equity_incentive": {"years": 5, "stock": "300750.SZ"}, + "get_guarantee": {"years": 3, "stock": "300750.SZ"}, +} + + +def _call(func, stock=STOCK): + """按函数签名与覆盖配置调用.""" + over = CASE_OVERRIDES.get(func.__name__, {}) + s = over.get("stock", stock) + if "years" in inspect.signature(func).parameters: + return func([s], years=over.get("years", 1)) + return func([s]) + + # (模块函数, 该接口应返回的关键列, 测试名) CASES = [ pytest.param(get_main_business, "MAIN_BUSINESS_INCOME", id="main_business"), @@ -38,13 +71,26 @@ CASES = [ pytest.param(get_free_holders, "HOLDER_NAME", id="free_holders"), pytest.param(get_holders, "HOLDER_NAME", id="holders"), pytest.param(get_holder_num, "HOLDER_TOTAL_NUM", id="holder_num"), + pytest.param(get_mda, "REPORT_NAME", id="mda"), + pytest.param(get_equity, "TOTAL_SHARES", id="equity"), + pytest.param(get_company_info, "ORG_NAME", id="company_info"), + pytest.param(get_industry, "INDUSTRY_NAME", id="industry"), + pytest.param(get_index_classif, "INDEX_CLASSIF", id="index_classif"), + pytest.param(get_belong_index, "INDEX_NUM", id="belong_index"), + pytest.param(get_index_membership, "INDEX_NAME_ABBR", id="index_membership"), + pytest.param(get_acquisitions, "PLAN_PROCESS", id="acquisitions"), + pytest.param(get_equity_incentive, "PLAN_PROCESS", id="equity_incentive"), + pytest.param(get_litigation, "CASE_NAME", id="litigation"), + pytest.param(get_related_transaction, "RELATED_PARTY", id="related_transaction"), + pytest.param(get_violation, "VIOLATE_TYPE", id="violation"), + pytest.param(get_guarantee, "GUAR_NAME", id="guarantee"), ] @pytest.mark.parametrize("func,key_col", CASES) def test_fetch_data(func, key_col): - """拉 1 年数据, 应返回非空 DataFrame 且含关键列.""" - df = func([STOCK], years=1) + """拉数据, 应返回非空 DataFrame 且含关键列.""" + df = _call(func) assert isinstance(df, pd.DataFrame) assert not df.empty, f"{func.__name__} 未返回数据" assert "SECUCODE" in df.columns, f"{func.__name__} 缺少 SECUCODE 列" @@ -54,7 +100,10 @@ def test_fetch_data(func, key_col): @pytest.mark.parametrize("func,_key_col", CASES) def test_empty_codes(func, _key_col): """空代码列表应返回空 DataFrame, 不抛异常.""" - df = func([], years=1) + if "years" in inspect.signature(func).parameters: + df = func([], years=1) + else: + df = func([]) assert isinstance(df, pd.DataFrame) assert df.empty @@ -89,5 +138,18 @@ def test_api_routes(): "/fin/free-holders", "/fin/holders", "/fin/holder-num", + "/fin/mda", + "/fin/equity", + "/fin/company-info", + "/fin/industry", + "/fin/index-classif", + "/fin/belong-index", + "/fin/index-membership", + "/fin/acquisitions", + "/fin/equity-incentive", + "/fin/litigation", + "/fin/related-transaction", + "/fin/violation", + "/fin/guarantee", ]: assert route in paths, f"缺少路由 {route}" diff --git a/utils/_emchoice.py b/utils/_emchoice.py index cbf6d33..83feeb7 100644 --- a/utils/_emchoice.py +++ b/utils/_emchoice.py @@ -34,5 +34,9 @@ def fetch_report(url_or_params: str | dict[str, str]) -> list[dict]: r.raise_for_status() payload = r.json() if not payload.get("success"): + # 上游明确提示"数据为空"时按空结果处理 (如某股无股权激励/担保记录), 其余视为失败 + message = str(payload.get("message") or "") + if any(keyword in message for keyword in ("返回数据为空", "暂无数据", "无数据")): + return [] raise RuntimeError(f"接口返回失败: {payload}") return payload.get("result", {}).get("data") or [] diff --git a/utils/fin_belong_index.py b/utils/fin_belong_index.py new file mode 100644 index 0000000..73705cc --- /dev/null +++ b/utils/fin_belong_index.py @@ -0,0 +1,120 @@ +"""东方财富 CHOICE API 所属指数抓取工具。 +- 输入: 股票代码列表 (如 ['603233.SH']) +- 输出: pandas DataFrame (指数分类 / 纳入指数统计 / 指数成分权重) +- 不需要登录, 不需要 cookie, 无文件落盘 +""" +from __future__ import annotations + +import pandas as pd + +from utils._emchoice import fetch_report + + +def _fetch_one(secucode: str, params: dict[str, str]) -> pd.DataFrame: + """按给定参数拉一只股票的数据.""" + rows = fetch_report(params) + df = pd.DataFrame(rows) + if not df.empty: + df["SECUCODE"] = secucode + return df + + +def _concat(frames: list[pd.DataFrame]) -> pd.DataFrame: + if not frames: + return pd.DataFrame() + return pd.concat(frames, ignore_index=True) + + +# ---------------------------------------------------------------- 指数分类 + +def _build_classif_params(secucode: str) -> dict[str, str]: + """构造 RPT_HSF9_BELONGINDEX_CLASSIF 查询参数 (股票所属的指数分类).""" + return { + "reportName": "RPT_HSF9_BELONGINDEX_CLASSIF", + "columns": "INDEX_CLASSIF_CODE,INDEX_CLASSIF", + "quoteColumns": "", + "filter": f'(SECUCODE="{secucode}")(STR_TYPE="1")', + "distinct": "INDEX_CLASSIF_CODE,INDEX_CLASSIF", + "pageNumber": "", + "pageSize": "", + "source": "CHOICE", + "client": "SW", + } + + +def get_index_classif(secucodes: list[str]) -> pd.DataFrame: + """抓取指定股票所属的指数分类列表.""" + codes = [code.strip() for code in secucodes if code.strip()] + if not codes: + return pd.DataFrame() + + frames = [_fetch_one(code, _build_classif_params(code)) for code in codes] + return _concat([df for df in frames if not df.empty]) + + +# ---------------------------------------------------------------- 纳入指数统计 + +def _build_belong_index_params(secucode: str) -> dict[str, str]: + """构造 RPT_CUSTOM_HSF9_BELONG_INDEX 查询参数 (纳入指数数量统计).""" + return { + "reportName": "RPT_CUSTOM_HSF9_BELONG_INDEX", + "filter": f'(SECUCODE="{secucode}")', + "source": "CHOICE", + "client": "SW", + } + + +def get_belong_index(secucodes: list[str]) -> pd.DataFrame: + """抓取指定股票纳入指数的统计 (指数/基金数量).""" + codes = [code.strip() for code in secucodes if code.strip()] + if not codes: + return pd.DataFrame() + + frames = [_fetch_one(code, _build_belong_index_params(code)) for code in codes] + return _concat([df for df in frames if not df.empty]) + + +# ---------------------------------------------------------------- 指数成分权重 + +# 只保留主要指数分类 (剔除行业/风格类), 沿用 docs/api_docs.md 抓包值 +_INDEX_CLASSIF_LIKE = ( + '(INDEX_CLASSIF_CODE like "%001001%")(|INDEX_CLASSIF_CODE like "%001002%")' + '(|INDEX_CLASSIF_CODE like "%001003%")(|INDEX_CLASSIF_CODE like "%001004%")' + '(|INDEX_CLASSIF_CODE like "%001005%")(|INDEX_CLASSIF_CODE like "%001007%")' + '(|INDEX_CLASSIF_CODE like "%001009%")(|INDEX_CLASSIF_CODE like "%001010%")' + '(|INDEX_CLASSIF_CODE like "%001011%")(|INDEX_CLASSIF_CODE like "%001017%")' + '(|INDEX_CLASSIF_CODE like "%001020%")(|INDEX_CLASSIF_CODE like "%001022%")' + '(|INDEX_CLASSIF_CODE like "%001025%")(|INDEX_CLASSIF_CODE like "%001030%")' + '(|INDEX_CLASSIF_CODE like "%001006%")(|INDEX_CLASSIF_CODE like "%001024%")' + '(|INDEX_CLASSIF_CODE like "%013003%")(|INDEX_CLASSIF_CODE like "%014%")' +) + + +def _build_membership_params(secucode: str) -> dict[str, str]: + """构造 RPT_HSF9_BELONG_INDEX 查询参数 (指数成分及权重).""" + return { + "reportName": "RPT_HSF9_BELONG_INDEX", + "columns": ( + "INDEX_CODE,INDEX_INNER_CODE,TRADE_DATE,SECUCODE,SECURITY_CODE," + "SECURITY_NAME_ABBR,INCLUDE_DATE,MAKER_NAME,INDEX_NAME_ABBR," + "SECURITY_INNER_CODE,INDEX_CLASSIF_CODE,INDEX_CLASSIF,WEIGHT,NUM," + "TOTAL_SCALE,WEIGHT_DATE,CURRENCY" + ), + "filter": f'(SECUCODE="{secucode}")({_INDEX_CLASSIF_LIKE})', + "sortColumns": "NUM,INDEX_CODE", + "sortTypes": "-1,1", + "pageNumber": "1", + "pageSize": "50", + "source": "CHOICE", + "client": "SW", + } + + +def get_index_membership(secucodes: list[str]) -> pd.DataFrame: + """抓取指定股票所属的指数成分及权重.""" + codes = [code.strip() for code in secucodes if code.strip()] + if not codes: + return pd.DataFrame() + + frames = [_fetch_one(code, _build_membership_params(code)) for code in codes] + return _concat([df for df in frames if not df.empty]) diff --git a/utils/fin_company.py b/utils/fin_company.py new file mode 100644 index 0000000..55a55f4 --- /dev/null +++ b/utils/fin_company.py @@ -0,0 +1,84 @@ +"""东方财富 CHOICE API 公司介绍 + 所属行业抓取工具。 +- 输入: 股票代码列表 (如 ['603233.SH']) +- 输出: pandas DataFrame (公司基本资料 / 所属行业) +- 不需要登录, 不需要 cookie, 无文件落盘 +""" +from __future__ import annotations + +import pandas as pd + +from utils._emchoice import fetch_report + + +def _fetch_one(secucode: str, params: dict[str, str]) -> pd.DataFrame: + """按给定参数拉一只股票的数据.""" + rows = fetch_report(params) + df = pd.DataFrame(rows) + if not df.empty: + df["SECUCODE"] = secucode + return df + + +def _concat(frames: list[pd.DataFrame]) -> pd.DataFrame: + if not frames: + return pd.DataFrame() + return pd.concat(frames, ignore_index=True) + + +# ---------------------------------------------------------------- 公司介绍 + +def _build_company_params(secucode: str) -> dict[str, str]: + """构造 RPT_HSF9_BASIC_ORGINFO 查询参数 (公司基本资料).""" + return { + "source": "CHOICE", + "reportName": "RPT_HSF9_BASIC_ORGINFO", + "columns": "HSF9_ORGINFO", + "quoteColumns": "", + "filter": f'(SECUCODE="{secucode}")', + "pageNumber": "1", + "pageSize": "200", + "client": "SW", + } + + +def get_company_info(secucodes: list[str]) -> pd.DataFrame: + """抓取指定股票的公司介绍 (基本资料, 每只 1 行).""" + codes = [code.strip() for code in secucodes if code.strip()] + if not codes: + return pd.DataFrame() + + frames = [_fetch_one(code, _build_company_params(code)) for code in codes] + return _concat([df for df in frames if not df.empty]) + + +# ---------------------------------------------------------------- 所属行业 + +def _build_industry_params(secucode: str) -> dict[str, str]: + """构造 RPT_STOCKF9_INDUSTRY 查询参数 (当前生效的行业分类).""" + return { + "reportName": "RPT_STOCKF9_INDUSTRY", + "columns": ( + "SECUCODE,SECURITY_CODE,SECURITY_NAME_ABBR,INDUSTRY_TYPE_CODE," + "INDUSTRY_TYPE,INDUSTRY_NAME,INDUSTRY_CODE,SECURITY_INNER_CODE," + "INDUSTRY_NAME_MIN,INDUSTRY_CODE_MIN,ENTRY_DATE,OUT_DATE," + "SECURITY_TYPE_CODE,STATE,STATE_CODE,RN" + ), + "quoteColumns": "", + "filter": f'(SECUCODE="{secucode}")(STATE_CODE="1")', + "pageNumber": "", + "pageSize": "", + "sortTypes": "1", + "sortColumns": "RN", + "source": "CHOICE", + "client": "SW", + } + + +def get_industry(secucodes: list[str]) -> pd.DataFrame: + """抓取指定股票的所属行业 (当前生效分类).""" + codes = [code.strip() for code in secucodes if code.strip()] + if not codes: + return pd.DataFrame() + + frames = [_fetch_one(code, _build_industry_params(code)) for code in codes] + return _concat([df for df in frames if not df.empty]) diff --git a/utils/fin_equity.py b/utils/fin_equity.py new file mode 100644 index 0000000..88925a9 --- /dev/null +++ b/utils/fin_equity.py @@ -0,0 +1,57 @@ +"""东方财富 CHOICE API 股本结构抓取工具。 +- 输入: 股票代码列表 (如 ['603233.SH']) +- 输出: pandas DataFrame (按报告期末的股本结构, 全量历史) +- 不需要登录, 不需要 cookie, 无文件落盘 +""" +from __future__ import annotations + +import pandas as pd + +from utils._emchoice import fetch_report + + +_REPORT_NAME = "RPT_F10_EH_EQUITY" +_COLUMNS = "CHOICE_F9_EH_EQUITY" + + +def _build_params(secucode: str, end_date: str) -> dict[str, str]: + """构造 RPT_F10_EH_EQUITY 查询参数.""" + return { + "source": "CHOICE", + "reportName": _REPORT_NAME, + "columns": _COLUMNS, + "quoteColumns": "", + "pageNumber": "1", + "pageSize": "", + "sortColumns": "END_DATE", + "client": "SW", + "filter": f'(SECUCODE="{secucode}")(LISTING_DATE>=\'1900-01-01\')(LISTING_DATE<=\'{end_date}\')', + "sortTypes": "-1", + } + + +def _fetch_one(secucode: str) -> pd.DataFrame: + """拉一只股票的股本结构 (全量历史).""" + end_date = pd.Timestamp.today().strftime("%Y-%m-%d") + rows = fetch_report(_build_params(secucode, end_date)) + df = pd.DataFrame(rows) + if not df.empty: + df["SECUCODE"] = secucode + return df + + +def get_equity(secucodes: list[str]) -> pd.DataFrame: + """抓取指定股票的股本结构(全量历史)并按股票合并。""" + codes = [code.strip() for code in secucodes if code.strip()] + if not codes: + return pd.DataFrame() + + frames: list[pd.DataFrame] = [] + for code in codes: + df = _fetch_one(code) + if not df.empty: + frames.append(df) + + if not frames: + return pd.DataFrame() + return pd.concat(frames, ignore_index=True) diff --git a/utils/fin_major_events.py b/utils/fin_major_events.py new file mode 100644 index 0000000..23c4140 --- /dev/null +++ b/utils/fin_major_events.py @@ -0,0 +1,222 @@ +"""东方财富 CHOICE API 重大事项抓取工具 (并购/股权激励/诉讼/关联交易/违规/担保)。 +- 输入: 股票代码列表 (如 ['603233.SH']) +- 输出: pandas DataFrame, 六类事项各一个入口函数 +- 不需要登录, 不需要 cookie, 无文件落盘 +""" +from __future__ import annotations + +import pandas as pd + +from utils._emchoice import fetch_report + + +def _date_range(years: int) -> tuple[str, str]: + """按年份数生成 [start, end] 日期区间.""" + today = pd.Timestamp.today() + start = (today - pd.DateOffset(years=years)).strftime("%Y-%m-%d") + end = today.strftime("%Y-%m-%d") + return start, end + + +def _year_range(years: int) -> tuple[str, str]: + """按年份数生成 [start_year, end_year] 区间 (字符串).""" + end_year = pd.Timestamp.today().year + start_year = end_year - years + 1 + return str(start_year), str(end_year) + + +def _fetch_one(secucode: str, years: int, build_params) -> pd.DataFrame: + """按给定参数构造器拉一只股票的数据.""" + rows = fetch_report(build_params(secucode, years)) + df = pd.DataFrame(rows) + if not df.empty: + df["SECUCODE"] = secucode + return df + + +def _run(secucodes: list[str], years: int, build_params) -> pd.DataFrame: + codes = [code.strip() for code in secucodes if code.strip()] + if not codes: + return pd.DataFrame() + + frames = [_fetch_one(code, years, build_params) for code in codes] + frames = [df for df in frames if not df.empty] + if not frames: + return pd.DataFrame() + return pd.concat(frames, ignore_index=True) + + +# ---------------------------------------------------------------- 并购事件 + +def _build_acquisitions_params(secucode: str, years: int) -> dict[str, str]: + start, end = _date_range(years) + return { + "reportName": "RPT_HS_ACQUISITIONS_EVENT", + "columns": ( + "SECUCODE,SECURITY_CODE,FIRST_NOTICE_DATE,NOTICE_DATE,PLAN_PROCESS," + "NAME_OBJ,NAME_BUY,MXID,NAME_SELL,DEAL_AMT,TRANSFER_RATIO,TRADTE_CURRENCY" + ), + "quoteColumns": "", + "filter": ( + f'(SECUCODE="{secucode}")(FIRST_NOTICE_DATE>=\'{start}\')' + f"(FIRST_NOTICE_DATE<='{end}')" + ), + "sortTypes": "-1,1", + "sortColumns": "FIRST_NOTICE_DATE,MXID", + "requestParams": "1", + "pageNumber": "1", + "pageSize": "50", + "client": "SW", + } + + +def get_acquisitions(secucodes: list[str], years: int = 3) -> pd.DataFrame: + """抓取指定股票的并购事件。""" + return _run(secucodes, years, _build_acquisitions_params) + + +# ---------------------------------------------------------------- 股权激励 + +def _build_equity_incentive_params(secucode: str, years: int) -> dict[str, str]: + start, end = _date_range(years) + return { + "reportName": "RPT_DMSK_SH_EQUITYINCENTIVE", + "columns": ( + "SECUCODE,ORG_CODE,PLAN_PROCESS,EI_TARGET,EI_WAY,TOTAL_INCENTIVE_SHARES," + "TOTAL_INCENTIVE_TSR,INITIAL_EXERCISE_PRICE,VALID_EXPIRE,PLAN_NOTICE_DATE," + "GMDECISION_NOTICE_DATE,FIRST_NOTICE_DATE,PLAN_EXPLAIN,PRICE_EXPLAIN," + "INCENTIVE_SELL_EXPLAIN,INCENTIVE_GRANT_CONDITION,EXERCISE_SPECIAL_CONDITION," + "NOTICE_DATE,STR_YEAR,SECURITY_NAME_ABBR,SECURITY_CODE,INDUSTRYCSRC," + "EM_INDUSTRY,PLAN_NUM" + ), + "filter": ( + f'(SECUCODE="{secucode}")(PLAN_NOTICE_DATE>=\'{start}\')' + f"(PLAN_NOTICE_DATE<='{end}')" + ), + "sortTypes": "-1", + "sortColumns": "PLAN_NOTICE_DATE", + "source": "CHOICE", + "client": "SW", + } + + +def get_equity_incentive(secucodes: list[str], years: int = 3) -> pd.DataFrame: + """抓取指定股票的股权激励方案。""" + return _run(secucodes, years, _build_equity_incentive_params) + + +# ---------------------------------------------------------------- 诉讼仲裁 + +def _build_litigation_params(secucode: str, years: int) -> dict[str, str]: + start, end = _year_range(years) + return { + "source": "CHOICE", + "reportName": "RPT_LITIGATION_ARBITRATION_BSINFO", + "columns": ( + "ORG_CODE,SECUCODE,SECURITY_CODE,SECURITY_NAME_ABBR,NOTICE_DATE," + "CASE_NAME,CASE_PROFILE,PLAINTIFF,DEFENCE,LITIGATION_TYPE," + "FI_PROSECUTE_DATE,FI_ACCEPTANCE_COURT,CASE_AMOUNT,FI_JUDGMENT_DATE," + "FI_JUDGMENT_CONTENT,FI_IS_APPEAL,SI_APPELLEE,SI_ACCEPTANCE_COURT," + "SI_JUDGMENT_DATE,SI_JUDGMENT_CONTENT,EXECUTE_SITUATION,CURRENCY,NOTICE_YEAR" + ), + "quoteColumns": "", + "filter": f'(SECUCODE="{secucode}")(NOTICE_YEAR>="{start}")(NOTICE_YEAR<="{end}")', + "pageNumber": "", + "pageSize": "", + "sortTypes": "-1", + "sortColumns": "NOTICE_DATE", + "client": "SW", + } + + +def get_litigation(secucodes: list[str], years: int = 3) -> pd.DataFrame: + """抓取指定股票的诉讼仲裁信息。""" + return _run(secucodes, years, _build_litigation_params) + + +# ---------------------------------------------------------------- 关联交易 + +def _build_related_transaction_params(secucode: str, years: int) -> dict[str, str]: + start, end = _date_range(years) + return { + "reportName": "RPT_RELATED_TRANSACTION", + "columns": ( + "SECUCODE,SECURITY_CODE,SECURITY_NAME_ABBR,ORG_CODE,NOTICE_DATE," + "RELATED_PARTY,RELATED_RELATION,IS_CONTROL,TRADE_AMT,PAY_WAY," + "TRADE_PROFILE,PRICE_BASIS,TRADE_START_DATE,TRADE_END_DATE,CURRENCY,STR_YEAR" + ), + "quoteColumns": "", + # 抓包中该报表 filter 含分号, 保留原样 + "filter": ( + f'(SECUCODE="{secucode}");(NOTICE_DATE>=\'{start}\')' + f"(NOTICE_DATE<='{end}')" + ), + "pageNumber": "", + "pageSize": "", + "sortTypes": "-1", + "sortColumns": "NOTICE_DATE", + "client": "SW", + "source": "CHOICE", + } + + +def get_related_transaction(secucodes: list[str], years: int = 3) -> pd.DataFrame: + """抓取指定股票的关联交易。""" + return _run(secucodes, years, _build_related_transaction_params) + + +# ---------------------------------------------------------------- 违规 + +def _build_violation_params(secucode: str, years: int) -> dict[str, str]: + start, end = _date_range(years) + return { + "source": "CHOICE", + "reportName": "RPT_HSF9_OP_VIOLATION", + "columns": ( + "ORG_CODE,NOTICE_DATE,NOTICE_YEAR,CURRENCY,SECUCODE,SECURITY_CODE," + "SECURITY_NAME_ABBR,SOLVE_ORG,VIOLATE_TYPE,PUNISH_TYPE,PUNISH_AMT," + "VIOLATE_BEHAVIOUR,PUNISH_STEP,PUNISH_OBJECT" + ), + "quoteColumns": "", + # 抓包中该报表 secucode 为小写, 保留原样 + "filter": ( + f'(secucode="{secucode}")(NOTICE_DATE>=\'{start}\')' + f"(NOTICE_DATE<='{end}')" + ), + "pageNumber": "1", + "pageSize": "", + "sortTypes": "-1", + "sortColumns": "NOTICE_DATE", + "client": "SW", + } + + +def get_violation(secucodes: list[str], years: int = 3) -> pd.DataFrame: + """抓取指定股票的违规处罚记录。""" + return _run(secucodes, years, _build_violation_params) + + +# ---------------------------------------------------------------- 担保 + +def _build_guarantee_params(secucode: str, years: int) -> dict[str, str]: + start, end = _year_range(years) + return { + "source": "CHOICE", + "reportName": "RPT_F9_GUARANTEE", + "columns": ( + "SECURITY_CODE,SECUCODE,SECURITY_NAME_ABBR,REPORT_DATE,GUAR_NAME," + "GUARANTEED_NAME,GUARANTEE_START_DATE,GUARANTEE_END_DATE,IS_PERFORM," + "TRADE_DATE,GUARANTEE_WAY,GUARANTEE_AMT,GUARANTEE_EXPIRE," + "IS_RELATED_TRADE,CURRENCY,REPORT_YEAR,HISTORYCHG,ORG_CODE" + ), + "quoteColumns": "", + "filter": f'(SECUCODE="{secucode}")(REPORT_YEAR>="{start}")(REPORT_YEAR<="{end}")', + "sortTypes": "-1", + "sortColumns": "REPORT_DATE", + "client": "SW", + } + + +def get_guarantee(secucodes: list[str], years: int = 3) -> pd.DataFrame: + """抓取指定股票的对外担保记录。""" + return _run(secucodes, years, _build_guarantee_params) diff --git a/utils/fin_mda.py b/utils/fin_mda.py new file mode 100644 index 0000000..2380f80 --- /dev/null +++ b/utils/fin_mda.py @@ -0,0 +1,73 @@ +"""东方财富 CHOICE API 管理层讨论与分析(MD&A)抓取工具。 +- 输入: 股票代码列表 (如 ['603233.SH']) +- 输出: pandas DataFrame (年报/中报的董事会报告索引, 含标题与页码) +- 不需要登录, 不需要 cookie, 无文件落盘 +""" +from __future__ import annotations + +import pandas as pd + +from utils._emchoice import fetch_report + + +_REPORT_NAME = "RPT_BUSINESSANALYSIS" +# 列清单沿用 docs/api_docs.md 抓包值 +_COLUMNS = ( + "ORG_CODE,SECUCODE,SECURITY_CODE,SECURITY_INNER_CODE,SECURITY_NAME_ABBR," + "REPORT_DATE,REPORT_NAME,NOTICE_DATE,SOURCE,RELINFOCODE,TITLE,PAGE" +) +# 只取年报/中报的讨论与分析 +_REPORT_NAMES = '"年报","中报"' + + +def _build_params( + secucode: str, + start_date: str, + end_date: str, +) -> dict[str, str]: + """构造 RPT_BUSINESSANALYSIS 查询参数.""" + return { + "source": "CHOICE", + "reportName": _REPORT_NAME, + "columns": _COLUMNS, + "quoteColumns": "", + "filter": ( + f'(SECUCODE="{secucode}")(REPORT_NAME in ({_REPORT_NAMES}))' + f"(REPORT_DATE<='{end_date}')(REPORT_DATE>='{start_date}')" + ), + "pageNumber": "1", + "pageSize": "50", + "sortTypes": "-1", + "sortColumns": "REPORT_DATE", + "client": "SW", + } + + +def _fetch_one(secucode: str, years: int) -> pd.DataFrame: + """拉一只股票的管理层讨论与分析.""" + today = pd.Timestamp.today() + start = (today - pd.DateOffset(years=years)).strftime("%Y-%m-%d") + end = today.strftime("%Y-%m-%d") + + rows = fetch_report(_build_params(secucode, start, end)) + df = pd.DataFrame(rows) + if not df.empty: + df["SECUCODE"] = secucode + return df + + +def get_mda(secucodes: list[str], years: int = 5) -> pd.DataFrame: + """抓取指定股票的管理层讨论与分析(年报/中报)并按股票合并。""" + codes = [code.strip() for code in secucodes if code.strip()] + if not codes: + return pd.DataFrame() + + frames: list[pd.DataFrame] = [] + for code in codes: + df = _fetch_one(code, years) + if not df.empty: + frames.append(df) + + if not frames: + return pd.DataFrame() + return pd.concat(frames, ignore_index=True)