Files
EMWebApi/api.py
T
toor cd7ca92cff Initial EMWeb API implementation
Add financial summary and quarterly endpoints with project configuration and documentation.
2026-08-15 00:20:47 +08:00

226 lines
7.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
东方财富财务摘要 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")