From 797369bbed03aa29bc1ec596d806fe7bde95e158 Mon Sep 17 00:00:00 2001 From: Jack Date: Sat, 15 Aug 2026 10:33:47 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E4=B8=89=E5=A4=A7=E8=82=A1?= =?UTF-8?q?=E4=B8=9C=E6=8E=A5=E5=8F=A3,=20=E8=A1=A5=E5=85=85=20SKILLS.md?= =?UTF-8?q?=20=E4=BD=BF=E7=94=A8=E8=AF=B4=E6=98=8E=E4=B9=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 十大流通股东/十大股东明细/股东户数 三个抓取模块并接入 API - 冒烟测试扩展至 30 用例, 全部通过 - 新增 SKILLS.md (给其他 agent 使用的工具说明书) - 补充待优化端点清单与文档对齐规范 --- SKILLS.md | 139 +++++++++++++++++++++++++++++++++++ api.py | 66 +++++++++++++++++ docs/api_docs.md | 61 +++++++++++++++ docs/api_manual.md | 37 +++++++++- docs/blueprint.md | 13 +++- docs/update_standard.md | 3 + docs/待优化-2026-08-15.md | 38 ++++++++++ tests/test_interfaces.py | 9 +++ utils/fin_free_holders.py | 64 ++++++++++++++++ utils/fin_holder_num.py | 64 ++++++++++++++++ utils/fin_holders.py | 70 ++++++++++++++++++ 11 files changed, 561 insertions(+), 3 deletions(-) create mode 100644 SKILLS.md create mode 100644 docs/update_standard.md create mode 100644 docs/待优化-2026-08-15.md create mode 100644 utils/fin_free_holders.py create mode 100644 utils/fin_holder_num.py create mode 100644 utils/fin_holders.py diff --git a/SKILLS.md b/SKILLS.md new file mode 100644 index 0000000..23e1b33 --- /dev/null +++ b/SKILLS.md @@ -0,0 +1,139 @@ +# SKILLS.md — EMWeb API 使用说明书 + +> 本工具是一个 HTTP 财务数据服务,聚合了东方财富 CHOICE 的财务数据抓取能力。 +> **无鉴权、无 cookie、无 key**,直接 GET 即可调用,返回结构化 JSON。 +> 适合任何需要 A 股上市公司财务数据/股东/分红信息的 agent 使用。 + +--- + +## 1. 基本信息 + +| 项 | 值 | +|---|---| +| Base URL | `http://<服务地址>:8765` | +| 鉴权 | 无 | +| 数据覆盖 | A 股财务三表、财务指标、主营构成、分红、股东数据 | +| Swagger | `http://<服务地址>:8765/docs`(可视化调试) | + +## 2. 通用约定 + +- **股票代码格式**:`代码.市场`,如 `603233.SH`(上交所)、`000001.SZ`(深交所)。多只用英文逗号分隔:`603233.SH,600519.SH`。 +- **`years` 参数**:拉取最近 N 年数据,范围 1~20。 +- **返回结构**(成功): + +```json +{ + "success": true, + "count": 10, + "columns": ["SECUCODE", "REPORT_DATE", "..."], + "data": [ { "SECUCODE": "603233.SH", ... } ] +} +``` + +- **错误情况**: + - HTTP `400`:参数不合法(如 `stocks` 为空)。 + - HTTP `404` + `success: false`:请求成功但无数据(股票代码写错、或该股无此数据)。此时 `data` 为 `[]`,不要重试。 + - HTTP `5xx`:上游接口异常,可稍后重试。 + - 空值(NaN/NaT/±inf)在 JSON 中统一为 `null`。 + +## 3. 接口总览 + +| 类别 | 路径 | 一句话说明 | +|---|---|---| +| 健康 | `GET /health` | 探活 | +| 财务摘要 | `GET /fin/summary` | 财务摘要(报告期口径,需 ORG_CODE) | +| 财务摘要 | `GET /fin/quarterly-summary` | 财务摘要(单季度,只需代码) | +| 主营 | `GET /fin/main-business` | 主营构成(按产品 / 按地区) | +| 报表 | `GET /fin/balance` | 资产负债表 | +| 报表 | `GET /fin/income` | 利润表 | +| 报表 | `GET /fin/cashflow` | 现金流量表 | +| 指标 | `GET /fin/profitability` | 盈利能力与收益质量 | +| 指标 | `GET /fin/capital-structure` | 资本结构与偿债能力 | +| 指标 | `GET /fin/operate-ability` | 营运能力 | +| 指标 | `GET /fin/growth` | 成长能力 | +| 指标 | `GET /fin/dupont` | 杜邦分析(ROE 拆解) | +| 分红 | `GET /fin/dividend-detail` | 分红明细(方案/除权日等) | +| 分红 | `GET /fin/dividend-statics` | 分红统计(按年汇总) | +| 股东 | `GET /fin/free-holders` | 十大流通股东 | +| 股东 | `GET /fin/holders` | 十大股东明细 | +| 股东 | `GET /fin/holder-num` | 股东户数 | + +## 4. 接口详情 + +### 4.1 快速探活 + +```bash +curl "http://127.0.0.1:8765/health" +# => {"status":"ok"} +``` + +### 4.2 财务摘要 + +```bash +# 单季度 (只需代码) —— 适合拿季度经营数据 +curl "http://127.0.0.1:8765/fin/quarterly-summary?stocks=603233.SH&years=3" + +# 报告期口径 (推荐带 ORG_CODE, 否则可能查不到, 详见第 5 节) +curl "http://127.0.0.1:8765/fin/summary?stocks=603233.SH:10500736&years=5" +``` + +### 4.3 主营构成 + +```bash +# 按产品分类 (中文参数需要 URL 编码: 产品=%E4%BA%A7%E5%93%81, 地区=%E5%9C%B0%E5%8C%BA) +curl "http://127.0.0.1:8765/fin/main-business?stocks=603233.SH&classify_type=%E4%BA%A7%E5%93%81&years=3" +``` + +关键列:`STD_REPORT_NAME`(产品/地区名,含"合计"行)、`MAIN_BUSINESS_INCOME`、`MAIN_BUSINESS_RPOFIT`、`GROSS_RPOFIT_RATIO`(毛利率)。 + +### 4.4 三张财务报表 + +```bash +curl "http://127.0.0.1:8765/fin/balance?stocks=603233.SH&years=3" # 资产负债表, 列含 TOTAL_ASSETS 等 +curl "http://127.0.0.1:8765/fin/income?stocks=603233.SH&years=3" # 利润表, 列含 TOTAL_OPERATE_INCOME 等 +curl "http://127.0.0.1:8765/fin/cashflow?stocks=603233.SH&years=3" # 现金流量表, 列含 SALES_SERVICES 等 +``` + +### 4.5 财务指标(四大分析 + 杜邦) + +```bash +curl "http://127.0.0.1:8765/fin/profitability?stocks=603233.SH" # ROE/ROA/毛利率 +curl "http://127.0.0.1:8765/fin/capital-structure?stocks=603233.SH" # 资产负债率/流动比率 +curl "http://127.0.0.1:8765/fin/operate-ability?stocks=603233.SH" # 周转率/周转天数 +curl "http://127.0.0.1:8765/fin/growth?stocks=603233.SH" # 营收/净利同比增速 +curl "http://127.0.0.1:8765/fin/dupont?stocks=603233.SH" # ROE 三因素拆解 +``` + +### 4.6 分红 + +```bash +curl "http://127.0.0.1:8765/fin/dividend-detail?stocks=603233.SH&years=5" # 明细: IMPL_PLAN_PROFILE(方案)/EX_DIVIDEND_DATE(除权日) +curl "http://127.0.0.1:8765/fin/dividend-statics?stocks=603233.SH&years=5" # 统计: GXL(股息率)/AUALACCMDIV_ARD(每股股利) +``` + +### 4.7 股东数据 + +```bash +curl "http://127.0.0.1:8765/fin/free-holders?stocks=603233.SH&years=3" # 十大流通股东: HOLDER_NAME/HOLD_RATIO +curl "http://127.0.0.1:8765/fin/holders?stocks=603233.SH&years=3" # 十大股东明细: RANK/HOLDER_NAME/DIRECTION +curl "http://127.0.0.1:8765/fin/holder-num?stocks=603233.SH&years=3" # 股东户数: HOLDER_TOTAL_NUM/户数增减 +``` + +## 5. 使用注意事项 + +1. **只有 `/fin/summary` 需要 ORG_CODE**:格式 `代码:ORG_CODE`(如 `603233.SH:10500736`)。ORG_CODE 是东财内部机构编码,只传代码时服务会尝试自动查询但**不稳定**;如果 400 报错提示 ORG_CODE,请改用 `代码:ORG_CODE` 格式,或换用 `/fin/quarterly-summary`。 +2. **中文参数要 URL 编码**:仅 `classify_type`(产品/地区)一处,浏览器或 curl 请编码后传。 +3. **一次可查多只**:`stocks` 用逗号分隔,返回多股合并数据(每行带 `SECUCODE` 区分)。 +4. **列名不翻译**:`columns` 与上游东财接口一致(英文大写,如 `REPORT_DATE`、`TOTAL_ASSETS`),`data` 每行是键值对象,值可能为 `null`。 +5. **报告期**:财务数据按报告期返回,`REPORT_DATE`/`END_DATE` 字段为 `YYYY-MM-DD` 格式(JSON 中是字符串)。 +6. **频率**:接口为实时抓取,没有缓存;高频批量调用请控制节奏。 + +## 6. 常见组合场景 + +| 任务 | 推荐调用 | +|---|---| +| 快速体检一家公司 | `quarterly-summary` + `main-business` + `profitability` | +| 深度财务分析 | `balance` + `income` + `cashflow` + `dupont` | +| 成长性判断 | `growth` + `quarterly-summary` | +| 分红投资 | `dividend-detail` + `dividend-statics` | +| 股东结构/筹码分析 | `holders` + `free-holders` + `holder-num` | diff --git a/api.py b/api.py index 95b6e35..30f48c7 100644 --- a/api.py +++ b/api.py @@ -26,6 +26,9 @@ from utils.fin_operate import get_operate_ability from utils.fin_growth import get_growth from utils.fin_dupont import get_dupont 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 app = FastAPI( @@ -131,6 +134,9 @@ def root(): "GET /fin/dupont": "拉取杜邦分析", "GET /fin/dividend-detail": "拉取分红明细", "GET /fin/dividend-statics": "拉取分红统计", + "GET /fin/free-holders": "拉取十大流通股东", + "GET /fin/holders": "拉取十大股东明细", + "GET /fin/holder-num": "拉取股东户数", "GET /health": "健康检查", }, "usage": { @@ -498,6 +504,66 @@ def fin_dividend_statics( return payload +@app.get("/fin/free-holders") +def fin_free_holders( + stocks: str = Query(..., description="股票代码,逗号分隔,例如 '603233.SH'。仅支持代码,不需要 ORG_CODE。", examples=["603233.SH"]), + years: int = Query(3, ge=1, le=20, description="拉取最近多少年的十大流通股东"), +): + """拉取十大流通股东。""" + codes = [code.strip() for code in stocks.split(",") if code.strip()] + if not codes: + raise HTTPException(400, detail="stocks 参数不能为空") + + df = get_free_holders(codes, years=years) + if df.empty: + return _empty_response("无十大流通股东数据, 请检查股票代码或筛选条件", codes, years=years) + + payload = _df_to_json_payload(df) + payload["codes_requested"] = codes + payload["years"] = years + return payload + + +@app.get("/fin/holders") +def fin_holders( + stocks: str = Query(..., description="股票代码,逗号分隔,例如 '603233.SH'。仅支持代码,不需要 ORG_CODE。", examples=["603233.SH"]), + years: int = Query(3, ge=1, le=20, description="拉取最近多少年的十大股东明细"), +): + """拉取十大股东明细。""" + codes = [code.strip() for code in stocks.split(",") if code.strip()] + if not codes: + raise HTTPException(400, detail="stocks 参数不能为空") + + df = get_holders(codes, years=years) + if df.empty: + return _empty_response("无十大股东明细数据, 请检查股票代码或筛选条件", codes, years=years) + + payload = _df_to_json_payload(df) + payload["codes_requested"] = codes + payload["years"] = years + return payload + + +@app.get("/fin/holder-num") +def fin_holder_num( + stocks: str = Query(..., description="股票代码,逗号分隔,例如 '603233.SH'。仅支持代码,不需要 ORG_CODE。", examples=["603233.SH"]), + years: int = Query(3, ge=1, le=20, description="拉取最近多少年的股东户数"), +): + """拉取股东户数。""" + codes = [code.strip() for code in stocks.split(",") if code.strip()] + if not codes: + raise HTTPException(400, detail="stocks 参数不能为空") + + df = get_holder_num(codes, years=years) + if df.empty: + return _empty_response("无股东户数数据, 请检查股票代码或筛选条件", codes, years=years) + + payload = _df_to_json_payload(df) + payload["codes_requested"] = codes + payload["years"] = years + return payload + + 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 79072d9..1f83408 100644 --- a/docs/api_docs.md +++ b/docs/api_docs.md @@ -274,3 +274,64 @@ 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_FREEHOLDERS&columns=F10_FREEHOLDERS"eColumns=&pageNumber=&pageSize=&sortColumns=END_DATE,HOLD_NUM&client=SW&filter=(SECUCODE=%22603233.SH%22)(END_DATE%3E=%272024-01-01%27)(END_DATE%3C=%272026-12-31%27)&sortTypes=-1,-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 /api/data/v1/get?source=CHOICE&reportName=RPT_DMSK_HOLDERS&columns=SECUCODE,SECURITY_CODE,ORG_CODE,SECURITY_TYPE_CODE,END_DATE,RANK,HOLDER_CODE,HOLDER_NAME,HOLD_NUM,HOLD_RATIO,HOLD_NUM_CHANGE,HOLD_RATIO_CHANGE,DIRECTION,SHARES_TYPE,HOLDER_NATURE,REPORT_TYPE,HOLD_CHANGE,NOTICE_DATE,HOLD_RATIO_YOY,REPORT_DATE_NAME,REFERENCE_MARKET_CAP"eColumns=&filter=(SECUCODE=%22603233.SH%22)(END_DATE%3E=%272022-01-01%27)(END_DATE%3C=%272026-12-31%27)&pageNumber=&pageSize=&sortTypes=-1,1&sortColumns=END_DATE,RANK&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_HOLDERNUM&columns=CHOICEF9_EH_HOLDERNUM"eColumns=&filter=(SECUCODE=%22603233.SH%22)(END_DATE%3E=%272022-01-01%27)(END_DATE%3C=%272026-12-31%27)&pageNumber=&pageSize=&sortColumns=END_DATE&client=SW&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 + diff --git a/docs/api_manual.md b/docs/api_manual.md index 96cd889..2199b18 100644 --- a/docs/api_manual.md +++ b/docs/api_manual.md @@ -253,6 +253,38 @@ curl "http://127.0.0.1:8765/fin/dividend-statics?stocks=603233.SH&years=5" --- +## 8. 股东数据(十大流通股东 / 十大股东明细 / 股东户数) + +**公共参数** + +| 参数 | 类型 | 必填 | 默认 | 说明 | +|---|---|---|---|---| +| `stocks` | string | ✅ | — | 逗号分隔股票代码,如 `603233.SH`。仅需代码,不需要 ORG_CODE | +| `years` | int | | `3` | 拉取最近多少年的数据(1~20) | + +**示例** + +```bash +# 十大流通股东 +curl "http://127.0.0.1:8765/fin/free-holders?stocks=603233.SH&years=3" + +# 十大股东明细 +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_NAME`(股东名)、`HOLD_NUM`(持股数)、`HOLD_RATIO`、`FREE_HOLDNUM_RATIO`(占流通股比)、`HOLD_CHANGE` 等 | +| 十大股东明细 | `RANK`(排名)、`HOLDER_NAME`、`HOLD_NUM`、`HOLD_RATIO`、`DIRECTION`(增减持方向)、`NOTICE_DATE` 等 | +| 股东户数 | `HOLDER_TOTAL_NUM`(股东总数)、`HOLDER_TOTAL_NUMCHANGE`(户数增减)、`AVG_TOTAL_SHARES`(户均持股)等 | + +--- + ## 接口一览 | 路径 | 功能 | 数据来源报表 | @@ -271,9 +303,12 @@ curl "http://127.0.0.1:8765/fin/dividend-statics?stocks=603233.SH&years=5" | `/fin/dupont` | 杜邦分析 | `RPT_HSF9_FINA_DUPONT` | | `/fin/dividend-detail` | 分红明细 | `RPT_HSF9_ASSIGNPLAN_DETAIL` | | `/fin/dividend-statics` | 分红统计 | `RPT_HSF9_ASSIGNPLAN_STATICS` | +| `/fin/free-holders` | 十大流通股东 | `RPT_F10_EH_FREEHOLDERS` | +| `/fin/holders` | 十大股东明细 | `RPT_DMSK_HOLDERS` | +| `/fin/holder-num` | 股东户数 | `RPT_F10_EH_HOLDERNUM` | ## 测试 ```bash -uv run pytest tests/ -q # 冒烟测试: 连真实接口验证各模块能取到数据 +uv run pytest tests/ -q # 冒烟测试: 连真实接口验证各模块能取到数据 (30 用例) ``` diff --git a/docs/blueprint.md b/docs/blueprint.md index 110c03d..e622f2b 100644 --- a/docs/blueprint.md +++ b/docs/blueprint.md @@ -35,7 +35,10 @@ EMWebApi/ │ ├── fin_operate.py # 营运能力 │ ├── fin_growth.py # 成长能力 │ ├── fin_dupont.py # 杜邦分析 -│ └── fin_dividend.py # 分红明细 + 分红统计 +│ ├── fin_dividend.py # 分红明细 + 分红统计 +│ ├── fin_free_holders.py # 十大流通股东 +│ ├── fin_holders.py # 十大股东明细 +│ ├── fin_holder_num.py # 股东户数 ├── tests/ │ └── test_interfaces.py # 冒烟测试 (连真实接口) └── docs/ @@ -65,6 +68,9 @@ EMWebApi/ | 9 | 成长能力 | `CWFX_CZNL` → `RPT_HSF9_FINA_GROWTH` | ✅ 已实现 | | 10 | 杜邦分析 | `RPT_HSF9_FINA_DUPONT` | ✅ 已实现 | | 11 | 分红明细 + 分红统计 | `RPT_HSF9_ASSIGNPLAN_DETAIL` / `_STATICS` | ✅ 已实现 | +| 12 | 十大流通股东 | `RPT_F10_EH_FREEHOLDERS` | ✅ 已实现 | +| 13 | 十大股东明细 | `RPT_DMSK_HOLDERS` | ✅ 已实现 | +| 14 | 股东户数 | `RPT_F10_EH_HOLDERNUM` | ✅ 已实现 | **文档外补充**:财务摘要(报告期) 用 `RPT_CUSTOM_ORGF9_FIN_SUMMARY`,需 ORG_CODE(不在 api_docs.md 里)。 @@ -88,7 +94,10 @@ EMWebApi/ | 成长能力 | `utils/fin_growth.py` | `GET /fin/growth` | | 杜邦分析 | `utils/fin_dupont.py` | `GET /fin/dupont` | | 分红明细 + 统计 | `utils/fin_dividend.py` | `GET /fin/dividend-detail` / `dividend-statics` | -| 测试 | `tests/test_interfaces.py` | `uv run pytest tests/ -q` (24 用例) | +| 十大流通股东 | `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 用例) | ### 🧹 待清理 diff --git a/docs/update_standard.md b/docs/update_standard.md new file mode 100644 index 0000000..5092855 --- /dev/null +++ b/docs/update_standard.md @@ -0,0 +1,3 @@ +- 每次更新需要需要检查 + - 代码和文档的对齐 + - 代码/文档 和 skills 对齐 \ No newline at end of file diff --git a/docs/待优化-2026-08-15.md b/docs/待优化-2026-08-15.md new file mode 100644 index 0000000..8ebe1b1 --- /dev/null +++ b/docs/待优化-2026-08-15.md @@ -0,0 +1,38 @@ + +--- + +## 你需要补的 EMWebApi 端点清单 + +按价值排序,按工作量排序(带推测的 CHOICE 接口 ID): + +### 🔴 高价值(建议优先补) + +| # | 端点名 | 对应路径 | 用途 | 推测 CHOICE 接口 | +|---|--------|---------|------|-----------------| +| 1 | `/fin/holder` | 股东户数 + 十大股东 | 筹码集中度 + 机构动向 | `RPT_F10_EH_EHD` / `RPT_F10_SHAREHOLDER` | +| 2 | `/fin/valuation-history` | 历史 PE/PB 分位 | 当前估值贵不贵 | `RPT_VALUATION_ANALYSIS` | +| 3 | `/fin/profit-forecast` | 卖方一致预期 | 未来 EPS 预测 + 评级 | `RPT_PROFIT_FORECAST` 或 `RPT_F10_PROFITNOTE` | +| 4 | `/fin/industry-compare` | 行业横向对比 | 在行业里的位置 | `RPT_INDUSTRY_COMP` | + +### 🟡 中价值(看时间补) + +| # | 端点名 | 对应路径 | 用途 | 推测 CHOICE 接口 | +|---|--------|---------|------|-----------------| +| 5 | `/fin/cashflow-quarterly` | 单季度现金流量表 | 季度经营性现金流 | 在现有 `fin/cashflow` 上加 `reportType` 参数 | +| 6 | `/fin/operating-data` | 经营数据(药房专属) | 门店数、同店增长、新开/关闭 | `RPT_F10_OPDATA`(按行业走分表) | +| 7 | `/fin/notice` | 重大事项 | 重组/激励/回购/担保 | `RPT_F10_NOTICE` 或事件流 | + +### 🟢 低价值(看心情) + +| # | 端点名 | 对应路径 | 用途 | 推测 CHOICE 接口 | +|---|--------|---------|------|-----------------| +| 8 | `/fin/management` | 管理层介绍 + 持股 | 老板背景、薪酬 | `RPT_F10_MANAGEMENT` | +| 9 | `/fin/top-flow` | 大宗交易 + 高管增减持 | 减持信号 | `RPT_F10_BIGTRADE` / `RPT_F10_HOLDERCHANGE` | + +### 💡 实现提示 + +1. **`fin/operating-data` 是药房/银行/地产的行业专属接口**,CHOICE 不会给一个通用端点,可能要按行业分多个子接口(`fin/op-data-pharmacy` / `fin/op-data-bank` ...) +2. **`fin/holder` 通常分两张**:股东户数 + 十大股东明细,建议拆成两个端点更清晰 +3. **接口 ID 不确定的话**:你先写一个能跑通的 demo(比如 `summary`),我这边按你的命名风格对照补其它4. **每个新接口都建议加 `org_code` 参数**(`stocks=603233.SH:10500736`格式),少走自动查询这一步,更稳 + +**优先级建议**:今晚先攻 1+2+3(持仓 + 估值历史 + 盈利预测),这三个对大参林的报告补全是**质变级别**的提升。其它周末慢慢来。 \ No newline at end of file diff --git a/tests/test_interfaces.py b/tests/test_interfaces.py index c73957d..7edc08d 100644 --- a/tests/test_interfaces.py +++ b/tests/test_interfaces.py @@ -11,7 +11,10 @@ from utils.fin_capital_structure import get_capital_structure from utils.fin_cashflow import get_cashflow from utils.fin_dividend import get_dividend_detail, get_dividend_statics from utils.fin_dupont import get_dupont +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_operate import get_operate_ability @@ -32,6 +35,9 @@ CASES = [ pytest.param(get_dupont, "ROE", id="dupont"), pytest.param(get_dividend_detail, "IMPL_PLAN_PROFILE", id="dividend_detail"), pytest.param(get_dividend_statics, "AUALACCMDIV_ARD", id="dividend_statics"), + 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"), ] @@ -80,5 +86,8 @@ def test_api_routes(): "/fin/dupont", "/fin/dividend-detail", "/fin/dividend-statics", + "/fin/free-holders", + "/fin/holders", + "/fin/holder-num", ]: assert route in paths, f"缺少路由 {route}" diff --git a/utils/fin_free_holders.py b/utils/fin_free_holders.py new file mode 100644 index 0000000..02c6891 --- /dev/null +++ b/utils/fin_free_holders.py @@ -0,0 +1,64 @@ +"""东方财富 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_FREEHOLDERS" +_COLUMNS = "F10_FREEHOLDERS" + + +def _build_params( + secucode: str, + start_date: str, + end_date: str, +) -> dict[str, str]: + """构造 RPT_F10_EH_FREEHOLDERS 查询参数.""" + return { + "source": "CHOICE", + "reportName": _REPORT_NAME, + "columns": _COLUMNS, + "quoteColumns": "", + "pageNumber": "", + "pageSize": "", + "sortColumns": "END_DATE,HOLD_NUM", + "client": "SW", + "filter": f'(SECUCODE="{secucode}")(END_DATE>=\'{start_date}\')(END_DATE<=\'{end_date}\')', + "sortTypes": "-1,-1", + } + + +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_free_holders(secucodes: list[str], years: int = 3) -> 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) diff --git a/utils/fin_holder_num.py b/utils/fin_holder_num.py new file mode 100644 index 0000000..047ecc9 --- /dev/null +++ b/utils/fin_holder_num.py @@ -0,0 +1,64 @@ +"""东方财富 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_HOLDERNUM" +_COLUMNS = "CHOICEF9_EH_HOLDERNUM" + + +def _build_params( + secucode: str, + start_date: str, + end_date: str, +) -> dict[str, str]: + """构造 RPT_F10_EH_HOLDERNUM 查询参数.""" + return { + "source": "CHOICE", + "reportName": _REPORT_NAME, + "columns": _COLUMNS, + "quoteColumns": "", + "filter": f'(SECUCODE="{secucode}")(END_DATE>=\'{start_date}\')(END_DATE<=\'{end_date}\')', + "pageNumber": "", + "pageSize": "", + "sortColumns": "END_DATE", + "client": "SW", + "sortTypes": "-1", + } + + +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_holder_num(secucodes: list[str], years: int = 3) -> 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) diff --git a/utils/fin_holders.py b/utils/fin_holders.py new file mode 100644 index 0000000..fc4b3cb --- /dev/null +++ b/utils/fin_holders.py @@ -0,0 +1,70 @@ +"""东方财富 CHOICE API 十大股东明细抓取工具。 +- 输入: 股票代码列表 (如 ['603233.SH']) +- 输出: pandas DataFrame (按报告期末的十大股东明细) +- 不需要登录, 不需要 cookie, 无文件落盘 +""" +from __future__ import annotations + +import pandas as pd + +from utils._emchoice import fetch_report + + +_REPORT_NAME = "RPT_DMSK_HOLDERS" +# 列清单沿用 docs/api_docs.md 抓包值 +_COLUMNS = ( + "SECUCODE,SECURITY_CODE,ORG_CODE,SECURITY_TYPE_CODE,END_DATE,RANK," + "HOLDER_CODE,HOLDER_NAME,HOLD_NUM,HOLD_RATIO,HOLD_NUM_CHANGE," + "HOLD_RATIO_CHANGE,DIRECTION,SHARES_TYPE,HOLDER_NATURE,REPORT_TYPE," + "HOLD_CHANGE,NOTICE_DATE,HOLD_RATIO_YOY,REPORT_DATE_NAME,REFERENCE_MARKET_CAP" +) + + +def _build_params( + secucode: str, + start_date: str, + end_date: str, +) -> dict[str, str]: + """构造 RPT_DMSK_HOLDERS 查询参数.""" + return { + "reportName": _REPORT_NAME, + "columns": _COLUMNS, + "quoteColumns": "", + "filter": f'(SECUCODE="{secucode}")(END_DATE>=\'{start_date}\')(END_DATE<=\'{end_date}\')', + "pageNumber": "", + "pageSize": "", + "sortTypes": "-1,1", + "sortColumns": "END_DATE,RANK", + "client": "SW", + "source": "CHOICE", + } + + +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_holders(secucodes: list[str], years: int = 3) -> 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)