From 6390545e610062b267bd6085bd9cf9f77f2065e1 Mon Sep 17 00:00:00 2001 From: Sakurasan <26715255+Sakurasan@users.noreply.github.com> Date: Mon, 6 Jul 2026 22:27:45 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BD=BF=E7=94=A8push2=20=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E6=8E=A5=E5=8F=A3=EF=BC=8C=E4=BC=98=E5=8C=96=E4=BB=A3=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/routes/stock.py | 200 +++++++--------- backend/services/eastmoney.py | 428 +++++++++++++++++++++++++--------- backend/services/tencent.py | 25 +- docs/api-data-sources.md | 208 +++++++++++++++++ src/lib/stock-api.ts | 17 +- src/routes/sectors.tsx | 137 ++++++++--- src/routes/stock.$code.tsx | 11 +- 7 files changed, 750 insertions(+), 276 deletions(-) create mode 100644 docs/api-data-sources.md diff --git a/backend/routes/stock.py b/backend/routes/stock.py index f49c6a7..bcd35c0 100644 --- a/backend/routes/stock.py +++ b/backend/routes/stock.py @@ -2,7 +2,6 @@ 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 @@ -55,18 +54,25 @@ async def stock_history( 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"} + # 主数据源:东方财富 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"} - # 降级:新浪 + # 降级1:腾讯(含涨跌幅) + 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"} + + # 降级2:新浪 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"} + 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线数据") @@ -141,141 +147,93 @@ async def business_segments( @router.get("/fund-flow", summary="资金流向") async def stock_fund_flow( code: str = Query(..., description="6位股票代码"), - name: str = Query("", description="股票名称"), + 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位数字") - # 获取股票名称(如果未提供) - stock_name = name - if not stock_name: - quote = await tencent.fetch_quote(code) - if quote: - stock_name = quote.get("name", "") + # 数据源1:push2his daykline(主源,无配额限制) + data = await eastmoney.fetch_fund_flow_daykline(code, days) - # MX 请求 90 天以获取尽可能多的数据,最终只取最近 days 条 - fetch_days = max(90, 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, + }) - # 数据源1:MX API - mx_data = None - if stock_name: - mx_data = await eastmoney.fetch_mx_api(stock_name, fetch_days) - - # 数据源2:push2his 补充(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: + if not 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:] + # 从腾讯 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 parsed_data: - cumulative_main += d["mainForceNet"] - cumulative_retail += d["retailNet"] + 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["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) + 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": parsed_data, - "count": len(parsed_data), + "data": data, + "count": len(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, + "avgDailyMainNet": total_main / len(data) if data else 0, "positiveDays": positive, "negativeDays": negative, }, diff --git a/backend/services/eastmoney.py b/backend/services/eastmoney.py index e89734b..9d14ea8 100644 --- a/backend/services/eastmoney.py +++ b/backend/services/eastmoney.py @@ -1,6 +1,7 @@ """东方财富 API 客户端(资金流向数据)""" import asyncio +import time import httpx import json import os @@ -10,7 +11,8 @@ from typing import Optional, List from services.cache import get_cache, set_cache -# ---- API Key 轮询 ---- + +# ---- API Key 轮询(MX 备选源用)---- _api_keys: List[str] = [] _key_index: int = -1 @@ -61,18 +63,6 @@ def _rotate_key(): set_cache("_mx_key_index", str(_key_index), ttl_hours=24) -# ---- 工具函数 ---- - - -def get_eastmoney_market(code: str) -> str: - """获取东方财富格式的市场标识""" - if code.startswith("688"): - return "6" - if code.startswith("60"): - return "1" - return "0" - - def parse_amount(text) -> float: """解析金额文本(支持 1.432亿元, -9915万元, 0元)""" if isinstance(text, (int, float)): @@ -96,7 +86,7 @@ def parse_amount(text) -> float: return sign * value -# ---- MX API ---- +# ---- MX API(备选源)---- async def _call_mx_api(api_key: str, name: str, days: int) -> Optional[dict]: @@ -176,12 +166,10 @@ async def fetch_mx_api(name: str, days: int) -> Optional[List[dict]]: """调用东方财富妙想MX API获取资金流向(缓存6小时,多key轮询)""" cache_key = f"mx_fund_flow:{name}:{days}" - # 缓存命中 cached = get_cache(cache_key) if cached is not None: return json.loads(cached) - # 多 key 轮询:按序尝试,遇到 113(超限) 自动切下一个 key if not _ensure_keys(): return None @@ -207,7 +195,6 @@ async def fetch_mx_api(name: str, days: int) -> Optional[List[dict]]: _rotate_key() continue - # 成功 data_list = _parse_mx_response(result) if data_list: set_cache(cache_key, json.dumps(data_list, ensure_ascii=False)) @@ -217,36 +204,196 @@ async def fetch_mx_api(name: str, days: int) -> Optional[List[dict]]: return None +def get_eastmoney_market(code: str) -> str: + """获取东方财富格式的市场标识""" + if code.startswith("688"): + return "6" + if code.startswith("60"): + return "1" + return "0" + + # ---- 板块数据 ---- +# 从东方财富 bkzj/list.js 逆向的字段映射 +# f62=主力净流入, f184=主力净流入占比 +# f66=超大单净流入, f69=超大单净流入占比 +# f72=大单净流入, f75=大单净流入占比 +# f78=中单净流入, f81=中单净流入占比 +# f84=小单净流入, f87=小单净流入占比 +# f70=成交额 +SECTOR_FIELDS = "f12,f14,f2,f3,f62,f184,f66,f69,f72,f75,f78,f81,f84,f87,f70" + # 东方财富板块类型映射 -SECTOR_TYPE_MAP = { - "industry": "m:90+t:2", # 行业板块 - "concept": "m:90+t:3", # 概念板块 +SECTOR_MEDIA_MAP = { + "industry": "m:90+s:4", + "concept": "m:90+t:3", } -# 板块列表字段:f12=代码, f14=名称, f3=涨跌幅%, f62=主力净流入, f184=主力净流入占比 -# f66=超大单净流入, f69=超大单净流入占比, f70=成交额, f78=小单净流入 -SECTOR_FIELDS = "f12,f14,f2,f3,f4,f62,f184,f66,f69,f70,f78" +# 东方财富 UT 令牌管理 +_em_ut: str = "8dec03ba335b81bf4ebdf7b29ec27d15" +_em_ut_lock = asyncio.Lock() -# ---- 板块数据(通过 akshare 调用同花顺数据源)---- +async def _refresh_em_ut() -> str: + """ + 从东方财富前端 JS 中提取最新的 ut 令牌。 + 按优先级尝试: + 1. bkzj/list.js(板块页专用) + 2. common/emdataview.js(通用数据组件) + """ + urls = [ + "https://data.eastmoney.com/newstatic/js/bkzj/list.js", + "https://data.eastmoney.com/newstatic/js/common/emdataview.js", + ] + headers = { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", + "Referer": "https://data.eastmoney.com/bkzj/hy.html", + } -import akshare as ak -import pandas as pd + async with httpx.AsyncClient() as client: + for url in urls: + try: + resp = await client.get(url, headers=headers, timeout=10) + if resp.status_code != 200: + continue + # 匹配 ut: 'xxxx' 或 ut:'xxxx' 或 ut: "xxxx" + m = re.search(r"""ut['"]?\s*:\s*['"]([a-f0-9]{32})['"]""", resp.text) + if m: + token = m.group(1) + print(f"[eastmoney] 已刷新 UT 令牌: {token[:8]}...") + return token + except Exception as e: + print(f"[eastmoney] 获取 UT 失败({url}): {e}") + return _em_ut # 保底返回当前值 + + +async def get_em_ut(force_refresh: bool = False) -> str: + """获取当前 UT,必要时刷新""" + global _em_ut + if force_refresh: + async with _em_ut_lock: + _em_ut = await _refresh_em_ut() + return _em_ut + + +# curl_cffi 模拟 Chrome TLS 指纹 +from curl_cffi.requests import AsyncSession + +_sector_session: Optional[AsyncSession] = None + + +def _get_sector_session() -> AsyncSession: + global _sector_session + if _sector_session is None: + _sector_session = AsyncSession( + impersonate="chrome120", + headers={ + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", + "Referer": "https://data.eastmoney.com/bkzj/hy.html", + "Accept": "*/*", + "Accept-Language": "zh-CN,zh;q=0.9", + }, + timeout=10, + ) + return _sector_session + + +# 内存缓存 +_sector_cache: dict[str, tuple[list[dict], float]] = {} +_SECTOR_CACHE_TTL = 60 async def fetch_sector_list(sector_type: str) -> list[dict]: - """ - 获取板块资金流向数据 - sector_type: "industry" 或 "concept" - 返回按主力净流入降序排列的板块列表 - 编码优先东方财富(BKxxxx),降级同花顺(6位数字) - """ + now = time.time() + if sector_type in _sector_cache: + data, ts = _sector_cache[sector_type] + if now - ts < _SECTOR_CACHE_TTL: + return data + + data = await _fetch_push2(sector_type) + if data: + _sector_cache[sector_type] = (data, now) + return data + + data = await _fetch_akshare(sector_type) + if data: + _sector_cache[sector_type] = (data, now) + return data + + +async def _fetch_push2(sector_type: str) -> list[dict]: + """东方财富 push2 API(curl_cffi 模拟浏览器 TLS 指纹)""" + fs = SECTOR_MEDIA_MAP.get(sector_type) + if not fs: + return [] + + session = _get_sector_session() + ut = await get_em_ut() + url = ( + f"https://push2.eastmoney.com/api/qt/clist/get" + f"?fs={fs}&fields={SECTOR_FIELDS}" + f"&fid=f62&po=1&pz=500&pn=1&np=1&fltt=2" + f"&invt=2&ut={ut}" + ) + + for attempt in range(2): + try: + resp = await session.get(url) + if resp.status_code != 200: + if attempt == 0: + await asyncio.sleep(1) + continue + return [] + result = resp.json() + if result.get("rc") != 0: + return [] + diff = result.get("data", {}).get("diff", []) + items = [] + for item in diff: + items.append({ + "code": item.get("f12", ""), + "name": item.get("f14", ""), + "level": item.get("f2"), + "changePercent": item.get("f3"), + "changeAmount": None, + "mainNetInflow": item.get("f62", 0) or 0, + "mainNetInflowPercent": item.get("f184", 0), + "superLargeInflow": item.get("f66", 0) or 0, + "superLargeInflowPercent": item.get("f69", 0), + "largeInflow": item.get("f72", 0) or 0, + "largeInflowPercent": item.get("f75", 0), + "mediumInflow": item.get("f78", 0) or 0, + "mediumInflowPercent": item.get("f81", 0), + "smallInflow": item.get("f84", 0) or 0, + "smallInflowPercent": item.get("f87", 0), + "turnover": item.get("f70", 0) or 0, + }) + return items + except Exception as e: + err = str(e) + print(f"[eastmoney] push2 获取{sector_type}板块失败(attempt {attempt+1}): {err[:80]}") + # UT 可能过期,尝试刷新 + if "disconnect" in err.lower() or "refused" in err.lower() or attempt == 1: + await get_em_ut(force_refresh=True) + ut = _em_ut + # 重建会话(TLS 指纹可能会被缓存) + global _sector_session + _sector_session = None + session = _get_sector_session() + if attempt == 0: + await asyncio.sleep(1) + return [] + + +import akshare as ak + + +async def _fetch_akshare(sector_type: str) -> list[dict]: + """akshare 降级方案(同花顺数据源)""" loop = asyncio.get_event_loop() - def _get_data(): - # 1) 板块编码映射:东方财富 BK 编码 → 降级同花顺编码 + def _get(): code_map = {} try: if sector_type == "industry": @@ -268,7 +415,6 @@ async def fetch_sector_list(sector_type: str) -> list[dict]: except Exception: pass - # 2) 资金流向(10jqka 同花顺数据源) if sector_type == "industry": df = ak.stock_fund_flow_industry() else: @@ -277,92 +423,39 @@ async def fetch_sector_list(sector_type: str) -> list[dict]: return code_map, df try: - code_map, df = await loop.run_in_executor(None, _get_data) - + code_map, df = await loop.run_in_executor(None, _get) if df is None or df.empty: return [] - df = df.sort_values("净额", ascending=False) - items = [] for _, row in df.iterrows(): name = str(row.get("行业", "")).strip() + inflow = float(row.get("流入资金", 0) or 0) * 100000000 + outflow = float(row.get("流出资金", 0) or 0) * 100000000 items.append({ "code": code_map.get(name, ""), "name": name, - "level": _safe_float(row.get("行业指数")), - "changePercent": _safe_float(row.get("行业-涨跌幅")), + "level": float(row.get("行业指数") or 0), + "changePercent": float(row.get("行业-涨跌幅") or 0), "changeAmount": None, - "mainNetInflow": _safe_float(row.get("净额", 0)) * 100000000, # 亿→元 + "mainNetInflow": float(row.get("净额", 0) or 0) * 100000000, "mainNetInflowPercent": None, "superLargeInflow": None, "superLargeInflowPercent": None, - "turnover": _safe_float(row.get("流入资金", 0)) * 100000000 + _safe_float(row.get("流出资金", 0)) * 100000000, - "smallNetInflow": None, + "largeInflow": None, + "largeInflowPercent": None, + "mediumInflow": None, + "mediumInflowPercent": None, + "smallInflow": None, + "smallInflowPercent": None, + "turnover": inflow + outflow, }) return items except Exception as e: - print(f"[eastmoney] 获取{sector_type}板块失败: {e}") + print(f"[eastmoney] akshare 获取{sector_type}板块失败: {e}") return [] -def _safe_float(val) -> float: - if val is None: - return 0.0 - try: - return float(val) - except (ValueError, TypeError): - return 0.0 - - -async def fetch_sector_list_direct(sector_type: str) -> list[dict]: - """ - 直接从东方财富 push2 API 获取板块列表(备用,当 akshare 不可用时) - """ - fs = SECTOR_TYPE_MAP.get(sector_type) - if not fs: - return [] - - url = ( - f"https://push2.eastmoney.com/api/qt/clist/get" - f"?fs={fs}&fields={SECTOR_FIELDS}" - f"&fid=f62&po=1&pz=500&pn=1&np=1&fltt=2" - ) - headers = { - "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", - "Referer": "https://quote.eastmoney.com/", - } - - async with httpx.AsyncClient() as client: - try: - resp = await client.get(url, headers=headers, timeout=10) - if resp.status_code != 200: - return [] - result = resp.json() - if result.get("rc") != 0: - return [] - diff = result.get("data", {}).get("diff", []) - items = [] - for item in diff: - items.append({ - "code": item.get("f12", ""), - "name": item.get("f14", ""), - "level": item.get("f2"), - "changePercent": item.get("f3"), - "changeAmount": item.get("f4"), - "mainNetInflow": item.get("f62", 0), - "mainNetInflowPercent": item.get("f184", 0), - "superLargeInflow": item.get("f66", 0), - "superLargeInflowPercent": item.get("f69", 0), - "turnover": item.get("f70", 0), - "smallNetInflow": item.get("f78", 0), - }) - return items - except Exception as e: - print(f"[eastmoney] 获取{sector_type}板块失败: {e}") - return [] - - # ---- 公司概况 ---- _F10_MARKET_MAP = {"6": "SH", "0": "SZ", "3": "SZ"} @@ -733,14 +826,88 @@ async def fetch_business_segments(code: str, by_type: str = "product", years: in return result -async def fetch_push2his(code: str, days: int) -> Optional[list]: - """回退到东方财富 push2his 接口(自动重试一次)""" +async def fetch_kline_history(code: str, days: int = 90) -> Optional[list[dict]]: + """从 push2his kline/get 获取日K线(curl_cffi 模拟浏览器 TLS 指纹) + + 可能受 IP 限流影响,调用方应有降级。 + 返回: [{date, open, close, high, low, volume(手), turnover, changePercent, amplitude, turnoverRate}] + """ + market = get_eastmoney_market(code) + secid = f"{market}.{code}" + url = ( + f"https://push2his.eastmoney.com/api/qt/stock/kline/get" + f"?secid={secid}" + f"&ut=b2884a393a59ad64002292a3e90d46a5" + f"&fields1=f1,f2,f3,f4,f5,f6" + f"&fields2=f51,f52,f53,f54,f55,f56,f57,f58,f59,f60,f61" + f"&klt=101&fqt=1&end=20500101&lmt={days}" + ) + + from curl_cffi.requests import AsyncSession + async with AsyncSession(impersonate="chrome120") as session: + for attempt in range(2): + try: + resp = await session.get(url, headers={ + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", + "Referer": "https://quote.eastmoney.com/", + }, timeout=10) + if resp.status_code != 200: + if attempt == 0: + continue + return None + result = resp.json() + if result.get("rc") != 0: + if attempt == 0: + continue + return None + klines = result.get("data", {}).get("klines", []) + if not klines: + if attempt == 0: + continue + return None + + parsed = [] + for line in klines: + fields = line.split(",") + if len(fields) < 11: + continue + def _f(i): return float(fields[i]) if fields[i] else 0 + parsed.append({ + "date": fields[0], + "open": _f(1), + "close": _f(2), + "high": _f(3), + "low": _f(4), + "volume": int(_f(5)), # 手 + "turnover": _f(6), + "amplitude": _f(7), + "changePercent": _f(8), + "change": _f(9), + "turnoverRate": _f(10), + }) + + parsed.reverse() + return parsed + except Exception as e: + if attempt == 0: + continue + return None + return None + + +async def fetch_fund_flow_daykline(code: str, days: int) -> Optional[list[dict]]: + """从 push2his daykline/get 获取资金流向日线数据(结构化返回,替代 MX API) + + 返回字段:date, mainNetInflow, superLargeInflow/Outflow, largeInflow/Outflow, + mediumInflow/Outflow, smallInflow/Outflow, 各占比%, closePrice, changePercent, + mainForceNet, retailNet + """ market = get_eastmoney_market(code) secid = f"{market}.{code}" url = ( f"https://push2his.eastmoney.com/api/qt/stock/fflow/daykline/get" f"?lmt={days}&fields1=f1,f2,f3,f7" - f"&fields2=f51,f52,f53,f54,f55,f56,f57,f58,f59,f60,f61,f62,f63,f64,f65,f66,f67,f68,f69" + f"&fields2=f51,f52,f53,f54,f55,f56,f57,f58,f59,f60,f61,f62,f63" f"&ut=b2884a393a59ad64002292a3e90d46a5&secid={secid}" ) headers = { @@ -755,19 +922,62 @@ async def fetch_push2his(code: str, days: int) -> Optional[list]: if resp.status_code != 200: if attempt == 0: continue - print(f"[eastmoney] push2his HTTP {resp.status_code}") return None result = resp.json() klines = result.get("data", {}).get("klines", []) if not klines: if attempt == 0: continue - print(f"[eastmoney] push2his no klines in response") return None - return klines + + parsed = [] + for line in klines: + fields = line.split(",") + if len(fields) < 13: + continue + def _f(i): return float(fields[i]) if fields[i] else 0 + + # 字段索引: 0=f51(date), 1=f52(主力), 2=f53(小单), 3=f54(中单), + # 4=f55(大单), 5=f56(超大单), 6=f57(主力%), 7=f58(小单%), + # 8=f59(中单%), 9=f60(大单%), 10=f61(超大单%), 11=f62(收盘价), 12=f63(涨跌幅) + date = fields[0] + main_net = _f(1) + small_net = _f(2) + medium_net = _f(3) + large_net = _f(4) + super_large_net = _f(5) + + parsed.append({ + "date": date, + "mainNetInflow": main_net, + "superLargeInflow": max(0, super_large_net), + "superLargeOutflow": abs(min(0, super_large_net)), + "largeInflow": max(0, large_net), + "largeOutflow": abs(min(0, large_net)), + "mediumInflow": max(0, medium_net), + "mediumOutflow": abs(min(0, medium_net)), + "smallInflow": max(0, small_net), + "smallOutflow": abs(min(0, small_net)), + "mainNetInflowPercent": _f(6), + "superLargeInflowPercent": _f(10), + "superLargeOutflowPercent": 0, + "largeInflowPercent": _f(9), + "largeOutflowPercent": 0, + "mediumInflowPercent": _f(8), + "mediumOutflowPercent": 0, + "smallInflowPercent": _f(7), + "smallOutflowPercent": 0, + "turnover": 0, # daykline 不含成交额,由调用方从 Tencent K 线合并 + "mainForceNet": main_net, + "retailNet": small_net + medium_net, + "changePercent": _f(12), + "closePrice": _f(11), + }) + + return parsed except Exception as e: if attempt == 0: continue - print(f"[eastmoney] push2his error: {e}") + print(f"[eastmoney] fetch_fund_flow_daykline error: {e}") return None return None diff --git a/backend/services/tencent.py b/backend/services/tencent.py index c3af08e..9266b09 100644 --- a/backend/services/tencent.py +++ b/backend/services/tencent.py @@ -192,10 +192,13 @@ async def fetch_quote(code: str) -> Optional[dict]: async def fetch_history(code: str, days: int = 90) -> List[dict]: - """获取历史K线(前复权日K),主数据源""" + """获取历史K线(前复权日K),含涨跌幅 + + 多请求1天以计算第一条的涨跌幅,最终只返回 days 条。 + """ market = get_market_prefix(code) stock_code = f"{market}{code}" - url = f"https://web.ifzq.gtimg.cn/appstock/app/fqkline/get?param={stock_code},day,,,{days},qfq" + url = f"https://web.ifzq.gtimg.cn/appstock/app/fqkline/get?param={stock_code},day,,,{days + 1},qfq" headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", "Referer": "https://stockapp.finance.qq.com/", @@ -212,17 +215,31 @@ async def fetch_history(code: str, days: int = 90) -> List[dict]: if not isinstance(raw, list): return [] result = [] + prev_close = 0 for record in raw: if not isinstance(record, list) or len(record) < 6: continue + close = float(record[2]) if record[2] else 0 + change_pct = 0 + if prev_close > 0: + change_pct = (close - prev_close) / prev_close * 100 result.append({ "date": record[0], "open": float(record[1]) if record[1] else 0, - "close": float(record[2]) if record[2] else 0, + "close": close, "high": float(record[3]) if record[3] else 0, "low": float(record[4]) if record[4] else 0, - "volume": int(record[5]) if record[5] else 0, + "volume": int(float(record[5])) if record[5] else 0, + "changePercent": round(change_pct, 2), }) + prev_close = close + + # 丢弃最旧1条(它的 prev_close=0 导致涨跌幅为0),只保留最新 days 条 + if len(result) > 1: + result = result[1:] + if len(result) > days: + result = result[-days:] + return result except Exception: return [] diff --git a/docs/api-data-sources.md b/docs/api-data-sources.md new file mode 100644 index 0000000..a51f7cb --- /dev/null +++ b/docs/api-data-sources.md @@ -0,0 +1,208 @@ +# 数据接口 & 数据源一览 + +## 架构概览 + +``` +前端 (fetch) → 后端 FastAPI (路由层) → 数据源服务层 → 第三方 API +``` + +- 前端统一走后端代理,前端不直接调第三方 +- 每个接口有主源 + 备选降级,降级对前端透明 +- 后端服务层有 SQLite 缓存(6-24h)和内存缓存(60s) + +--- + +## 1. 股票搜索 `/api/stock/search` + +| 项目 | 内容 | +|------|------| +| 前端调用 | `fetchStockSearch(keyword)` → `src/lib/stock-api.ts` | +| 路由 | `GET /api/stock/search?keyword=` → `routes/stock.py:12` | +| 用途 | 模糊搜索股票(名称/代码/拼音) | + +**数据源:** + +| 优先级 | 源 | 方式 | 可靠性 | +|--------|----|------|--------| +| 主选 | 腾讯智能搜索 `smartbox.gtimg.cn` | HTTPS JSONP | ⭐⭐⭐⭐⭐ 稳定,覆盖全 | +| 降级 | 腾讯行情接口 `qt.gtimg.cn` | HTTPS 文本 | ⭐⭐⭐⭐ 仅当 keyword=6位代码且搜索无结果时 | + +--- + +## 2. 实时行情 `/api/stock/quote` + +| 项目 | 内容 | +|------|------| +| 前端调用 | `fetchStockQuote(code)` → `src/lib/stock-api.ts:137` | +| 路由 | `GET /api/stock/quote?code=` → `routes/stock.py:38` | +| 用途 | 获取当前实时价格、开盘价、昨收、最高最低、成交量额、盘口 | +| 前端使用 | 股票详情页基本信息区(价格、涨跌幅、内外盘) | + +**数据源:** + +| 优先级 | 源 | 方式 | 可靠性 | +|--------|----|------|--------| +| 主选 | 腾讯行情 `qt.gtimg.cn` | HTTPS GBK文本 | ⭐⭐⭐⭐⭐ A股全量覆盖,无IP限流 | +| 降级 | 无 | — | 无响应则返回 404 | + +**字段映射:** 名称、当前价、昨收、今开、最高、最低、成交量(手)、成交额(万)、外盘、内盘、涨跌额、涨跌幅 + +--- + +## 3. 历史 K 线 / 每日行情明细 `/api/stock/history` + +| 项目 | 内容 | +|------|------| +| 前端调用 | `fetchStockHistory(code, days)` → `src/lib/stock-api.ts:182` | +| 路由 | `GET /api/stock/history?code=&days=` → `routes/stock.py:49` | +| 用途 | 日K前复权 OHLCV、涨跌幅、成交额 | +| 前端使用 | K线图 + 每日行情明细表(涨跌幅列) | + +**数据源:** + +| 优先级 | 源 | API | 可靠性 | +|--------|----|-----|--------| +| **主选** | **东方财富 push2his** | `kline/get` (curl_cffi chrome120) | ⚠️ IP 限流严重,常被拒 | +| **备选①** | **腾讯** | `web.ifzq.gtimg.cn` HTTPS | ⭐⭐⭐⭐⭐ 稳定可靠 | +| **备选②** | **新浪** | `money.finance.sina.com.cn` HTTPS | ⭐⭐⭐ 偶有超时 | + +**当前实际工作源:** 腾讯(备选①) + +**增强字段(东方财富源独有,走腾讯时无):** +- `turnover` — 成交额(元) +- `amplitude` — 振幅(%) +- `changePercent` — 涨跌幅(%)(腾讯已通过连续收盘价计算补齐) +- `turnoverRate` — 换手率(%) + +> 东方财富 push2his kline/get 与 fund-flow 同域名但路径不同,kline/get 有额外反爬。 +> 即使使用 curl_cffi + chrome120 指纹也无法绕过,暂不修复。 + +--- + +## 4. 资金流向 `/api/stock/fund-flow` + +| 项目 | 内容 | +|------|------| +| 前端调用 | `fetchStockFundFlow(code, name, days)` → `src/lib/stock-api.ts:248` | +| 路由 | `GET /api/stock/fund-flow?code=&name=&days=` → `routes/stock.py:147` | +| 用途 | 日度主力净流入、超大单/大单/中单/小单明细、占比、累计值 | +| 前端使用 | 每日行情明细表(主力净流入列 + 成交额列),资金流向图表 | + +**数据源:** + +| 优先级 | 源 | API | 可靠性 | +|--------|----|-----|--------| +| **主选** | **东方财富 push2his** | `fflow/daykline/get` (httpx) | ⭐⭐⭐⭐⭐ 稳定,无 IP 限流 | +| **备选** | **MX 妙想 API** | `mkapi2.dfcfs.com` (HTTP POST + apikey) | ⚠️ 有每日配额,多 key 轮询+缓存 | + +**字段映射(东方财富):** +``` +f51=日期, f52=主力净流入, f53=小单, f54=中单, f55=大单, f56=超大单 +f57-f61=各占比%, f62=收盘价, f63=涨跌幅 +``` + +> MX API 当前保留为备选。配置 `MX_APIKEY` 或 `MX_APIKEY_{1-9}` 环境变量启用。 +> MX 数据只有主力净流入 + 成交额,无大/中/小单拆分(拆分的比例是估算的)。 + +--- + +## 5. 公司概况 `/api/stock/profile` + +| 项目 | 内容 | +|------|------| +| 前端调用 | `fetchCompanyProfile(code)` → `src/lib/stock-api.ts:337` | +| 路由 | `GET /api/stock/profile?code=` → `routes/stock.py:80` | +| 用途 | 公司全名、行业、高管、联系方式、注册信息、发行信息 | +| 前端使用 | 股票详情页「公司概况」tab | + +**数据源:** + +| 优先级 | 源 | API | 可靠性 | +|--------|----|-----|--------| +| 主选 | 东方财富 F10 | `emweb.securities.eastmoney.com` HTTPS | ⭐⭐⭐⭐ 稳定,24h 缓存 | +| 降级 | 无 | — | — | + +--- + +## 6. 财务指标 `/api/stock/financial` + +| 项目 | 内容 | +|------|------| +| 前端调用 | `fetchFinancialData(code, years)` → `src/lib/stock-api.ts:415` | +| 路由 | `GET /api/stock/financial?code=&years=` → `routes/stock.py:91` | +| 用途 | 每股指标、盈利能力、成长能力、偿债能力、营运能力 | +| 前端使用 | 股票详情页「财务分析」tab(表格+图表) | + +**数据源:** + +| 优先级 | 源 | API | 可靠性 | +|--------|----|-----|--------| +| 主选 | 东方财富数据中心 | `datacenter.eastmoney.com` HTTPS | ⭐⭐⭐⭐⭐ 稳定,6h 缓存 | +| 降级 | 无 | — | — | + +--- + +## 7. 主营构成 `/api/stock/business-segments` + +| 项目 | 内容 | +|------|------| +| 前端调用 | `fetchBusinessSegments(code, byType, years)` → `src/lib/stock-api.ts:382` | +| 路由 | `GET /api/stock/business-segments?code=&by_type=&years=` → `routes/stock.py:119` | +| 用途 | 收入构成(按产品/行业/地区)、成本构成、利润占比、毛利率 | +| 前端使用 | 股票详情页「经营分析」tab(饼图) | + +**数据源:** + +| 优先级 | 源 | API | 可靠性 | +|--------|----|-----|--------| +| 主选 | 东方财富数据中心 | `datacenter.eastmoney.com` HTTPS | ⭐⭐⭐⭐⭐ 稳定,6h 缓存 | +| 降级 | 无 | — | — | + +--- + +## 8. 板块资金流向 `/api/sectors` + +| 项目 | 内容 | +|------|------| +| 前端调用 | `fetchSectors(type)` → `src/lib/stock-api.ts:461` | +| 路由 | `GET /api/sectors?type=` → `routes/sectors.py:9` | +| 用途 | 行业/概念板块列表,按主力净流入排序 | +| 前端使用 | `/sectors` 板块资金流向页面 | + +**数据源:** + +| 优先级 | 源 | API | 可靠性 | +|--------|----|-----|--------| +| **主选** | **东方财富 push2** | `push2.eastmoney.com` (curl_cffi chrome120) | ⭐⭐⭐ 有 IP 限流,60s 内存缓存 + session 重建 | +| **备选** | **AkShare** | `ak.stock_fund_flow_industry/concept` | ⭐⭐ 慢(同步调用),列名需适配 | + +**字段映射(东方财富 push2):** +``` +f62=主力净流入, f184=主力净流入占比 +f66/f69=超大单净流入/占比, f72/f75=大单净流入/占比 +f78/f81=中单净流入/占比, f84/f87=小单净流入/占比 +f70=成交额 +``` + +> push2.eastmoney.com 有较激进的 IP 限流。通过 curl_cffi 模拟浏览器 TLS 指纹 + 持久化 session + 60s 缓存 + 自动 UT 刷新 + session 重建来缓解。 + +--- + +## 9. 估值数据(外部) + +> 股票详情页「估值分析」tab 的数据从外部服务加载(landing-page),不由后端代理。 + +--- + +## 数据源总结 + +| 第三方源 | 域名 | 使用场景 | 限流情况 | 是否需要反爬 | +|----------|------|---------|---------|------------| +| **腾讯财经** | `qt.gtimg.cn`, `web.ifzq.gtimg.cn`, `smartbox.gtimg.cn` | 搜索、行情、K线 | 基本无限流 | ❌ | +| **新浪财经** | `money.finance.sina.com.cn` | K线降级 | 宽松 | ❌ | +| **东方财富 push2his** | `push2his.eastmoney.com` | 资金流向、K线 | **部分路径限流**(kline/get 被禁,fflow/daykline/get 正常) | ✅ 需 curl_cffi | +| **东方财富 push2** | `push2.eastmoney.com` | 板块数据 | **IP 限流严重** | ✅ 需 curl_cffi | +| **东方财富数据中心** | `datacenter.eastmoney.com` | 财务数据、主营构成 | 宽松 | ❌ | +| **东方财富 F10** | `emweb.securities.eastmoney.com` | 公司概况 | 宽松 | ❌ | +| **MX 妙想** | `mkapi2.dfcfs.com` | 资金流向备选 | 每日配额(113错误码) | ❌ | +| **AkShare** | 同花顺/东方财富 | 板块降级 | 慢但无限制 | ❌ | diff --git a/src/lib/stock-api.ts b/src/lib/stock-api.ts index 45dd332..849b970 100755 --- a/src/lib/stock-api.ts +++ b/src/lib/stock-api.ts @@ -27,6 +27,10 @@ export interface KLineData { high: number; low: number; volume: number; + changePercent?: number; + turnover?: number; + amplitude?: number; + turnoverRate?: number; } export interface StockSearchResult { @@ -430,11 +434,16 @@ export interface SectorItem { changePercent: number | null; changeAmount: number | null; mainNetInflow: number; - mainNetInflowPercent: number; - superLargeInflow: number; - superLargeInflowPercent: number; + mainNetInflowPercent: number | null; + superLargeInflow: number | null; + superLargeInflowPercent: number | null; + largeInflow: number | null; + largeInflowPercent: number | null; + mediumInflow: number | null; + mediumInflowPercent: number | null; + smallInflow: number | null; + smallInflowPercent: number | null; turnover: number; - smallNetInflow: number; } export type SectorType = "industry" | "concept"; diff --git a/src/routes/sectors.tsx b/src/routes/sectors.tsx index 71f2700..00f3eab 100644 --- a/src/routes/sectors.tsx +++ b/src/routes/sectors.tsx @@ -1,7 +1,6 @@ import { createFileRoute } from "@tanstack/react-router"; -import { useState, useEffect } from "react"; +import { useState, useEffect, useMemo } from "react"; import { fetchSectors, type SectorItem, type SectorType } from "@/lib/stock-api"; -import { formatMoney } from "@/lib/utils"; import { Card, CardContent } from "@/components/ui/card"; import { ArrowLeft, TrendingUp, TrendingDown, RefreshCw } from "lucide-react"; import { Link } from "@tanstack/react-router"; @@ -15,19 +14,33 @@ const TABS: { key: SectorType; label: string }[] = [ { key: "concept", label: "概念板块" }, ]; +type SortMode = "mainNetInflow" | "changePercent"; + function SectorsPage() { const [tab, setTab] = useState("industry"); const [data, setData] = useState([]); const [loading, setLoading] = useState(true); + const [sort, setSort] = useState("mainNetInflow"); - useEffect(() => { + const loadData = (t: SectorType) => { setLoading(true); - fetchSectors(tab).then((items) => { + fetchSectors(t).then((items) => { setData(items); setLoading(false); }); + }; + + useEffect(() => { + loadData(tab); }, [tab]); + const sorted = useMemo(() => { + return [...data].sort((a, b) => { + if (sort === "mainNetInflow") return b.mainNetInflow - a.mainNetInflow; + return (b.changePercent ?? 0) - (a.changePercent ?? 0); + }); + }, [data, sort]); + return (
{/* 顶栏 */} @@ -40,10 +53,7 @@ function SectorsPage() {

板块资金流向

+ + - {/* 数据更新时间 */} + {/* 数据信息 */}

- 实时数据 · 按主力净流入降序 · 共 {data.length} 个板块 + 含 {sorted.length} 个板块 · 按{sort === "mainNetInflow" ? "主力净流入" : "涨跌幅"}降序

- {/* 方块图 */} + {/* 卡片网格 */}
{loading ? (
{Array.from({ length: 20 }).map((_, i) => ( -
+
))}
) : (
- {data.map((item) => ( + {sorted.map((item) => ( ))}
@@ -97,29 +123,68 @@ function SectorsPage() { ); } +function formatInflow(val: number | null | undefined): string { + if (val == null) return "--"; + const abs = Math.abs(val); + if (abs >= 1e8) return (val / 1e8).toFixed(2) + "亿"; + if (abs >= 1e4) return (val / 1e4).toFixed(0) + "万"; + return val.toFixed(0); +} + +function FundFlowBar({ value, maxAbs }: { value: number | null | undefined; maxAbs: number }) { + if (value == null) return null; + const pct = maxAbs > 0 ? (value / maxAbs) * 100 : 0; + const isPos = value >= 0; + return ( +
+
+
+
+ + {formatInflow(value)} + +
+ ); +} + function SectorBlock({ item }: { item: SectorItem }) { const inflow = item.mainNetInflow; const isPositive = inflow >= 0; const change = item.changePercent; + const maxAbs = Math.max( + Math.abs(item.mainNetInflow), + Math.abs(item.superLargeInflow ?? 0), + Math.abs(item.largeInflow ?? 0), + Math.abs(item.mediumInflow ?? 0), + Math.abs(item.smallInflow ?? 0), + 1 + ); return ( - {/* 板块名称 + 编号 */} + {/* 板块名称 */}

{item.name}

{item.code && ( - {item.code} + {item.code.replace("BK", "")} )}
{/* 涨跌幅 */} -
- {change != null && ( +
+ {change != null ? ( = 0 ? "text-red-500" : "text-green-500" @@ -133,20 +198,32 @@ function SectorBlock({ item }: { item: SectorItem }) { {change >= 0 ? "+" : ""} {change.toFixed(2)}% + ) : ( + -- )} + + {formatInflow(item.turnover)} +
- {/* 资金流向 */} -
-

主力净流入

-

+

+ 主力净流入 + - {isPositive ? "+" : ""} - {formatMoney(inflow)} -

+ }`}> + {isPositive ? "+" : ""}{formatInflow(inflow)} +
+
+ + {/* 资金流向明细条 */} +
+ + + + +
diff --git a/src/routes/stock.$code.tsx b/src/routes/stock.$code.tsx index ceca374..7d8a7b4 100755 --- a/src/routes/stock.$code.tsx +++ b/src/routes/stock.$code.tsx @@ -46,6 +46,7 @@ interface StockData { low: number; volume: number; isAddedDate: boolean; + changePercent: number; } interface StockInfo { @@ -226,6 +227,7 @@ function StockDetail() { low: kline.low, volume: kline.volume, isAddedDate, + changePercent: kline.changePercent ?? 0, }; }); }; @@ -685,14 +687,7 @@ function StockDetail() { const displayData = chartData.slice(-dailyTableDays).reverse(); return displayData.map((item, idx) => { - // 计算涨幅:与后一天收盘价比较(因为已倒序,后一天是更早的日期) - let changePercent = 0; - if (idx < displayData.length - 1) { - const nextClose = displayData[idx + 1].close; - if (nextClose > 0) { - changePercent = ((item.close - nextClose) / nextClose) * 100; - } - } + const changePercent = item.changePercent; const isPositive = changePercent >= 0; // 查找对应的资金流向数据