mirror of
https://ghfast.top/https://github.com/aeroxw/easy-tdx.git
synced 2026-09-12 20:24:16 +08:00
feat(sina): 新增新浪财报三表 — 三层接入(API/CLI/Web),独立数据源
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
"""财务数据命令(暂未实现)。"""
|
||||
"""财务数据命令。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -6,16 +6,46 @@ import click
|
||||
|
||||
|
||||
@click.command("f10")
|
||||
@click.argument("market")
|
||||
@click.argument("code")
|
||||
def f10(market: str, code: str) -> None:
|
||||
"""获取 F10 财务数据(暂未实现)。
|
||||
@click.option(
|
||||
"--type",
|
||||
"report_type",
|
||||
type=click.Choice(["lrb", "fzb", "llb"], case_sensitive=False),
|
||||
default="lrb",
|
||||
help="报表类型: lrb(利润表) / fzb(资产负债表) / llb(现金流量表)",
|
||||
)
|
||||
@click.option("--num", default=8, type=int, help="取最近 N 期(默认 8)")
|
||||
@click.option("--table", "use_table", is_flag=True, help="表格输出")
|
||||
@click.option("--output", "output_fmt", type=click.Choice(["json", "table", "csv"]), default="json")
|
||||
def f10(code: str, report_type: str, num: int, use_table: bool, output_fmt: str) -> None:
|
||||
"""获取财报三表(新浪数据源,独立于 TDX 行情服务器)。
|
||||
|
||||
\b
|
||||
报表类型:
|
||||
lrb 利润表(默认)
|
||||
fzb 资产负债表
|
||||
llb 现金流量表
|
||||
|
||||
\b
|
||||
示例:
|
||||
|
||||
easy-tdx f10 SZ 000001
|
||||
easy-tdx f10 600519 # 茅台利润表,最近 8 期
|
||||
|
||||
easy-tdx f10 600519 --type fzb --num 4 # 资产负债表,最近 4 期
|
||||
|
||||
easy-tdx f10 000001 --type llb --table # 平安现金流量表,表格输出
|
||||
"""
|
||||
raise click.UsageError("f10 命令暂未实现,请使用 TdxClient.get_finance_info() API")
|
||||
from ..sina import SinaClient, SinaError
|
||||
from .output import print_error, print_output
|
||||
|
||||
fmt = "table" if use_table else output_fmt
|
||||
client = SinaClient()
|
||||
try:
|
||||
df = client.get_financial_report(code, report_type=report_type, num=num)
|
||||
except SinaError as e:
|
||||
print_error(str(e))
|
||||
raise SystemExit(1) from e
|
||||
print_output(df, fmt)
|
||||
|
||||
|
||||
@click.command("fund-flow")
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
"""新浪财经财报三表 —— 独立于 TDX 协议的 HTTP 数据源。
|
||||
|
||||
零额外依赖(标准库 urllib),无需连接 TDX 服务器即可使用。
|
||||
|
||||
支持三表:
|
||||
- ``lrb`` 利润表
|
||||
- ``fzb`` 资产负债表
|
||||
- ``llb`` 现金流量表
|
||||
|
||||
用法::
|
||||
|
||||
from easy_tdx.sina import SinaClient
|
||||
|
||||
client = SinaClient()
|
||||
# 利润表(默认 8 期)
|
||||
df = client.get_financial_report("600519", report_type="lrb")
|
||||
# → DataFrame,每行一期,列 = [报告期, 营业总收入, 营业总收入_同比, ...]
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .client import SinaClient
|
||||
from .models import ReportType, SinaError, normalize_report_type, report_type_name
|
||||
|
||||
__all__ = [
|
||||
"SinaClient",
|
||||
"ReportType",
|
||||
"SinaError",
|
||||
"normalize_report_type",
|
||||
"report_type_name",
|
||||
]
|
||||
@@ -0,0 +1,165 @@
|
||||
"""新浪财经财报三表客户端。
|
||||
|
||||
独立于 TDX 协议的 HTTP 数据源(标准库 urllib,零额外依赖)。
|
||||
公开方法返回 ``pd.DataFrame``,遵循项目 ``get_*`` 约定。
|
||||
|
||||
新浪财报接口 ``CompanyFinanceService.getFinanceReport2022`` 返回结构::
|
||||
|
||||
result.data.report_list = { "20260331": { "data": [行项...] }, ... }
|
||||
↑ 报告期(YYYYMMDD) 为键,倒序排列
|
||||
|
||||
每行项含 ``item_title``(科目名)/ ``item_value``(字符串数值)/ ``item_tongbi``
|
||||
(同比比例,如 0.06336 = +6.3%)/ ``item_display``(大类/小类)。
|
||||
|
||||
参考脚本的 bug:``item_value`` 是字符串(如 "54702912385.230000"),
|
||||
直接存入 DataFrame 导致列是 object 类型无法数值计算。本实现转 float,
|
||||
空字符串/非数值转 None(保留行,因为大类标题行有价值)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
from urllib import parse
|
||||
from urllib import request as urlrequest
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from .models import ReportType, SinaError, normalize_report_type
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_UA = (
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
||||
"(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
||||
)
|
||||
_API_URL = "https://quotes.sina.cn/cn/api/openapi.php/CompanyFinanceService.getFinanceReport2022"
|
||||
|
||||
|
||||
def _http_get_json(url: str, params: dict[str, str], timeout: float = 15.0) -> Any:
|
||||
"""GET JSON,自动 urlencode query 参数(stdlib urllib,monkeypatch 点)。"""
|
||||
full = f"{url}?{parse.urlencode(params)}" if params else url
|
||||
req = urlrequest.Request(full, headers={"User-Agent": _UA})
|
||||
with urlrequest.urlopen(req, timeout=timeout) as resp:
|
||||
return json.loads(resp.read().decode("utf-8"))
|
||||
|
||||
|
||||
def _to_float(v: Any) -> float | None:
|
||||
"""item_value 字符串转 float,空/非数值返回 None。"""
|
||||
if v is None or v == "":
|
||||
return None
|
||||
try:
|
||||
return float(v)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _format_period(period: str) -> str:
|
||||
"""``20260331`` → ``2026-03-31``。"""
|
||||
if len(period) == 8 and period.isdigit():
|
||||
return f"{period[:4]}-{period[4:6]}-{period[6:8]}"
|
||||
return period
|
||||
|
||||
|
||||
class SinaClient:
|
||||
"""新浪财经财报检索客户端(无状态 HTTP,无需 connect/close)。
|
||||
|
||||
用法::
|
||||
|
||||
from easy_tdx.sina import SinaClient
|
||||
|
||||
client = SinaClient()
|
||||
# 利润表(默认 8 期,最新在前)
|
||||
df = client.get_financial_report("600519", report_type="lrb")
|
||||
# → DataFrame,每行一期,列 = [报告期, 营业总收入, 营业总收入_同比, ...]
|
||||
"""
|
||||
|
||||
def __init__(self, *, timeout: float = 15.0) -> None:
|
||||
self.timeout = timeout
|
||||
|
||||
def _build_paper_code(self, code: str) -> str:
|
||||
"""6 位代码 → 新浪 paperCode(sh/sz 前缀)。"""
|
||||
prefix = "sh" if code.startswith("6") else "sz"
|
||||
return f"{prefix}{code}"
|
||||
|
||||
def get_financial_report(
|
||||
self,
|
||||
code: str,
|
||||
report_type: ReportType | str = "lrb",
|
||||
*,
|
||||
num: int = 8,
|
||||
) -> pd.DataFrame:
|
||||
"""获取财报三表数据。
|
||||
|
||||
Args:
|
||||
code: 6 位股票代码(不含市场前缀),如 ``600519``。
|
||||
report_type: 报表类型,标准值 ``lrb``(利润表)/ ``fzb``(资产负债表)/
|
||||
``llb``(现金流量表),也接受中文/英文别名(如 ``利润表``/``income``)。
|
||||
num: 取最近 N 期(默认 8)。
|
||||
|
||||
Returns:
|
||||
``DataFrame``,每行一期报告(最新在前)。
|
||||
|
||||
- 第一列 ``报告期``(``YYYY-MM-DD`` 格式)
|
||||
- 其余列为科目名(如 ``营业总收入``),值为 float
|
||||
- 有同比数据的科目附加 ``{科目}_同比`` 列(float 比例值,如 0.06336 = +6.3%)
|
||||
|
||||
大类标题行(如 ``流动资产``)的 ``item_value`` 为 None(保留行以反映报表结构)。
|
||||
无结果时返回空 DataFrame(含 ``报告期`` 列名)。
|
||||
"""
|
||||
rt = normalize_report_type(str(report_type))
|
||||
rows = self._query(code, rt, num=num)
|
||||
if not rows:
|
||||
return pd.DataFrame(columns=["报告期"])
|
||||
return pd.DataFrame(rows)
|
||||
|
||||
def _query(self, code: str, report_type: str, *, num: int) -> list[dict[str, Any]]:
|
||||
"""调用新浪 API,解析为「按报告期」的行列表。
|
||||
|
||||
整个 HTTP + 解析过程统一捕获异常并转为 ``SinaError``。
|
||||
"""
|
||||
paper_code = self._build_paper_code(code)
|
||||
params = {
|
||||
"paperCode": paper_code,
|
||||
"source": report_type,
|
||||
"type": "0",
|
||||
"page": "1",
|
||||
"num": str(num),
|
||||
}
|
||||
try:
|
||||
d = _http_get_json(_API_URL, params, timeout=self.timeout)
|
||||
report_list = (
|
||||
d.get("result", {}).get("data", {}).get("report_list", {})
|
||||
if isinstance(d, dict)
|
||||
else {}
|
||||
)
|
||||
if not report_list:
|
||||
return []
|
||||
|
||||
rows: list[dict[str, Any]] = []
|
||||
# 按报告期倒序,取最近 num 期
|
||||
for period in sorted(report_list.keys(), reverse=True)[:num]:
|
||||
obj = report_list[period]
|
||||
if not isinstance(obj, dict):
|
||||
continue
|
||||
rec: dict[str, Any] = {"报告期": _format_period(period)}
|
||||
for it in obj.get("data", []) or []:
|
||||
if not isinstance(it, dict):
|
||||
continue
|
||||
title = it.get("item_title", "")
|
||||
if not title:
|
||||
continue
|
||||
# item_value 字符串转 float(空/非数值 → None,保留行)
|
||||
rec[title] = _to_float(it.get("item_value"))
|
||||
tongbi = it.get("item_tongbi")
|
||||
if tongbi not in (None, ""):
|
||||
tb = _to_float(tongbi)
|
||||
if tb is not None:
|
||||
rec[f"{title}_同比"] = tb
|
||||
rows.append(rec)
|
||||
return rows
|
||||
except SinaError:
|
||||
raise
|
||||
except Exception as e: # noqa: BLE001 — HTTP/JSON/解析统一转领域异常
|
||||
raise SinaError(f"新浪财报查询失败: {e}") from e
|
||||
@@ -0,0 +1,53 @@
|
||||
"""新浪财经数据模型。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal
|
||||
|
||||
from easy_tdx.exceptions import TdxError
|
||||
|
||||
# 财报三表类型(新浪 API 的 source 参数值)
|
||||
ReportType = Literal["lrb", "fzb", "llb"]
|
||||
|
||||
# 中文别名 → API source 值(CLI/Web 层方便用户)
|
||||
_REPORT_TYPE_ALIASES: dict[str, str] = {
|
||||
"lrb": "lrb",
|
||||
"利润表": "lrb",
|
||||
"income": "lrb",
|
||||
"fzb": "fzb",
|
||||
"资产负债表": "fzb",
|
||||
"balance": "fzb",
|
||||
"llb": "llb",
|
||||
"现金流量表": "llb",
|
||||
"cashflow": "llb",
|
||||
}
|
||||
|
||||
_REPORT_TYPE_NAMES: dict[str, str] = {
|
||||
"lrb": "利润表",
|
||||
"fzb": "资产负债表",
|
||||
"llb": "现金流量表",
|
||||
}
|
||||
|
||||
|
||||
def normalize_report_type(s: str) -> str:
|
||||
"""归一化 report_type 输入:接受 lrb/fzb/llb 及中文/英文别名,返回标准三值之一。
|
||||
|
||||
Raises:
|
||||
ValueError: 无法识别的输入。
|
||||
"""
|
||||
key = s.strip().lower() if s.isascii() else s.strip()
|
||||
if key in _REPORT_TYPE_ALIASES:
|
||||
return _REPORT_TYPE_ALIASES[key]
|
||||
raise ValueError(
|
||||
f"无法识别的报表类型: {s!r}"
|
||||
"(支持 lrb/利润表/income、fzb/资产负债表/balance、llb/现金流量表/cashflow)"
|
||||
)
|
||||
|
||||
|
||||
def report_type_name(report_type: str) -> str:
|
||||
"""报表类型的中文名(用于展示)。"""
|
||||
return _REPORT_TYPE_NAMES.get(report_type, report_type)
|
||||
|
||||
|
||||
class SinaError(TdxError):
|
||||
"""新浪财经数据请求或解析失败。"""
|
||||
@@ -152,6 +152,7 @@ def _create_app(
|
||||
from easy_tdx.web.routers.mac_quotes import router as mac_quotes_router
|
||||
from easy_tdx.web.routers.market import router as market_router
|
||||
from easy_tdx.web.routers.realtime import router as realtime_router
|
||||
from easy_tdx.web.routers.sina import router as sina_router
|
||||
|
||||
app.include_router(market_router, prefix="/api/v1")
|
||||
app.include_router(bars_router, prefix="/api/v1")
|
||||
@@ -169,5 +170,7 @@ def _create_app(
|
||||
app.include_router(indicator_router, prefix="/api/v1")
|
||||
# 公告检索路由(巨潮资讯网,独立数据源)
|
||||
app.include_router(announcement_router, prefix="/api/v1")
|
||||
# 新浪财报三表路由(独立数据源)
|
||||
app.include_router(sina_router, prefix="/api/v1")
|
||||
|
||||
return app
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
"""新浪财报三表路由(独立数据源,不依赖 TDX 服务器)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
|
||||
from easy_tdx.web.schemas import DataFrameResponse
|
||||
|
||||
router = APIRouter(tags=["sina"])
|
||||
|
||||
|
||||
@router.get("/sina/financial-report", response_model=DataFrameResponse)
|
||||
async def financial_report(
|
||||
code: str = Query(..., min_length=6, max_length=6, description="6位股票代码"),
|
||||
type: str = Query(
|
||||
"lrb",
|
||||
pattern=r"^(lrb|fzb|llb)$",
|
||||
description="报表类型: lrb(利润表)/fzb(资产负债表)/llb(现金流量表)",
|
||||
),
|
||||
num: int = Query(8, ge=1, le=40, description="取最近 N 期"),
|
||||
) -> DataFrameResponse:
|
||||
"""获取财报三表(新浪数据源,独立于 TDX 行情服务器)。
|
||||
|
||||
返回每行一期报告(最新在前),列为科目名 + ``{科目}_同比``(如有同比)。
|
||||
"""
|
||||
from easy_tdx.sina import SinaClient, SinaError
|
||||
|
||||
client = SinaClient()
|
||||
|
||||
def _fetch() -> DataFrameResponse:
|
||||
df = client.get_financial_report(code, report_type=type, num=num)
|
||||
return DataFrameResponse.from_dataframe(df)
|
||||
|
||||
try:
|
||||
return await asyncio.to_thread(_fetch)
|
||||
except SinaError as e:
|
||||
raise HTTPException(status_code=503, detail=str(e)) from e
|
||||
Reference in New Issue
Block a user