腾讯 fqkline 仅返回 6 个字段(date/open/close/high/low/volume), 不含成交额,导致 fund-flow 路由补充的 turnover 始终为 0。 在路由层额外调用 eastmoney.fetch_kline_history 获取成交额映射, 优先使用东方财富的成交额数据补充,腾讯 kline_map 作为兜底。 Co-Authored-By: Claude <noreply@anthropic.com>
272 lines
11 KiB
Python
272 lines
11 KiB
Python
"""股票数据路由:搜索、行情、K线、资金流向"""
|
||
|
||
from fastapi import APIRouter, Query, HTTPException
|
||
import re
|
||
|
||
from services import tencent, sina, eastmoney, mootdx
|
||
from models import StockSearchResult, StockQuote, KLineData, FundFlowData, FundFlowSummary, CompanyProfile, FinancialReportItem, FinancialDataResponse
|
||
|
||
router = APIRouter()
|
||
|
||
|
||
@router.get("/search", summary="股票搜索")
|
||
async def search_stock(keyword: str = Query(..., description="关键词:名称/代码/拼音")):
|
||
keyword = keyword.strip()
|
||
if not keyword:
|
||
return {"data": [], "count": 0}
|
||
|
||
results = await tencent.search_stock(keyword)
|
||
|
||
# 降级:搜索无结果但 keyword 是 6 位数字代码,直接查行情接口
|
||
if not results and re.match(r"^\d{6}$", keyword):
|
||
direct = await tencent.lookup_by_quote(keyword)
|
||
if direct:
|
||
results = [direct]
|
||
|
||
# 去重
|
||
seen = set()
|
||
unique = []
|
||
for r in results:
|
||
if r["code"] not in seen:
|
||
seen.add(r["code"])
|
||
unique.append(r)
|
||
|
||
limited = unique[:20]
|
||
return {"data": limited, "count": len(limited)}
|
||
|
||
|
||
@router.get("/quote", summary="实时行情")
|
||
async def stock_quote(code: str = Query(..., description="6位股票代码")):
|
||
if not re.match(r"^\d{6}$", code):
|
||
raise HTTPException(status_code=400, detail="股票代码格式错误,需为6位数字")
|
||
|
||
data = await tencent.fetch_quote(code)
|
||
if not data:
|
||
raise HTTPException(status_code=404, detail="股票不存在或已停牌")
|
||
return {"data": data}
|
||
|
||
|
||
@router.get("/history", summary="历史K线")
|
||
async def stock_history(
|
||
code: str = Query(..., description="6位股票代码"),
|
||
days: int = Query(90, description="天数"),
|
||
):
|
||
if not re.match(r"^\d{6}$", code):
|
||
raise HTTPException(status_code=400, detail="股票代码格式错误,需为6位数字")
|
||
|
||
# 主数据源:通达信 mootdx(TCP直连,不被限流,稳定可靠)
|
||
md_klines = await mootdx.fetch_kline_history(code, days)
|
||
if md_klines and len(md_klines) >= 2:
|
||
return {"data": md_klines, "count": len(md_klines), "source": "mootdx"}
|
||
|
||
# 降级1:东方财富 push2his(含成交额/涨跌幅/振幅/换手率)
|
||
em_klines = await eastmoney.fetch_kline_history(code, days)
|
||
if em_klines and len(em_klines) >= 2:
|
||
return {"data": em_klines, "count": len(em_klines), "source": "eastmoney"}
|
||
|
||
# 降级2:腾讯(含涨跌幅)
|
||
tencent_klines = await tencent.fetch_history(code, days)
|
||
if tencent_klines and len(tencent_klines) >= 2:
|
||
return {"data": tencent_klines, "count": len(tencent_klines), "source": "tencent"}
|
||
|
||
# 降级3:新浪
|
||
sina_klines = await sina.fetch_history(code, days)
|
||
if sina_klines:
|
||
return {"data": sina_klines, "count": len(sina_klines), "source": "sina"}
|
||
|
||
if md_klines:
|
||
return {"data": md_klines, "count": len(md_klines), "source": "mootdx"}
|
||
if em_klines:
|
||
return {"data": em_klines, "count": len(em_klines), "source": "eastmoney"}
|
||
if tencent_klines:
|
||
return {"data": tencent_klines, "count": len(tencent_klines), "source": "tencent"}
|
||
|
||
raise HTTPException(status_code=404, detail="未获取到K线数据")
|
||
|
||
|
||
@router.get("/profile", response_model=dict, summary="公司概况")
|
||
async def company_profile(code: str = Query(..., description="6位股票代码")):
|
||
"""获取东方财富F10公司概况数据"""
|
||
if not re.match(r"^\d{6}$", code):
|
||
raise HTTPException(status_code=400, detail="股票代码格式错误,需为6位数字")
|
||
data = await eastmoney.fetch_company_profile(code)
|
||
if not data:
|
||
raise HTTPException(status_code=404, detail="未获取到公司概况数据")
|
||
return {"data": data}
|
||
|
||
|
||
@router.get("/financial", response_model=dict, summary="财务分析数据")
|
||
async def financial_data(
|
||
code: str = Query(..., description="6位股票代码"),
|
||
years: int = Query(5, description="获取年数", ge=1, le=10),
|
||
):
|
||
"""获取东方财富财务分析指标数据(每股指标、盈利能力、成长能力、偿债能力等)"""
|
||
if not re.match(r"^\d{6}$", code):
|
||
raise HTTPException(status_code=400, detail="股票代码格式错误,需为6位数字")
|
||
data = await eastmoney.fetch_financial_data_main(code, years)
|
||
|
||
# 获取股票名称
|
||
name = code
|
||
if data:
|
||
quote = await tencent.fetch_quote(code)
|
||
if quote:
|
||
name = quote.get("name", code)
|
||
|
||
if not data:
|
||
raise HTTPException(status_code=404, detail="未获取到财务数据")
|
||
|
||
return {
|
||
"data": data,
|
||
"count": len(data),
|
||
"code": code,
|
||
"name": name,
|
||
}
|
||
|
||
|
||
@router.get("/business-segments", response_model=dict, summary="主营构成/收入构成")
|
||
async def business_segments(
|
||
code: str = Query(..., description="6位股票代码"),
|
||
by_type: str = Query("product", description="分类方式: product=按产品, industry=按行业, region=按地区"),
|
||
years: int = Query(3, description="获取年数", ge=1, le=30),
|
||
):
|
||
"""获取东方财富经营分析 - 主营构成数据(收入构成、成本构成、利润构成、毛利率)"""
|
||
if not re.match(r"^\d{6}$", code):
|
||
raise HTTPException(status_code=400, detail="股票代码格式错误,需为6位数字")
|
||
if by_type not in ("product", "industry", "region"):
|
||
raise HTTPException(status_code=400, detail="by_type 需为 product/industry/region")
|
||
|
||
data = await eastmoney.fetch_business_segments(code, by_type, years)
|
||
if not data:
|
||
raise HTTPException(status_code=404, detail="未获取到主营构成数据")
|
||
|
||
quote = await tencent.fetch_quote(code)
|
||
name = quote.get("name", code) if quote else code
|
||
|
||
return {
|
||
"data": data,
|
||
"count": len(data),
|
||
"code": code,
|
||
"name": name,
|
||
"byType": by_type,
|
||
}
|
||
|
||
|
||
@router.get("/fund-flow", summary="资金流向")
|
||
async def stock_fund_flow(
|
||
code: str = Query(..., description="6位股票代码"),
|
||
name: str = Query("", description="股票名称(MX 备选源需要)"),
|
||
days: int = Query(21, description="目标天数"),
|
||
):
|
||
if not re.match(r"^\d{6}$", code):
|
||
raise HTTPException(status_code=400, detail="股票代码格式错误,需为6位数字")
|
||
|
||
# 数据源1:push2his daykline(主源,无配额限制)
|
||
data = await eastmoney.fetch_fund_flow_daykline(code, days)
|
||
|
||
# 数据源2:MX API(备选,push2his 无数据时降级)
|
||
if not data:
|
||
if not name:
|
||
quote = await tencent.fetch_quote(code)
|
||
if quote:
|
||
name = quote.get("name", "")
|
||
if name:
|
||
fetch_days = max(90, days)
|
||
mx_data = await eastmoney.fetch_mx_api(name, fetch_days)
|
||
if mx_data:
|
||
mx_data.sort(key=lambda x: x["date"])
|
||
data = []
|
||
for item in mx_data:
|
||
main_net = item["mainNetInflow"]
|
||
data.append({
|
||
"date": item["date"],
|
||
"mainNetInflow": main_net,
|
||
"superLargeInflow": max(0, main_net * 0.4),
|
||
"superLargeOutflow": abs(min(0, main_net * 0.4)),
|
||
"largeInflow": max(0, main_net * 0.6),
|
||
"largeOutflow": abs(min(0, main_net * 0.6)),
|
||
"mediumInflow": 0, "mediumOutflow": 0,
|
||
"smallInflow": 0, "smallOutflow": 0,
|
||
"mainNetInflowPercent": 0,
|
||
"superLargeInflowPercent": 0, "superLargeOutflowPercent": 0,
|
||
"largeInflowPercent": 0, "largeOutflowPercent": 0,
|
||
"mediumInflowPercent": 0, "mediumOutflowPercent": 0,
|
||
"smallInflowPercent": 0, "smallOutflowPercent": 0,
|
||
"turnover": item.get("amount", 0),
|
||
"mainForceNet": main_net,
|
||
"retailNet": 0,
|
||
"changePercent": 0,
|
||
"closePrice": 0,
|
||
})
|
||
|
||
if not data:
|
||
raise HTTPException(status_code=404, detail="未获取到资金流向数据")
|
||
|
||
# 从腾讯 K 线补充收盘价/涨跌幅
|
||
kline_map = await tencent.fetch_kline_map(code, days)
|
||
# 从东方财富 K 线补充成交额(腾讯 fqkline 不含成交额字段)
|
||
em_klines = await eastmoney.fetch_kline_history(code, days)
|
||
em_kline_map = {}
|
||
if em_klines:
|
||
for k in em_klines:
|
||
if k.get("turnover"):
|
||
em_kline_map[k["date"]] = k["turnover"]
|
||
for d in data:
|
||
ki = kline_map.get(d["date"], {})
|
||
if d.get("turnover", 0) == 0:
|
||
# 优先从东方财富 kline 拿成交额,其次腾讯
|
||
d["turnover"] = em_kline_map.get(d["date"], ki.get("turnover", 0))
|
||
if d.get("closePrice", 0) == 0:
|
||
d["closePrice"] = ki.get("close", 0)
|
||
if d.get("changePercent", 0) == 0:
|
||
d["changePercent"] = ki.get("changePercent", 0)
|
||
|
||
# 统一排序,只保留最新 days 条
|
||
data.sort(key=lambda x: x["date"])
|
||
if len(data) > days:
|
||
data = data[-days:]
|
||
|
||
# 计算累计值
|
||
cumulative_main = 0
|
||
cumulative_retail = 0
|
||
for d in data:
|
||
cumulative_main += d.get("mainForceNet", 0)
|
||
cumulative_retail += d.get("retailNet", 0)
|
||
d["cumulativeMainNet"] = cumulative_main
|
||
d["cumulativeRetailNet"] = cumulative_retail
|
||
|
||
total_main = sum(d.get("mainForceNet", 0) for d in data)
|
||
total_turnover = sum(d.get("turnover", 0) for d in data)
|
||
total_large_inflow = sum(d.get("largeInflow", 0) for d in data)
|
||
positive = sum(1 for d in data if d.get("mainForceNet", 0) > 0)
|
||
negative = sum(1 for d in data if d.get("mainForceNet", 0) < 0)
|
||
|
||
return {
|
||
"data": data,
|
||
"count": len(data),
|
||
"stockCode": code,
|
||
"summary": {
|
||
"totalMainNet": total_main,
|
||
"totalTurnover": total_turnover,
|
||
"totalLargeInflow": total_large_inflow,
|
||
"avgDailyMainNet": total_main / len(data) if data else 0,
|
||
"positiveDays": positive,
|
||
"negativeDays": negative,
|
||
},
|
||
}
|
||
|
||
|
||
@router.get("/mx-tool", summary="MX 通用金融数据查询")
|
||
async def mx_tool(
|
||
query: str = Query(..., description="自然语言问句,如:「格力电器2024年净利润」「沪深300最新收盘价」「市盈率最低的50只股票」"),
|
||
):
|
||
"""通过 MX 妙想 API 查询任意金融数据(A股/港股/美股/基金/债券/指数/板块/宏观/新闻/公告/选股等)
|
||
|
||
单次查询最多支持 20 只证券。返回原始结构化表格(sheetName + columns + items)。
|
||
"""
|
||
if not query or not query.strip():
|
||
raise HTTPException(status_code=400, detail="查询内容不能为空")
|
||
data = await eastmoney.fetch_mx_tool(query.strip())
|
||
if not data:
|
||
raise HTTPException(status_code=404, detail="MX 查询无结果或所有 API Key 已耗尽")
|
||
return {"data": data}
|