"""股票数据路由:搜索、行情、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) 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}