diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..8421301 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,32 @@ +# Python +__pycache__/ +*.py[cod] +*.egg-info/ + +# 虚拟环境 +.venv/ +venv/ + +# 环境变量与密钥 +.env +.env.* +!.env.example +*.pem +*.key + +# 测试/构建产物 +.pytest_cache/ +.coverage +coverage.xml +htmlcov/ + +# git / IDE +.git/ +.gitignore +.vscode/ +.idea/ +.DS_Store + +# 容器内不需要的目录 +docs/ +tests/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..c2d8224 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,24 @@ +# EMWeb API 生产镜像 +# 基于 python:3.13-slim (与本地 uv 锁定的 CPython 3.13.14 一致) +FROM python:3.13-slim + +# 安装 uv (项目用 uv 管理依赖) +COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/ + +WORKDIR /app + +ENV UV_LINK_MODE=copy \ + UV_COMPILE_BYTECODE=1 + +# 先拷贝依赖清单, 利用 Docker 层缓存加速重建 +COPY pyproject.toml uv.lock ./ +# 只装生产依赖, 不装项目自身 (无 build-system, 跳过避免报错) +RUN uv sync --frozen --no-dev --no-install-project + +# 拷贝源码 +COPY . . + +EXPOSE 8765 + +# 直接用 venv 里的 uvicorn 启动 (不再触发 uv sync) +CMD [".venv/bin/uvicorn", "api:app", "--host", "0.0.0.0", "--port", "8765"] diff --git a/api.py b/api.py index 6fdfa06..95b6e35 100644 --- a/api.py +++ b/api.py @@ -16,6 +16,16 @@ import pandas as pd # 复用 fin_summary.py 里的核心逻辑 from utils.fin_summary import get_fin_summary, _fetch_org_codes from utils.fin_quarterly import get_fin_quarterly_summary +from utils.fin_main_business import get_main_business +from utils.fin_balance import get_balance +from utils.fin_income import get_income +from utils.fin_cashflow import get_cashflow +from utils.fin_profitability import get_profitability +from utils.fin_capital_structure import get_capital_structure +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 app = FastAPI( @@ -89,6 +99,19 @@ def _df_to_json_payload(df: pd.DataFrame) -> dict: } +def _empty_response(message: str, codes: list[str], **extra) -> JSONResponse: + """无数据时的统一 404 返回.""" + content = { + "success": False, + "count": 0, + "message": message, + "codes_requested": codes, + "data": [], + } + content.update(extra) + return JSONResponse(status_code=404, content=content) + + @app.get("/") def root(): return { @@ -97,11 +120,25 @@ def root(): "endpoints": { "GET /fin/summary": "拉取财务摘要(报告期)", "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": "拉取杜邦分析", + "GET /fin/dividend-detail": "拉取分红明细", + "GET /fin/dividend-statics": "拉取分红统计", "GET /health": "健康检查", }, "usage": { "fin_summary": "/fin/summary?stocks=603233.SH:10500736&years=10", "fin_quarterly_summary": "/fin/quarterly-summary?stocks=603233.SH&years=3", + "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", }, } @@ -221,6 +258,246 @@ def fin_quarterly_summary( return payload +@app.get("/fin/main-business") +def fin_main_business( + stocks: str = Query( + ..., + description="股票代码,逗号分隔,例如 '603233.SH,600519.SH'。仅支持代码,不需要 ORG_CODE。", + examples=["603233.SH"], + ), + classify_type: str = Query( + "产品", + description="分类方式: 产品 或 地区", + examples=["产品", "地区"], + ), + 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_main_business(codes, classify_type=classify_type, years=years) + if df.empty: + return JSONResponse( + status_code=404, + content={ + "success": False, + "count": 0, + "message": "无主营构成数据, 请检查股票代码或筛选条件", + "codes_requested": codes, + "classify_type": classify_type, + "data": [], + }, + ) + + payload = _df_to_json_payload(df) + payload["codes_requested"] = codes + payload["classify_type"] = classify_type + payload["years"] = years + return payload + + +@app.get("/fin/balance") +def fin_balance( + 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_balance(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/income") +def fin_income( + 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_income(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/cashflow") +def fin_cashflow( + 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_cashflow(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/profitability") +def fin_profitability( + 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_profitability(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/capital-structure") +def fin_capital_structure( + 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_capital_structure(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/operate-ability") +def fin_operate_ability( + 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_operate_ability(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/growth") +def fin_growth( + 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_growth(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/dupont") +def fin_dupont( + 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_dupont(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/dividend-detail") +def fin_dividend_detail( + stocks: str = Query(..., description="股票代码,逗号分隔,例如 '603233.SH'。仅支持代码,不需要 ORG_CODE。", examples=["603233.SH"]), + years: int = Query(5, 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_dividend_detail(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/dividend-statics") +def fin_dividend_statics( + stocks: str = Query(..., description="股票代码,逗号分隔,例如 '603233.SH'。仅支持代码,不需要 ORG_CODE。", examples=["603233.SH"]), + years: int = Query(5, 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_dividend_statics(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/deploy.sh b/deploy.sh new file mode 100644 index 0000000..59cbab8 --- /dev/null +++ b/deploy.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +set -e + +cd "$(dirname "$0")" + +echo "==> 拉取最新代码..." +git pull + +echo "==> 构建并启动服务..." +docker compose up -d --build + +echo "==> 清理构建缓存..." +docker builder prune -f + +echo "==> 部署完成" diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..3117595 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,16 @@ +services: + api: + build: + context: . + dockerfile: Dockerfile + image: emweb-api:latest + container_name: emweb-api + ports: + - "8765:8765" + restart: unless-stopped + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8765/health', timeout=3)"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 10s diff --git a/docs/api_manual.md b/docs/api_manual.md new file mode 100644 index 0000000..96cd889 --- /dev/null +++ b/docs/api_manual.md @@ -0,0 +1,279 @@ +# EMWeb API 接口文档 + +> 东方财富 CHOICE 数据抓取服务 HTTP 接口使用说明。对应目前已实现的功能,随版本更新。 + +## 基本信息 + +| 项 | 值 | +|---|---| +| 服务名称 | EMWeb API | +| 版本 | 0.1.0 | +| 默认地址 | `http://127.0.0.1:8765` | +| 数据源 | 东方财富 CHOICE 公开接口(无需登录/cookie) | +| Swagger | `http://127.0.0.1:8765/docs` | + +启动: + +```bash +uv run python api.py # 监听 0.0.0.0:8765 +``` + +## 通用说明 + +### 返回结构 + +所有 `/fin/*` 接口成功时返回统一结构: + +```json +{ + "success": true, + "count": 10, + "columns": ["SECUCODE", "REPORT_DATE", "..."], + "data": [ { "SECUCODE": "603233.SH", "..." }, "..." ], + "codes_requested": ["603233.SH"], + "years": 3 +} +``` + +| 字段 | 说明 | +|---|---| +| `success` | 是否成功 | +| `count` | 数据行数 | +| `columns` | 数据列名(与上游接口返回一致,不做翻译) | +| `data` | 数据行数组;空值/NaN/NaT/±inf 统一转 `null` | +| 其余字段 | 回显本次请求参数,便于排查 | + +### 错误 + +| HTTP 状态 | 场景 | +|---|---| +| `400` | 参数不合法(如 `stocks` 为空) | +| `404` | 请求成功但无数据(`success: false`,`data: []`) | +| `5xx` | 上游接口异常(超时、返回失败等) | + +### 股票代码格式 + +统一使用 `代码.市场`,如 `603233.SH`(上交所)/ `000001.SZ`(深交所)。多只股票用英文逗号分隔。 + +--- + +## 1. GET /health — 健康检查 + +**参数**:无 + +**响应**: + +```json +{ "status": "ok" } +``` + +--- + +## 2. GET /fin/summary — 财务摘要(报告期) + +拉取多只股票的财务摘要指标(报告期口径,单季度数据请用 `/fin/quarterly-summary`)。 + +**参数** + +| 参数 | 类型 | 必填 | 默认 | 说明 | +|---|---|---|---|---| +| `stocks` | string | ✅ | — | 逗号分隔股票列表。两种写法:① 仅代码 `603233.SH`(自动查 ORG_CODE,不稳定);② `代码:ORG_CODE`,如 `603233.SH:10500736`(推荐,最稳) | +| `years` | int | | `10` | 拉取多少年历史(1~20) | +| `date_type_codes` | string | | `1,5,6` | 报告期代码,逗号分隔:`1`=一季报,`5`=中报,`6`=年报 | +| `is_newest` | bool | | `true` | 是否只要最新合并报表 | + +**示例** + +```bash +curl "http://127.0.0.1:8765/fin/summary?stocks=603233.SH:10500736&years=5&date_type_codes=1,5,6" +``` + +**说明** + +- ORG_CODE 无法从公开接口稳定自动获取,推荐在抓包时记录(如大参林 `603233.SH` → `10500736`)。 +- 只传代码时自动查询失败会返回 `400` 并提示改用 `代码:ORG_CODE` 格式。 + +--- + +## 3. GET /fin/quarterly-summary — 财务摘要(单季度) + +拉取多只股票的季度财务摘要,数据按 `SECUCODE + REPORT_DATE` 去重并倒序排列。 + +**参数** + +| 参数 | 类型 | 必填 | 默认 | 说明 | +|---|---|---|---|---| +| `stocks` | string | ✅ | — | 逗号分隔股票代码,如 `603233.SH,600519.SH`。仅需代码,不需要 ORG_CODE | +| `years` | int | | `3` | 未传 `report_dates` 时,拉取最近多少年的季度数据(1~20) | +| `report_dates` | string | | 无 | 可选,逗号分隔的报告期 `YYYY-MM-DD`,如 `2025-03-31,2024-12-31`;传入时精确筛选 | + +**示例** + +```bash +# 最近 3 年全部季度 +curl "http://127.0.0.1:8765/fin/quarterly-summary?stocks=603233.SH&years=3" + +# 精确指定报告期 +curl "http://127.0.0.1:8765/fin/quarterly-summary?stocks=603233.SH&report_dates=2025-03-31,2024-12-31" +``` + +--- + +## 4. GET /fin/main-business — 主营构成(按产品 / 按地区) + +拉取多只股票的主营构成,可分别按产品分类或按地区分类。 + +**参数** + +| 参数 | 类型 | 必填 | 默认 | 说明 | +|---|---|---|---|---| +| `stocks` | string | ✅ | — | 逗号分隔股票代码,如 `603233.SH`。仅需代码,不需要 ORG_CODE | +| `classify_type` | string | | `产品` | 分类方式:`产品` 或 `地区` | +| `years` | int | | `3` | 拉取最近多少年数据(1~20) | + +**示例** + +```bash +# 按产品分类(中文参数建议 URL 编码:产品=%E4%BA%A7%E5%93%81) +curl "http://127.0.0.1:8765/fin/main-business?stocks=603233.SH&classify_type=%E4%BA%A7%E5%93%81&years=3" + +# 按地区分类(地区=%E5%9C%B0%E5%8C%BA) +curl "http://127.0.0.1:8765/fin/main-business?stocks=603233.SH&classify_type=%E5%9C%B0%E5%8C%BA&years=3" +``` + +**返回数据常见列** + +| 列名 | 说明 | +|---|---| +| `SECUCODE` | 股票代码 | +| `CLASSIFY_TYPE` | 分类方式(产品/地区) | +| `STD_REPORT_NAME` | 分类名(如产品名/地区名,含"合计"行) | +| `ReportDate` | 报告期(`YYYY-MM-DD HH:MM:SS`) | +| `MAIN_BUSINESS_INCOME` | 主营业务收入 | +| `MAIN_BUSINESS_COST` | 主营业务成本 | +| `MAIN_BUSINESS_RPOFIT` | 主营业务利润 | +| `GROSS_RPOFIT_RATIO` | 毛利率(%) | +| `MBI_RATIO` | 占主营业务收入比(%) | +| `MBR_RATIO` | 占主营业务利润比(%) | +| `IS_MAXREPORTDATE` | 是否最新报告期(`1`=是) | + +--- + +## 5. 财务报表(资产负债表 / 利润表 / 现金流量表) + +**公共参数** + +| 参数 | 类型 | 必填 | 默认 | 说明 | +|---|---|---|---|---| +| `stocks` | string | ✅ | — | 逗号分隔股票代码,如 `603233.SH`。仅需代码,不需要 ORG_CODE | +| `years` | int | | `3` | 拉取最近多少年的数据(1~20) | + +**示例** + +```bash +# 资产负债表 +curl "http://127.0.0.1:8765/fin/balance?stocks=603233.SH&years=3" + +# 利润表 +curl "http://127.0.0.1:8765/fin/income?stocks=603233.SH&years=3" + +# 现金流量表 +curl "http://127.0.0.1:8765/fin/cashflow?stocks=603233.SH&years=3" +``` + +**说明**:数据为按报告期的明细科目,列数较多(资产负债表约 160+ 列,利润表约 80+ 列,现金流量表约 140 列),列名与上游接口一致(如 `TOTAL_ASSETS`、`TOTAL_OPERATE_INCOME`、`SALES_SERVICES`)。 + +--- + +## 6. 财务分析指标(盈利能力 / 资本结构 / 营运能力 / 成长能力 / 杜邦) + +**公共参数** + +| 参数 | 类型 | 必填 | 默认 | 说明 | +|---|---|---|---|---| +| `stocks` | string | ✅ | — | 逗号分隔股票代码,如 `603233.SH`。仅需代码,不需要 ORG_CODE | +| `years` | int | | `3` | 拉取最近多少年的数据(1~20) | + +**示例** + +```bash +# 盈利能力与收益质量 (ROE/ROA/毛利率等) +curl "http://127.0.0.1:8765/fin/profitability?stocks=603233.SH&years=3" + +# 资本结构与偿债能力 (资产负债率/流动比率等) +curl "http://127.0.0.1:8765/fin/capital-structure?stocks=603233.SH&years=3" + +# 营运能力 (周转天数/周转率等) +curl "http://127.0.0.1:8765/fin/operate-ability?stocks=603233.SH&years=3" + +# 成长能力 (营收/净利同比等) +curl "http://127.0.0.1:8765/fin/growth?stocks=603233.SH&years=3" + +# 杜邦分析 (ROE 拆解) +curl "http://127.0.0.1:8765/fin/dupont?stocks=603233.SH&years=3" +``` + +**各接口常见列** + +| 接口 | 常见列 | +|---|---| +| 盈利能力 | `ROE_AVERAGE`(平均净资产收益率)、`ROE_DILUTED`(摊薄)、`ROA`、`ROIC`、`SALE_GPR`(毛利率)等 | +| 资本结构 | `DEBT_ASSET_RATIO`(资产负债率)、`CURRENT_RATIO`(流动比率)、`SPEED_RATIO`(速动比率)等 | +| 营运能力 | `OPERATE_CYCLE`(营业周期)、`INVENTORY_DAYS`(存货周转天数)、`ACCOUNTS_RECE_DAYS` 等 | +| 成长能力 | `BASICEPS_YOY`、`TOTAL_OPERATEINCOME_YOY`(营收同比)、`PARENT_NETPROFIT_YOY`(净利同比)等 | +| 杜邦 | `ROE`、`NETPROFIT`、`TOTAL_ASSETS`、`EQUITY_MULTIPLIER`(权益乘数)、`NETPROFIT_TOI` 等 | + +--- + +## 7. 分红(明细 / 统计) + +**公共参数** + +| 参数 | 类型 | 必填 | 默认 | 说明 | +|---|---|---|---|---| +| `stocks` | string | ✅ | — | 逗号分隔股票代码,如 `603233.SH`。仅需代码,不需要 ORG_CODE | +| `years` | int | | `5` | 拉取最近多少年的分红数据(1~20) | + +**示例** + +```bash +# 分红明细 (含方案/股权登记日/除权除息日等) +curl "http://127.0.0.1:8765/fin/dividend-detail?stocks=603233.SH&years=5" + +# 分红统计 (按年汇总, 含股息率/分红率等) +curl "http://127.0.0.1:8765/fin/dividend-statics?stocks=603233.SH&years=5" +``` + +**各接口常见列** + +| 接口 | 常见列 | +|---|---| +| 分红明细 | `IMPL_PLAN_PROFILE`(方案)、`ASSIGN_PROGRESS`(进度)、`PRETAX_BONUS_RMB`(每股税前)、`EX_DIVIDEND_DATE`(除权除息日)、`PAY_CASH_DATE` 等 | +| 分红统计 | `AUALACCMDIV_ARD`(每股股利)、`PARENTNETPROFIT`、`GXL`(股息率)、`SUM_AUALACCMDIV_ARD` 等 | + +--- + +## 接口一览 + +| 路径 | 功能 | 数据来源报表 | +|---|---|---| +| `/health` | 健康检查 | — | +| `/fin/summary` | 财务摘要(报告期) | `RPT_CUSTOM_ORGF9_FIN_SUMMARY` | +| `/fin/quarterly-summary` | 财务摘要(单季度) | `RPT_HSF9_FIN_SUMMARYQUARTERNEW` | +| `/fin/main-business` | 主营构成(产品/地区) | `ZYGC_AFLZS` → `RPT_HSF9_FN_MAINOPBUSINESS` | +| `/fin/balance` | 资产负债表 | `CWSJ_ZCFZB` → `RPT_CUSTOM_HSF9_FINA_GBALANCE` | +| `/fin/income` | 利润表 | `CWSJ_LRB` → `RPT_CUSTOM_HSF9_FINA_GINCOMESYN` | +| `/fin/cashflow` | 现金流量表 | `CWSJ_XJLLB` → `RPT_CUSTOM_HSF9_FINA_GCASHFLOW` | +| `/fin/profitability` | 盈利能力与收益质量 | `CWFX_YLNLYSYZL` → `RPT_HSF9_FINA_PROFITEARNING` | +| `/fin/capital-structure` | 资本结构与偿债能力 | `CWFX_ZBJGYCZNL` → `RPT_HSF9_FINA_CAPPAYAB` | +| `/fin/operate-ability` | 营运能力 | `RPT_HSF9_FINA_OPERATEABILITY` | +| `/fin/growth` | 成长能力 | `CWFX_CZNL` → `RPT_HSF9_FINA_GROWTH` | +| `/fin/dupont` | 杜邦分析 | `RPT_HSF9_FINA_DUPONT` | +| `/fin/dividend-detail` | 分红明细 | `RPT_HSF9_ASSIGNPLAN_DETAIL` | +| `/fin/dividend-statics` | 分红统计 | `RPT_HSF9_ASSIGNPLAN_STATICS` | + +## 测试 + +```bash +uv run pytest tests/ -q # 冒烟测试: 连真实接口验证各模块能取到数据 +``` diff --git a/docs/blueprint.md b/docs/blueprint.md new file mode 100644 index 0000000..110c03d --- /dev/null +++ b/docs/blueprint.md @@ -0,0 +1,121 @@ +# EMWeb API 项目施工蓝图 + +> 记录项目目标、架构、接口清单、实施进度与施工规范,避免"做到一半忘记当初的规划"。 + +--- + +## 1. 项目简介 + +**目标**:以东方财富 CHOICE 数据接口为基础,封装一套稳定、可复用的财务数据抓取服务,对外以 FastAPI 提供 HTTP 接口。 + +**核心诉求**: +- 不需要登录 / cookie,直接调公开接口即可拿到数据 +- 每个数据模块独立成文件,可脚本直接调用,也可通过 API 暴露 +- 数据以 pandas DataFrame 为内部统一载体,出 HTTP 时转 JSON-safe 结构 + +**技术栈**:Python >= 3.11 · FastAPI + Uvicorn · requests · pandas · uv 管理依赖 + +--- + +## 2. 架构总览 + +``` +EMWebApi/ +├── api.py # FastAPI 入口, 聚合各 utils 模块对外暴露 HTTP 接口 +├── utils/ +│ ├── fin_summary.py # 财务摘要(报告期) —— 最底层, 含共享常量/SSL修复 +│ ├── _emchoice.py # 内部共享请求辅助 (emws 跳转取 URL / datacenter 报表拉取) +│ ├── fin_quarterly.py # 财务摘要(单季度) +│ ├── fin_main_business.py # 主营构成(按产品/地区) +│ ├── fin_balance.py # 资产负债表 +│ ├── fin_income.py # 利润表 +│ ├── fin_cashflow.py # 现金流量表 +│ ├── fin_profitability.py # 盈利能力与收益质量 +│ ├── fin_capital_structure.py # 资本结构与偿债能力 +│ ├── fin_operate.py # 营运能力 +│ ├── fin_growth.py # 成长能力 +│ ├── fin_dupont.py # 杜邦分析 +│ └── fin_dividend.py # 分红明细 + 分红统计 +├── tests/ +│ └── test_interfaces.py # 冒烟测试 (连真实接口) +└── docs/ + ├── api_docs.md # 从 Reqable 抓包沉淀的原始接口记录 + ├── api_manual.md # 对外 HTTP 接口使用文档 + └── blueprint.md # 本文件: 施工蓝图 +``` + +**依赖方向**: +- `fin_quarterly.py` / `fin_main_business.py` 复用 `fin_summary.py` 里的 `HEADERS` / `TIMEOUT` / `BASE_URL`(顺带触发其模块级的 SSL 自动修复) +- 其余 9 个新模块复用 `_emchoice.py` 的 `get_data_url` / `fetch_report`(同样会触发 SSL 修复) + +--- + +## 3. 数据源接口清单(源自 docs/api_docs.md) + +| # | 接口 | 报表名/路径 | 实现状态 | +|---|------|-------------|----------| +| 1 | 财务摘要(单季度) | `RPT_HSF9_FIN_SUMMARYQUARTERNEW` | ✅ 已实现 | +| 2 | 主营构成(产品/地区) | `ZYGC_AFLZS` → `RPT_HSF9_FN_MAINOPBUSINESS` | ✅ 已实现 | +| 3 | 资产负债表 | `CWSJ_ZCFZB` → `RPT_CUSTOM_HSF9_FINA_GBALANCE` | ✅ 已实现 | +| 4 | 利润表 | `CWSJ_LRB` → `RPT_CUSTOM_HSF9_FINA_GINCOMESYN` | ✅ 已实现 | +| 5 | 现金流量表 | `CWSJ_XJLLB` → `RPT_CUSTOM_HSF9_FINA_GCASHFLOW` | ✅ 已实现 | +| 6 | 盈利能力与收益质量 | `CWFX_YLNLYSYZL` → `RPT_HSF9_FINA_PROFITEARNING` | ✅ 已实现 | +| 7 | 资本结构与偿债能力 | `CWFX_ZBJGYCZNL` → `RPT_HSF9_FINA_CAPPAYAB` | ✅ 已实现 | +| 8 | 营运能力 | `RPT_HSF9_FINA_OPERATEABILITY` | ✅ 已实现 | +| 9 | 成长能力 | `CWFX_CZNL` → `RPT_HSF9_FINA_GROWTH` | ✅ 已实现 | +| 10 | 杜邦分析 | `RPT_HSF9_FINA_DUPONT` | ✅ 已实现 | +| 11 | 分红明细 + 分红统计 | `RPT_HSF9_ASSIGNPLAN_DETAIL` / `_STATICS` | ✅ 已实现 | + +**文档外补充**:财务摘要(报告期) 用 `RPT_CUSTOM_ORGF9_FIN_SUMMARY`,需 ORG_CODE(不在 api_docs.md 里)。 + +--- + +## 4. 实施进度 + +### ✅ 已完成(11 类接口全部落地) + +| 功能 | 核心文件 | HTTP 接口 | +|------|----------|-----------| +| 财务摘要(报告期) | `utils/fin_summary.py` | `GET /fin/summary` | +| 财务摘要(单季度) | `utils/fin_quarterly.py` | `GET /fin/quarterly-summary` | +| 主营构成(产品/地区) | `utils/fin_main_business.py` | `GET /fin/main-business` | +| 资产负债表 | `utils/fin_balance.py` | `GET /fin/balance` | +| 利润表 | `utils/fin_income.py` | `GET /fin/income` | +| 现金流量表 | `utils/fin_cashflow.py` | `GET /fin/cashflow` | +| 盈利能力与收益质量 | `utils/fin_profitability.py` | `GET /fin/profitability` | +| 资本结构与偿债能力 | `utils/fin_capital_structure.py` | `GET /fin/capital-structure` | +| 营运能力 | `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` | +| 测试 | `tests/test_interfaces.py` | `uv run pytest tests/ -q` (24 用例) | + +### 🧹 待清理 + +- `fin_summary.py` 里的 `_debug_report`(向 `127.0.0.1:7777/event` 发 POST)是调试残留,可移除 +- `_auto_fix_ssl` 里残留 Windows 路径(`%USERPROFILE%\...`),运行环境是 macOS,可精简 + +--- + +## 5. 施工规范(新模块必须遵守) + +1. **文件命名**:`utils/fin_<主题>.py`,一个主题一个文件,内部函数私有化(`_` 开头);仅内部共享的请求辅助放 `utils/_emchoice.py` +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` +7. **礼貌抓取**:批量循环中加随机延时(参考 `SLEEP_RANGE`) +8. **注释语言**:中文,解释"为什么"(如接口编码映射差异) +9. **测试**:新模块在 `tests/test_interfaces.py` 注册冒烟用例(连真实接口取 1 年数据,断言非空 + 关键列) + +--- + +## 6. 常用命令 + +```bash +uv sync # 创建环境并安装依赖 +uv run python api.py # 启动服务 (默认端口 8765) +uv run pytest tests/ -q # 跑冒烟测试 (连真实接口) +uv run python -c "..." # 直接跑脚本 +``` diff --git a/pyproject.toml b/pyproject.toml index f1f6c19..4446772 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,4 +10,14 @@ dependencies = [ "fastapi>=0.110", "uvicorn>=0.27", "pydantic>=2.5", -] \ No newline at end of file +] + +[dependency-groups] +dev = [ + "pytest>=9.1.1", +] + +[tool.pytest.ini_options] +pythonpath = ["."] +testpaths = ["tests"] + diff --git a/tests/test_interfaces.py b/tests/test_interfaces.py new file mode 100644 index 0000000..c73957d --- /dev/null +++ b/tests/test_interfaces.py @@ -0,0 +1,84 @@ +"""接口冒烟测试: 直接调用 utils 各模块函数, 连真实东方财富接口验证能取到数据. + +运行: uv run pytest tests/ -q +注意: 依赖外网可达东财接口, 网络不通时用例会失败. +""" +import pandas as pd +import pytest + +from utils.fin_balance import get_balance +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_growth import get_growth +from utils.fin_income import get_income +from utils.fin_main_business import get_main_business +from utils.fin_operate import get_operate_ability +from utils.fin_profitability import get_profitability + +STOCK = "603233.SH" + +# (模块函数, 该接口应返回的关键列, 测试名) +CASES = [ + pytest.param(get_main_business, "MAIN_BUSINESS_INCOME", id="main_business"), + pytest.param(get_balance, "TOTAL_ASSETS", id="balance"), + pytest.param(get_income, "TOTAL_OPERATE_INCOME", id="income"), + pytest.param(get_cashflow, "SALES_SERVICES", id="cashflow"), + pytest.param(get_profitability, "ROE_AVERAGE", id="profitability"), + pytest.param(get_capital_structure, "DEBT_ASSET_RATIO", id="capital_structure"), + pytest.param(get_operate_ability, "OPERATE_CYCLE", id="operate_ability"), + pytest.param(get_growth, "BASICEPS_YOY", id="growth"), + 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.mark.parametrize("func,key_col", CASES) +def test_fetch_data(func, key_col): + """拉 1 年数据, 应返回非空 DataFrame 且含关键列.""" + df = func([STOCK], years=1) + assert isinstance(df, pd.DataFrame) + assert not df.empty, f"{func.__name__} 未返回数据" + assert "SECUCODE" in df.columns, f"{func.__name__} 缺少 SECUCODE 列" + assert key_col in df.columns, f"{func.__name__} 缺少关键列 {key_col}" + + +@pytest.mark.parametrize("func,_key_col", CASES) +def test_empty_codes(func, _key_col): + """空代码列表应返回空 DataFrame, 不抛异常.""" + df = func([], years=1) + assert isinstance(df, pd.DataFrame) + assert df.empty + + +def test_main_business_region(): + """主营构成按地区分类也能取到数据.""" + df = get_main_business([STOCK], classify_type="地区", years=1) + assert not df.empty + assert df["CLASSIFY_TYPE"].iloc[0] == "地区" + + +def test_api_routes(): + """api.py 的所有 HTTP 路由已注册.""" + import api + + paths = {r.path for r in api.app.routes if hasattr(r, "path")} + for route in [ + "/health", + "/fin/summary", + "/fin/quarterly-summary", + "/fin/main-business", + "/fin/balance", + "/fin/income", + "/fin/cashflow", + "/fin/profitability", + "/fin/capital-structure", + "/fin/operate-ability", + "/fin/growth", + "/fin/dupont", + "/fin/dividend-detail", + "/fin/dividend-statics", + ]: + assert route in paths, f"缺少路由 {route}" diff --git a/utils/_emchoice.py b/utils/_emchoice.py new file mode 100644 index 0000000..cbf6d33 --- /dev/null +++ b/utils/_emchoice.py @@ -0,0 +1,38 @@ +"""东方财富 CHOICE 接口公共请求辅助 (仅内部模块复用, 不对外暴露)。""" +from __future__ import annotations + +import requests + +from utils.fin_summary import HEADERS, TIMEOUT + + +# emchoicews2 的 f9 页面接口, 返回带 urls 的跳转结构 +_EMWS_URL = "https://emchoicews2.eastmoney.com" +# datacenter-choice 报表数据接口 +_BASE_URL = "https://datacenter-choice.eastmoney.com/api/data/v1/get" + + +def get_data_url(path: str, params: dict[str, str]) -> str: + """调用 emchoicews2 的 f9 路径, 返回真正的报表数据接口 URL.""" + r = requests.get(_EMWS_URL + path, headers=HEADERS, params=params, timeout=TIMEOUT) + r.raise_for_status() + payload = r.json() + urls = payload.get("urls") or [] + if not urls: + raise RuntimeError(f"{path}: 接口未返回数据 URL: {payload}") + return urls[0] + + +def fetch_report(url_or_params: str | dict[str, str]) -> list[dict]: + """拉取 datacenter-choice 报表数据, 返回行 dict 列表. + 支持直接传完整 URL (由 get_data_url 得到), 或传 reportName 等参数 dict. + """ + if isinstance(url_or_params, dict): + r = requests.get(_BASE_URL, headers=HEADERS, params=url_or_params, timeout=TIMEOUT) + else: + r = requests.get(url_or_params, headers=HEADERS, timeout=TIMEOUT) + r.raise_for_status() + payload = r.json() + if not payload.get("success"): + raise RuntimeError(f"接口返回失败: {payload}") + return payload.get("result", {}).get("data") or [] diff --git a/utils/fin_balance.py b/utils/fin_balance.py new file mode 100644 index 0000000..8fb8195 --- /dev/null +++ b/utils/fin_balance.py @@ -0,0 +1,80 @@ +"""东方财富 CHOICE API 资产负债表抓取工具。 +- 输入: 股票代码列表 (如 ['603233.SH']) +- 输出: pandas DataFrame (按报告期的资产负债表数据) +- 不需要登录, 不需要 cookie, 无文件落盘 +""" +from __future__ import annotations + +import pandas as pd + +from utils._emchoice import get_data_url, fetch_report + + +_PATH = "/stock/f9/CWSJ_ZCFZB" + +# 默认报告期类型: 1=一季报, 3=三季报, 5=中报, 6=年报 +_DEFAULT_REPORT_PERIOD_TYPE = "1,5,3,6,7" + + +def _build_params( + secucode: str, + start_date: str, + end_date: str, + report_period_type: str, +) -> dict[str, str]: + """构造 CWSJ_ZCFZB 请求参数 (其余参数沿用 docs/api_docs.md 抓包值).""" + return { + "SecurityCode": secucode, + "StartDate": start_date, + "EndDate": end_date, + "ReportPeriodType": report_period_type, + "ReportShowForm": "1", + "Digits": "2", + "Sort": "-1", + "ListedType": "0,1", + "IsShowEmptyRow": "true", + "ExchangeRateType": "1", + "Currency": "CNY:1", + "Magnitude": "4", + "ReportType": "1", + } + + +def _fetch_one( + secucode: str, + years: int, + report_period_type: str, +) -> pd.DataFrame: + """拉一只股票的资产负债表.""" + today = pd.Timestamp.today() + start = (today - pd.DateOffset(years=years)).strftime("%Y-%m-%d") + end = today.strftime("%Y-%m-%d") + + params = _build_params(secucode, start, end, report_period_type) + data_url = get_data_url(_PATH, params) + rows = fetch_report(data_url) + df = pd.DataFrame(rows) + if not df.empty: + df["SECUCODE"] = secucode + return df + + +def get_balance( + secucodes: list[str], + years: int = 3, + report_period_type: str = _DEFAULT_REPORT_PERIOD_TYPE, +) -> 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, report_period_type) + 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_capital_structure.py b/utils/fin_capital_structure.py new file mode 100644 index 0000000..949f108 --- /dev/null +++ b/utils/fin_capital_structure.py @@ -0,0 +1,75 @@ +"""东方财富 CHOICE API 资本结构与偿债能力抓取工具。 +- 输入: 股票代码列表 (如 ['603233.SH']) +- 输出: pandas DataFrame (按报告期的资本结构/偿债能力指标) +- 不需要登录, 不需要 cookie, 无文件落盘 +""" +from __future__ import annotations + +import pandas as pd + +from utils._emchoice import get_data_url, fetch_report + + +_PATH = "/stock/f9/CWFX_ZBJGYCZNL" + +# 默认报告期类型: 1=一季报, 3=三季报, 5=中报, 6=年报 (注意: 该接口需带引号) +_DEFAULT_REPORT_PERIOD_TYPE = '"1","5","3","6","7"' + + +def _build_params( + secucode: str, + start_date: str, + end_date: str, + report_period_type: str, +) -> dict[str, str]: + """构造 CWFX_ZBJGYCZNL 请求参数 (其余参数沿用 docs/api_docs.md 抓包值).""" + return { + "SecurityCode": secucode, + "StartDate": start_date, + "EndDate": end_date, + "ReportPeriodType": report_period_type, + "ReportShowForm": "0", + "Sort": "-1", + "Digits": "2", + "IsShowEmptyRow": "true", + } + + +def _fetch_one( + secucode: str, + years: int, + report_period_type: str, +) -> pd.DataFrame: + """拉一只股票的资本结构与偿债能力指标.""" + today = pd.Timestamp.today() + start = (today - pd.DateOffset(years=years)).strftime("%Y-%m-%d") + end = today.strftime("%Y-%m-%d") + + params = _build_params(secucode, start, end, report_period_type) + data_url = get_data_url(_PATH, params) + rows = fetch_report(data_url) + df = pd.DataFrame(rows) + if not df.empty: + df["SECUCODE"] = secucode + return df + + +def get_capital_structure( + secucodes: list[str], + years: int = 3, + report_period_type: str = _DEFAULT_REPORT_PERIOD_TYPE, +) -> 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, report_period_type) + 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_cashflow.py b/utils/fin_cashflow.py new file mode 100644 index 0000000..06f5a25 --- /dev/null +++ b/utils/fin_cashflow.py @@ -0,0 +1,80 @@ +"""东方财富 CHOICE API 现金流量表抓取工具。 +- 输入: 股票代码列表 (如 ['603233.SH']) +- 输出: pandas DataFrame (按报告期的现金流量表数据) +- 不需要登录, 不需要 cookie, 无文件落盘 +""" +from __future__ import annotations + +import pandas as pd + +from utils._emchoice import get_data_url, fetch_report + + +_PATH = "/stock/f9/CWSJ_XJLLB" + +# 默认报告期类型: 1=一季报, 3=三季报, 5=中报, 6=年报 +_DEFAULT_REPORT_PERIOD_TYPE = "1,5,3,6,7" + + +def _build_params( + secucode: str, + start_date: str, + end_date: str, + report_period_type: str, +) -> dict[str, str]: + """构造 CWSJ_XJLLB 请求参数 (其余参数沿用 docs/api_docs.md 抓包值).""" + return { + "SecurityCode": secucode, + "StartDate": start_date, + "EndDate": end_date, + "ReportPeriodType": report_period_type, + "ReportShowForm": "1", + "Digits": "2", + "Sort": "-1", + "ListedType": "0,1", + "IsShowEmptyRow": "true", + "ExchangeRateType": "1", + "Currency": "CNY:1", + "Magnitude": "4", + "ReportType": "1", + } + + +def _fetch_one( + secucode: str, + years: int, + report_period_type: str, +) -> pd.DataFrame: + """拉一只股票的现金流量表.""" + today = pd.Timestamp.today() + start = (today - pd.DateOffset(years=years)).strftime("%Y-%m-%d") + end = today.strftime("%Y-%m-%d") + + params = _build_params(secucode, start, end, report_period_type) + data_url = get_data_url(_PATH, params) + rows = fetch_report(data_url) + df = pd.DataFrame(rows) + if not df.empty: + df["SECUCODE"] = secucode + return df + + +def get_cashflow( + secucodes: list[str], + years: int = 3, + report_period_type: str = _DEFAULT_REPORT_PERIOD_TYPE, +) -> 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, report_period_type) + 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_dividend.py b/utils/fin_dividend.py new file mode 100644 index 0000000..0b84f6c --- /dev/null +++ b/utils/fin_dividend.py @@ -0,0 +1,126 @@ +"""东方财富 CHOICE API 分红数据抓取工具 (分红明细 + 分红统计)。 +- 输入: 股票代码列表 (如 ['603233.SH']) +- 输出: pandas DataFrame (按年度的分红明细 / 分红统计) +- 不需要登录, 不需要 cookie, 无文件落盘 +""" +from __future__ import annotations + +import pandas as pd + +from utils._emchoice import fetch_report + + +_DETAIL_REPORT_NAME = "RPT_HSF9_ASSIGNPLAN_DETAIL" +# 列清单沿用 docs/api_docs.md 抓包值 +_DETAIL_COLUMNS = ( + "SECUCODE,SECURITY_CODE,SECURITY_NAME_ABBR,REPORT_TYPE,ISSUE_YEAR," + "IMPL_PLAN_PROFILE,ASSIGN_PROGRESS,ASSIGN_OBJECT,PRETAX_BONUS_RMB," + "AFTERTAX_BONUS_RMB,BONUS_RATIO,IT_RATIO,CASHAMT,DIVSHAREBASE," + "DAT_YAGGR,GMDECISION_NOTICE_DATE,EQUITY_RECORD_DATE,EX_DIVIDEND_DATE," + "PAY_CASH_DATE,EQUITY_BASEDATE,NOTICE_DATE,NOTICE_ADDRES,INT_YEAR" +) + +_STATICS_REPORT_NAME = "RPT_HSF9_ASSIGNPLAN_STATICS" +_STATICS_COLUMNS = ( + "SECURITY_NAME_ABBR,SECURITY_CODE,SECUCODE,SECURITY_NAME,YEAR," + "AUALACCMDIV_ARD,PARENTNETPROFIT,SFCFJCXG,NDLJMGGLSQ,CLOSE_PRICE," + "GXL,SUM_AUALACCMDIV_ARD,SUM_PARENTNETPROFIT,ZFL" +) + + +def _year_range(years: int) -> tuple[int, int]: + """按年份数算出 (起始年, 结束年), 结束年 = 当前年.""" + end_year = pd.Timestamp.today().year + return end_year - years + 1, end_year + + +def _build_detail_params( + secucode: str, + start_year: int, + end_year: int, +) -> dict[str, str]: + return { + "reportName": _DETAIL_REPORT_NAME, + "columns": _DETAIL_COLUMNS, + "quoteColumns": "", + "filter": f'(SECUCODE="{secucode}")(INT_YEAR>={start_year})(INT_YEAR<={end_year})', + "pageNumber": "", + "pageSize": "", + "sortTypes": "-1,-1", + "sortColumns": "INT_YEAR,NOTICE_DATE", + "source": "CHOICE", + "client": "SW", + } + + +def _build_statics_params( + secucode: str, + start_year: int, + end_year: int, +) -> dict[str, str]: + return { + "reportName": _STATICS_REPORT_NAME, + "columns": _STATICS_COLUMNS, + "quoteColumns": "", + "filter": f'(SECUCODE="{secucode}")(YEAR>={start_year})(YEAR<={end_year})', + "pageNumber": "", + "pageSize": "", + "sortTypes": "-1", + "sortColumns": "YEAR", + "source": "CHOICE", + "client": "SW", + } + + +def _fetch_detail(secucode: str, years: int) -> pd.DataFrame: + """拉一只股票的分红明细.""" + start_year, end_year = _year_range(years) + rows = fetch_report(_build_detail_params(secucode, start_year, end_year)) + df = pd.DataFrame(rows) + if not df.empty: + df["SECUCODE"] = secucode + return df + + +def _fetch_statics(secucode: str, years: int) -> pd.DataFrame: + """拉一只股票的分红统计.""" + start_year, end_year = _year_range(years) + rows = fetch_report(_build_statics_params(secucode, start_year, end_year)) + df = pd.DataFrame(rows) + if not df.empty: + df["SECUCODE"] = secucode + return df + + +def get_dividend_detail(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_detail(code, years) + if not df.empty: + frames.append(df) + + if not frames: + return pd.DataFrame() + return pd.concat(frames, ignore_index=True) + + +def get_dividend_statics(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_statics(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_dupont.py b/utils/fin_dupont.py new file mode 100644 index 0000000..474cee3 --- /dev/null +++ b/utils/fin_dupont.py @@ -0,0 +1,80 @@ +"""东方财富 CHOICE API 杜邦分析抓取工具。 +- 输入: 股票代码列表 (如 ['603233.SH']) +- 输出: pandas DataFrame (按报告期的杜邦分析指标) +- 不需要登录, 不需要 cookie, 无文件落盘 +""" +from __future__ import annotations + +import pandas as pd + +from utils._emchoice import fetch_report + + +_REPORT_NAME = "RPT_HSF9_FINA_DUPONT" +# 列清单沿用 docs/api_docs.md 抓包值 +_COLUMNS = ( + "SECUCODE,SECURITY_CODE,SECURITY_NAME_ABBR,ORG_CODE,SECURITY_INNER_CODE," + "TRADE_MARKET_CODE,TRADE_MARKET,SECURITY_TYPE,SECURITY_TYPE_CODE," + "REPORT_DATE,ROE,NETPROFIT,TOTAL_OPERATE_INCOME,PARENT_NETPROFIT_RATIO," + "ASSET_TURNOVER_RATIO,TOTAL_ASSETS,EQUITY_MULTIPLIER,ROE_AVERAGE_PRE," + "ROE_DIF,NETPROFIT_TOI,NP_TP,TP_EBIT,EBIT_TOI,STR_YEAR,STR_MONTH," + "TOTAL_PROFIT,EBIT,PARENT_NETPROFIT,PRE_TOTAL_ASSETS,AVG_TOTAL_ASSETS," + "TOTAL_PARENT_EQUITY,PRETOTAL_PARENT_EQUITY,AVGTOTAL_PARENT_EQUITY," + "PARENT_EQUITY_NETMARGIN,IS_NEWEST" +) +# 报告期: 1=一季报, 3=三季报, 5=中报, 6=年报 +_REPORT_PERIOD_CODES = '"1","5","3","6","7"' + + +def _build_params( + secucode: str, + start_date: str, + end_date: str, +) -> dict[str, str]: + """构造 RPT_HSF9_FINA_DUPONT 查询参数.""" + return { + "reportName": _REPORT_NAME, + "columns": _COLUMNS, + "quoteColumns": "", + "filter": ( + f'(SECUCODE="{secucode}")' + f"((REPORT_DATE>='{start_date}')(REPORT_DATE<='{end_date}')" + f"(STR_MONTH in ({_REPORT_PERIOD_CODES}))(|IS_NEWEST=''1''))" + ), + "pageNumber": "1", + "pageSize": "", + "sortTypes": "-1", + "sortColumns": "REPORT_DATE", + "source": "choice", + "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_dupont(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_growth.py b/utils/fin_growth.py new file mode 100644 index 0000000..bd673cc --- /dev/null +++ b/utils/fin_growth.py @@ -0,0 +1,74 @@ +"""东方财富 CHOICE API 成长能力抓取工具。 +- 输入: 股票代码列表 (如 ['603233.SH']) +- 输出: pandas DataFrame (按报告期的成长能力指标) +- 不需要登录, 不需要 cookie, 无文件落盘 +""" +from __future__ import annotations + +import pandas as pd + +from utils._emchoice import get_data_url, fetch_report + + +_PATH = "/stock/f9/CWFX_CZNL" + +# 默认报告期类型: 1=一季报, 3=三季报, 5=中报, 6=年报 +_DEFAULT_REPORT_PERIOD_TYPE = "1,5,3,6,7" + + +def _build_params( + secucode: str, + start_date: str, + end_date: str, + report_period_type: str, +) -> dict[str, str]: + """构造 CWFX_CZNL 请求参数 (其余参数沿用 docs/api_docs.md 抓包值).""" + return { + "SecurityCode": secucode, + "StartDate": start_date, + "EndDate": end_date, + "ReportPeriodType": report_period_type, + "Digits": "2", + "Sort": "-1", + "IsShowEmptyRow": "true", + } + + +def _fetch_one( + secucode: str, + years: int, + report_period_type: str, +) -> pd.DataFrame: + """拉一只股票的成长能力指标.""" + today = pd.Timestamp.today() + start = (today - pd.DateOffset(years=years)).strftime("%Y-%m-%d") + end = today.strftime("%Y-%m-%d") + + params = _build_params(secucode, start, end, report_period_type) + data_url = get_data_url(_PATH, params) + rows = fetch_report(data_url) + df = pd.DataFrame(rows) + if not df.empty: + df["SECUCODE"] = secucode + return df + + +def get_growth( + secucodes: list[str], + years: int = 3, + report_period_type: str = _DEFAULT_REPORT_PERIOD_TYPE, +) -> 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, report_period_type) + 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_income.py b/utils/fin_income.py new file mode 100644 index 0000000..bd31e20 --- /dev/null +++ b/utils/fin_income.py @@ -0,0 +1,80 @@ +"""东方财富 CHOICE API 利润表抓取工具。 +- 输入: 股票代码列表 (如 ['603233.SH']) +- 输出: pandas DataFrame (按报告期的利润表数据) +- 不需要登录, 不需要 cookie, 无文件落盘 +""" +from __future__ import annotations + +import pandas as pd + +from utils._emchoice import get_data_url, fetch_report + + +_PATH = "/stock/f9/CWSJ_LRB" + +# 默认报告期类型: 1=一季报, 3=三季报, 5=中报, 6=年报 (注意: 该接口需带引号) +_DEFAULT_REPORT_PERIOD_TYPE = '"1","5","3","6","7"' + + +def _build_params( + secucode: str, + start_date: str, + end_date: str, + report_period_type: str, +) -> dict[str, str]: + """构造 CWSJ_LRB 请求参数 (其余参数沿用 docs/api_docs.md 抓包值).""" + return { + "SecurityCode": secucode, + "StartDate": start_date, + "EndDate": end_date, + "ReportPeriodType": report_period_type, + "ReportShowForm": "0", + "Digits": "2", + "Sort": "-1", + "ListedType": '"0","1"', + "IsShowEmptyRow": "true", + "ExchangeRateType": "1", + "Currency": "CNY:1", + "Magnitude": "4", + "ReportType": '"1"', + } + + +def _fetch_one( + secucode: str, + years: int, + report_period_type: str, +) -> pd.DataFrame: + """拉一只股票的利润表.""" + today = pd.Timestamp.today() + start = (today - pd.DateOffset(years=years)).strftime("%Y-%m-%d") + end = today.strftime("%Y-%m-%d") + + params = _build_params(secucode, start, end, report_period_type) + data_url = get_data_url(_PATH, params) + rows = fetch_report(data_url) + df = pd.DataFrame(rows) + if not df.empty: + df["SECUCODE"] = secucode + return df + + +def get_income( + secucodes: list[str], + years: int = 3, + report_period_type: str = _DEFAULT_REPORT_PERIOD_TYPE, +) -> 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, report_period_type) + 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_main_business.py b/utils/fin_main_business.py new file mode 100644 index 0000000..d6b8eb8 --- /dev/null +++ b/utils/fin_main_business.py @@ -0,0 +1,110 @@ +"""东方财富 CHOICE API 主营构成(按产品/按地区) 抓取工具。 +- 输入: 股票代码列表 (如 ['603233.SH']) +- 输出: pandas DataFrame (按报告期/产品分类的主营构成数据) +- 不需要登录, 不需要 cookie, 无文件落盘 +""" +from __future__ import annotations + +import pandas as pd +import requests + +from utils.fin_summary import HEADERS, TIMEOUT + + +# ZYGC_AFLZS 返回一个带 urls 的跳转结构, 真正的数据在 +# datacenter-choice 的 RPT_HSF9_FN_MAINOPBUSINESS 报表里. +_ZYGC_URL = "https://emchoicews2.eastmoney.com/stock/f9/ZYGC_AFLZS" + +# 默认报告期类型: 1=一季报, 5=中报, 3=三季报, 6=年报 (注意: 与财务摘要的编码不同) +_DEFAULT_REPORT_PERIOD_TYPE = '"1","5","3","6"' + + +def _build_params( + secucode: str, + start_date: str, + end_date: str, + classify_type: str, + report_period_type: str, +) -> dict[str, str]: + """构造 ZYGC_AFLZS 请求参数.""" + return { + "SecurityCode": secucode, + "StartDate": start_date, + "EndDate": end_date, + "ReportPeriodType": report_period_type, + "Magnitude": "4", + "ClassifyType": classify_type, + "ReportShowForm": "0", + "ExchangeRate": "1", + "Digits": "2", + "Sort": "-1", + "IsShowEmptyRow": "true", + } + + +def _fetch_data_url( + secucode: str, + start_date: str, + end_date: str, + classify_type: str, + report_period_type: str, +) -> str: + """调用 ZYGC_AFLZS, 返回真正的主营构成数据接口 URL.""" + params = _build_params( + secucode, start_date, end_date, classify_type, report_period_type + ) + r = requests.get(_ZYGC_URL, headers=HEADERS, params=params, timeout=TIMEOUT) + r.raise_for_status() + payload = r.json() + urls = payload.get("urls") or [] + if not urls: + raise RuntimeError(f"{secucode}: ZYGC_AFLZS 未返回数据 URL: {payload}") + return urls[0] + + +def _fetch_one( + secucode: str, + classify_type: str, + years: int, + report_period_type: str, +) -> pd.DataFrame: + """拉一只股票的主营构成.""" + today = pd.Timestamp.today() + start = (today - pd.DateOffset(years=years)).strftime("%Y-%m-%d") + end = today.strftime("%Y-%m-%d") + + data_url = _fetch_data_url(secucode, start, end, classify_type, report_period_type) + response = requests.get(data_url, headers=HEADERS, timeout=TIMEOUT) + response.raise_for_status() + payload = response.json() + if not payload.get("success"): + raise RuntimeError(f"{secucode}: 接口返回失败: {payload}") + + rows = payload.get("result", {}).get("data") or [] + df = pd.DataFrame(rows) + if not df.empty: + df["SECUCODE"] = secucode + df["CLASSIFY_TYPE"] = classify_type # 冗余一列, 方便区分产品/地区 + return df + + +def get_main_business( + secucodes: list[str], + classify_type: str = "产品", + years: int = 3, + report_period_type: str = _DEFAULT_REPORT_PERIOD_TYPE, +) -> 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, classify_type, years, report_period_type) + 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_operate.py b/utils/fin_operate.py new file mode 100644 index 0000000..8d44083 --- /dev/null +++ b/utils/fin_operate.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_HSF9_FINA_OPERATEABILITY" +_COLUMNS = "OPERATEABILITY" +# 报告期: 1=一季报, 3=三季报, 5=中报, 6=年报 +_REPORT_PERIOD_CODES = "1,5,3,6,7" + + +def _build_params( + secucode: str, + start_date: str, + end_date: str, +) -> dict[str, str]: + """构造 RPT_HSF9_FINA_OPERATEABILITY 查询参数.""" + return { + "reportName": _REPORT_NAME, + "columns": _COLUMNS, + "quoteColumns": "", + "filter": ( + f'(SECUCODE="{secucode}")' + f"((REPORT_DATE>='{start_date}')(REPORT_DATE<='{end_date}')" + f"(STR_MONTH in({_REPORT_PERIOD_CODES}))(|IS_NEWEST=\"1\") )" + ), + "pageNumber": "", + "pageSize": "", + "sortTypes": "-1", + "sortColumns": "REPORT_DATE", + "source": "CHOICE", + "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_operate_ability(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_profitability.py b/utils/fin_profitability.py new file mode 100644 index 0000000..397d4a2 --- /dev/null +++ b/utils/fin_profitability.py @@ -0,0 +1,74 @@ +"""东方财富 CHOICE API 盈利能力与收益质量抓取工具。 +- 输入: 股票代码列表 (如 ['603233.SH']) +- 输出: pandas DataFrame (按报告期的盈利能力/收益质量指标) +- 不需要登录, 不需要 cookie, 无文件落盘 +""" +from __future__ import annotations + +import pandas as pd + +from utils._emchoice import get_data_url, fetch_report + + +_PATH = "/stock/f9/CWFX_YLNLYSYZL" + +# 默认报告期类型: 1=一季报, 3=三季报, 5=中报, 6=年报 (注意: 该接口需带引号) +_DEFAULT_REPORT_PERIOD_TYPE = '"1","5","3","6","7"' + + +def _build_params( + secucode: str, + start_date: str, + end_date: str, + report_period_type: str, +) -> dict[str, str]: + """构造 CWFX_YLNLYSYZL 请求参数 (其余参数沿用 docs/api_docs.md 抓包值).""" + return { + "SecurityCode": secucode, + "StartDate": start_date, + "EndDate": end_date, + "ReportPeriodType": report_period_type, + "Sort": "-1", + "Digits": "2", + "IsShowEmptyRow": "true", + } + + +def _fetch_one( + secucode: str, + years: int, + report_period_type: str, +) -> pd.DataFrame: + """拉一只股票的盈利能力与收益质量指标.""" + today = pd.Timestamp.today() + start = (today - pd.DateOffset(years=years)).strftime("%Y-%m-%d") + end = today.strftime("%Y-%m-%d") + + params = _build_params(secucode, start, end, report_period_type) + data_url = get_data_url(_PATH, params) + rows = fetch_report(data_url) + df = pd.DataFrame(rows) + if not df.empty: + df["SECUCODE"] = secucode + return df + + +def get_profitability( + secucodes: list[str], + years: int = 3, + report_period_type: str = _DEFAULT_REPORT_PERIOD_TYPE, +) -> 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, report_period_type) + if not df.empty: + frames.append(df) + + if not frames: + return pd.DataFrame() + return pd.concat(frames, ignore_index=True) diff --git a/uv.lock b/uv.lock index 982050b..064db0f 100644 --- a/uv.lock +++ b/uv.lock @@ -234,6 +234,11 @@ dependencies = [ { name = "uvicorn" }, ] +[package.dev-dependencies] +dev = [ + { name = "pytest" }, +] + [package.metadata] requires-dist = [ { name = "fastapi", specifier = ">=0.110" }, @@ -244,6 +249,9 @@ requires-dist = [ { name = "uvicorn", specifier = ">=0.27" }, ] +[package.metadata.requires-dev] +dev = [{ name = "pytest", specifier = ">=9.1.1" }] + [[package]] name = "fastapi" version = "0.141.1" @@ -278,6 +286,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, ] +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + [[package]] name = "numpy" version = "2.4.6" @@ -443,6 +460,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b4/07/458c344f0f0c178f4481dad5cca790626ffe4c34eabf9467069d06ee4999/numpy-2.5.2-cp315-cp315t-win_arm64.whl", hash = "sha256:5f8e00be2ec6f45f4e8a41a527f68d44a7d96fee92a650e4d8b1326f77f61e6e", size = 10748103, upload-time = "2026-08-09T13:48:24.21Z" }, ] +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + [[package]] name = "pandas" version = "3.0.5" @@ -498,6 +524,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d2/cf/6a51b2c38980e04c279fd2fa908a1b0982064e860444acfca4ec2e2c8359/pandas-3.0.5-cp314-cp314t-win_arm64.whl", hash = "sha256:3c5015fd1730fbf883647e88068176c839c102cea883ba1769a6f4593bfc1f8c", size = 9509776, upload-time = "2026-07-22T22:19:26.694Z" }, ] +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + [[package]] name = "pydantic" version = "2.13.4" @@ -615,6 +650,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" }, ] +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + [[package]] name = "python-dateutil" version = "2.9.0.post0"