Files
auv/backend/routes/stock.py
T

264 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
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位数字")
# 数据源1push2his daykline(主源,无配额限制)
data = await eastmoney.fetch_fund_flow_daykline(code, days)
# 数据源2MX 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)
for d in data:
ki = kline_map.get(d["date"], {})
if d.get("turnover", 0) == 0:
d["turnover"] = 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}