Files
auv/backend/routes/stock.py
T
Sakurasan b1f216b2a4 feat: K线增加分钟/小时周期切换,拆分独立KLineCard组件
- 后端:腾讯 mkline 新增 /api/stock/history-minute(m1/m5/m15/m30/m60)
- 新组件 kline-card.tsx:自包含周期/折线蜡烛/指标开关与K线数据加载,
  切周期只重拉K线,不刷新页面其他模块
- 详情页瘦身为独立模块:K线图 / 今开最高最低昨收 / 每日行情明细互不耦合
- 时间统一按 UTC 解析传 Unix 秒,修复日线 invalid date/N/A 与分钟线时区问题
- 资金流向失败降级为非致命,不再导致整页报错
- vite 构建拆分 recharts/lightweight-charts/router 独立 chunk
2026-08-28 17:43:11 +08:00

289 lines
12 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
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("/history-minute", summary="分钟/小时级K线")
async def stock_history_minute(
code: str = Query(..., description="6位股票代码"),
period: str = Query("60", description="周期:m1/m5/m15/m30/m60(60=小时线)"),
count: int = Query(320, description="K线数量"),
):
if not re.match(r"^\d{6}$", code):
raise HTTPException(status_code=400, detail="股票代码格式错误,需为6位数字")
if period not in ("m1", "m5", "m15", "m30", "m60"):
raise HTTPException(status_code=400, detail="period 仅支持 m1/m5/m15/m30/m60")
klines = await tencent.fetch_history_minute(code, period, count)
if not klines:
raise HTTPException(status_code=404, detail="未获取到分钟K线数据")
return {"data": klines, "count": len(klines), "source": "tencent-minute"}
@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}