Add financial summary and quarterly endpoints with project configuration and documentation.
226 lines
7.1 KiB
Python
226 lines
7.1 KiB
Python
"""
|
||
东方财富财务摘要 API (FastAPI 封装)
|
||
GET /fin/summary?stocks=603233.SH:10500736,600519.SH:xxxxxx
|
||
GET /fin/summary?stocks=603233.SH (只传代码, 自动查 ORG_CODE, 不稳)
|
||
|
||
返回 JSON: {success, count, columns, data, errors}
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
from typing import Optional
|
||
|
||
from fastapi import FastAPI, Query, HTTPException
|
||
from fastapi.responses import JSONResponse
|
||
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
|
||
|
||
|
||
app = FastAPI(
|
||
title="EMWeb API",
|
||
description="东方财富 CHOICE API 数据抓取服务",
|
||
version="0.1.0",
|
||
)
|
||
|
||
|
||
def _parse_stocks(raw: str) -> tuple[list[str], Optional[dict[str, str]]]:
|
||
"""
|
||
解析 ?stocks= 参数.
|
||
"603233.SH:10500736,600519.SH:xxxxxx" -> (codes, {code: org_code})
|
||
"603233.SH,600519.SH" -> (codes, None) -> 自动查
|
||
"603233.SH:10500736" -> (codes, {code: org_code}) 单只
|
||
"""
|
||
codes: list[str] = []
|
||
org_map: dict[str, str] = {}
|
||
has_any_org = False
|
||
for part in raw.split(","):
|
||
part = part.strip()
|
||
if not part:
|
||
continue
|
||
if ":" in part:
|
||
code, org = part.split(":", 1)
|
||
code = code.strip()
|
||
org = org.strip()
|
||
codes.append(code)
|
||
if org:
|
||
org_map[code] = org
|
||
has_any_org = True
|
||
else:
|
||
codes.append(part.strip())
|
||
if has_any_org and len(org_map) != len(codes):
|
||
# 部分给了 ORG_CODE 部分没给 -> 退化为全部自动查
|
||
return codes, None
|
||
return codes, (org_map if has_any_org else None)
|
||
|
||
|
||
def _df_to_json_payload(df: pd.DataFrame) -> dict:
|
||
"""DataFrame -> JSON-safe dict."""
|
||
import math
|
||
safe = df.copy()
|
||
# 处理 NaN / NaT / Timestamp / inf -> None
|
||
for col in safe.columns:
|
||
if pd.api.types.is_datetime64_any_dtype(safe[col]):
|
||
safe[col] = safe[col].astype(str).replace("NaT", None)
|
||
safe = safe.where(pd.notnull(safe), None)
|
||
|
||
# 二次清理: inf / -inf / 极大值
|
||
records = safe.to_dict(orient="records")
|
||
cleaned = []
|
||
for row in records:
|
||
new_row = {}
|
||
for k, v in row.items():
|
||
if v is None:
|
||
new_row[k] = None
|
||
elif isinstance(v, float):
|
||
if math.isnan(v) or math.isinf(v):
|
||
new_row[k] = None
|
||
else:
|
||
new_row[k] = v
|
||
else:
|
||
new_row[k] = v
|
||
cleaned.append(new_row)
|
||
return {
|
||
"success": True,
|
||
"count": int(len(cleaned)),
|
||
"columns": list(safe.columns),
|
||
"data": cleaned,
|
||
}
|
||
|
||
|
||
@app.get("/")
|
||
def root():
|
||
return {
|
||
"name": "EMWeb API",
|
||
"version": "0.1.0",
|
||
"endpoints": {
|
||
"GET /fin/summary": "拉取财务摘要(报告期)",
|
||
"GET /fin/quarterly-summary": "拉取季度财务摘要",
|
||
"GET /health": "健康检查",
|
||
},
|
||
"usage": {
|
||
"fin_summary": "/fin/summary?stocks=603233.SH:10500736&years=10",
|
||
"fin_quarterly_summary": "/fin/quarterly-summary?stocks=603233.SH&years=3",
|
||
},
|
||
}
|
||
|
||
|
||
@app.get("/health")
|
||
def health():
|
||
return {"status": "ok"}
|
||
|
||
|
||
@app.get("/fin/summary")
|
||
def fin_summary(
|
||
stocks: str = Query(
|
||
...,
|
||
description=(
|
||
"股票列表, 逗号分隔. 支持仅代码自动查询;其中 '603233.SH' "
|
||
"会自动解析为 ORG_CODE '10500736'。也可使用代码:ORG_CODE,"
|
||
"如 '603233.SH:10500736,600519.SH:xxxxxx'"
|
||
),
|
||
examples=["603233.SH", "603233.SH:10500736"],
|
||
),
|
||
years: int = Query(10, ge=1, le=20, description="拉取多少年的历史"),
|
||
date_type_codes: str = Query(
|
||
"1,5,6",
|
||
description="报告期代码, 逗号分隔. 1=一季报, 5=中报, 6=年报",
|
||
),
|
||
is_newest: bool = Query(True, description="是否只要最新合并报表"),
|
||
):
|
||
"""拉取多只股票的财务摘要数据, 返回 JSON."""
|
||
codes, org_map = _parse_stocks(stocks)
|
||
if not codes:
|
||
raise HTTPException(400, detail="stocks 参数不能为空")
|
||
|
||
# 自动查 ORG_CODE
|
||
if org_map is None:
|
||
try:
|
||
org_map = _fetch_org_codes(codes)
|
||
except Exception as e: # noqa: BLE001
|
||
raise HTTPException(
|
||
400,
|
||
detail=(
|
||
f"自动查询失败: {e}. 请改用 '代码:ORG_CODE' 格式, "
|
||
f"ORG_CODE 可在 Reqable 抓包切换股票时拿到."
|
||
),
|
||
)
|
||
|
||
df = get_fin_summary(
|
||
org_map,
|
||
years=years,
|
||
date_type_codes=date_type_codes,
|
||
is_newest=is_newest,
|
||
verbose=False,
|
||
)
|
||
if df.empty:
|
||
return JSONResponse(
|
||
status_code=404,
|
||
content={
|
||
"success": False,
|
||
"count": 0,
|
||
"message": "无数据, 请检查 ORG_CODE 或放宽筛选条件",
|
||
"codes_requested": codes,
|
||
"data": [],
|
||
},
|
||
)
|
||
|
||
payload = _df_to_json_payload(df)
|
||
payload["codes_requested"] = codes
|
||
payload["years"] = years
|
||
payload["date_type_codes"] = date_type_codes
|
||
return payload
|
||
|
||
|
||
@app.get("/fin/quarterly-summary")
|
||
def fin_quarterly_summary(
|
||
stocks: str = Query(
|
||
...,
|
||
description="股票代码,逗号分隔,例如 '603233.SH,600519.SH'。仅支持代码,不需要 ORG_CODE。",
|
||
examples=["603233.SH"],
|
||
),
|
||
years: int = Query(3, ge=1, le=20, description="未传 report_dates 时拉取最近多少年的季度数据"),
|
||
report_dates: Optional[str] = Query(
|
||
None,
|
||
description=(
|
||
"可选,逗号分隔的 YYYY-MM-DD 报告期。未传时取最近 years 年季度数据;"
|
||
"传入时精确筛选报告期,并按股票代码和报告期自动去重。"
|
||
),
|
||
examples=["2025-03-31,2024-12-31"],
|
||
),
|
||
):
|
||
"""拉取季度财务摘要;指定报告期时执行精确报告期筛选。"""
|
||
codes = [code.strip() for code in stocks.split(",") if code.strip()]
|
||
if not codes:
|
||
raise HTTPException(400, detail="stocks 参数不能为空")
|
||
|
||
dates = None
|
||
if report_dates is not None:
|
||
dates = [date.strip() for date in report_dates.split(",") if date.strip()]
|
||
if not dates:
|
||
raise HTTPException(400, detail="report_dates 参数不能为空")
|
||
|
||
df = get_fin_quarterly_summary(codes, years=years, report_dates=dates)
|
||
if df.empty:
|
||
return JSONResponse(
|
||
status_code=404,
|
||
content={
|
||
"success": False,
|
||
"count": 0,
|
||
"message": "无季度财务摘要数据, 请检查股票代码或筛选条件",
|
||
"codes_requested": codes,
|
||
"years": years,
|
||
"data": [],
|
||
},
|
||
)
|
||
|
||
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") |