Files
auv/backend/routes/stock.py
T
2026-07-06 19:44:40 +08:00

283 lines
10 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.
"""股票数据路由:搜索、行情、K线、资金流向"""
from fastapi import APIRouter, Query, HTTPException
import re
import math
from services import tencent, sina, eastmoney
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位数字")
# 主数据源:腾讯
klines = await tencent.fetch_history(code, days)
if len(klines) >= 2:
return {"data": klines, "count": len(klines), "source": "tencent"}
# 降级:新浪
sina_klines = await sina.fetch_history(code, days)
if sina_klines:
return {"data": sina_klines, "count": len(sina_klines), "source": "sina"}
if klines:
return {"data": klines, "count": len(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="股票名称"),
days: int = Query(21, description="目标天数"),
):
if not re.match(r"^\d{6}$", code):
raise HTTPException(status_code=400, detail="股票代码格式错误,需为6位数字")
# 获取股票名称(如果未提供)
stock_name = name
if not stock_name:
quote = await tencent.fetch_quote(code)
if quote:
stock_name = quote.get("name", "")
# MX 请求 90 天以获取尽可能多的数据,最终只取最近 days 条
fetch_days = max(90, days)
# 数据源1MX API
mx_data = None
if stock_name:
mx_data = await eastmoney.fetch_mx_api(stock_name, fetch_days)
# 数据源2push2his 补充(MX 数据不足时尝试)
push2his_data = None
need_more = not mx_data or len(mx_data) < days
if need_more:
push2his_data = await eastmoney.fetch_push2his(code, fetch_days)
# 数据源3:腾讯K线(合并收盘价、涨跌幅)
kline_map = await tencent.fetch_kline_map(code, fetch_days)
# ---- 解析 ----
def _build_entry(date: str, main_net: float, turnover: float, force_net: float,
super_large: float, large: float, retail: float, pcts: dict) -> dict:
ki = kline_map.get(date, {})
return {
"date": date,
"mainNetInflow": main_net,
"superLargeInflow": max(0, super_large),
"superLargeOutflow": abs(min(0, super_large)),
"largeInflow": max(0, large),
"largeOutflow": abs(min(0, large)),
"mediumInflow": 0,
"mediumOutflow": 0,
"smallInflow": 0,
"smallOutflow": 0,
"mainNetInflowPercent": pcts.get("main", 0),
"superLargeInflowPercent": pcts.get("superLarge", 0),
"superLargeOutflowPercent": pcts.get("superLargeOut", 0),
"largeInflowPercent": pcts.get("large", 0),
"largeOutflowPercent": pcts.get("largeOut", 0),
"mediumInflowPercent": pcts.get("medium", 0),
"mediumOutflowPercent": pcts.get("mediumOut", 0),
"smallInflowPercent": pcts.get("small", 0),
"smallOutflowPercent": pcts.get("smallOut", 0),
"turnover": turnover,
"mainForceNet": force_net,
"retailNet": retail,
"changePercent": ki.get("changePercent", 0),
"closePrice": ki.get("close", 0),
}
parsed_data = []
# 先解析 MX 数据(主力净额准确)
if mx_data:
mx_data.sort(key=lambda x: x["date"])
for item in mx_data:
main_net = item["mainNetInflow"]
main_force = main_net
super_large = main_net * 0.4
large = main_net * 0.6
parsed_data.append(_build_entry(
item["date"], main_net, item["amount"], main_force,
super_large, large, 0, {},
))
# 补全 push2his 数据(f52=主力净流入为权威值)
if push2his_data:
existing_dates = {e["date"] for e in parsed_data}
for line in push2his_data:
fields = line.split(",")
if len(fields) < 11:
continue
date = fields[0]
if date in existing_dates:
continue
ki = kline_map.get(date, {})
def f(i): return float(fields[i]) if fields[i] else 0
main_force = f(1) # f52 主力净流入(权威值,与 MX 口径一致)
super_large_net = main_force * 0.4
large_net = main_force * 0.6
retail = f(4) + f(5) # 中单+小单净流入
pcts = {
"main": f(6), "superLarge": f(7), "superLargeOut": 0,
"large": f(8), "largeOut": 0, "medium": f(9),
"mediumOut": 0, "small": f(10), "smallOut": 0,
}
parsed_data.append(_build_entry(
date, main_force, ki.get("turnover", 0), main_force,
super_large_net, large_net, retail, pcts,
))
if not parsed_data:
raise HTTPException(status_code=404, detail="未获取到资金流向数据")
# 统一排序,只保留最新 days 条
parsed_data.sort(key=lambda x: x["date"])
if len(parsed_data) > days:
parsed_data = parsed_data[-days:]
# 重新计算累计值
cumulative_main = 0
cumulative_retail = 0
for d in parsed_data:
cumulative_main += d["mainForceNet"]
cumulative_retail += d["retailNet"]
d["cumulativeMainNet"] = cumulative_main
d["cumulativeRetailNet"] = cumulative_retail
total_main = sum(d["mainForceNet"] for d in parsed_data)
total_turnover = sum(d["turnover"] for d in parsed_data)
total_large_inflow = sum(d["largeInflow"] for d in parsed_data)
positive = sum(1 for d in parsed_data if d["mainForceNet"] > 0)
negative = sum(1 for d in parsed_data if d["mainForceNet"] < 0)
return {
"data": parsed_data,
"count": len(parsed_data),
"stockCode": code,
"summary": {
"totalMainNet": total_main,
"totalTurnover": total_turnover,
"totalLargeInflow": total_large_inflow,
"avgDailyMainNet": total_main / len(parsed_data) if parsed_data else 0,
"positiveDays": positive,
"negativeDays": negative,
},
}