使用push2 数据接口,优化代码

This commit is contained in:
Sakurasan
2026-07-06 22:27:45 +08:00
parent 83c88ee945
commit 6390545e61
7 changed files with 750 additions and 276 deletions
+79 -121
View File
@@ -2,7 +2,6 @@
from fastapi import APIRouter, Query, HTTPException from fastapi import APIRouter, Query, HTTPException
import re import re
import math
from services import tencent, sina, eastmoney from services import tencent, sina, eastmoney
from models import StockSearchResult, StockQuote, KLineData, FundFlowData, FundFlowSummary, CompanyProfile, FinancialReportItem, FinancialDataResponse 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): if not re.match(r"^\d{6}$", code):
raise HTTPException(status_code=400, detail="股票代码格式错误,需为6位数字") raise HTTPException(status_code=400, detail="股票代码格式错误,需为6位数字")
# 主数据源:腾讯 # 主数据源:东方财富 push2his(含成交额/涨跌幅/振幅/换手率,可能被限流)
klines = await tencent.fetch_history(code, days) em_klines = await eastmoney.fetch_kline_history(code, days)
if len(klines) >= 2: if em_klines and len(em_klines) >= 2:
return {"data": klines, "count": len(klines), "source": "tencent"} 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) sina_klines = await sina.fetch_history(code, days)
if sina_klines: if sina_klines:
return {"data": sina_klines, "count": len(sina_klines), "source": "sina"} return {"data": sina_klines, "count": len(sina_klines), "source": "sina"}
if klines: if em_klines:
return {"data": klines, "count": len(klines), "source": "tencent"} 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线数据") raise HTTPException(status_code=404, detail="未获取到K线数据")
@@ -141,141 +147,93 @@ async def business_segments(
@router.get("/fund-flow", summary="资金流向") @router.get("/fund-flow", summary="资金流向")
async def stock_fund_flow( async def stock_fund_flow(
code: str = Query(..., description="6位股票代码"), code: str = Query(..., description="6位股票代码"),
name: str = Query("", description="股票名称"), name: str = Query("", description="股票名称MX 备选源需要)"),
days: int = Query(21, description="目标天数"), days: int = Query(21, description="目标天数"),
): ):
if not re.match(r"^\d{6}$", code): if not re.match(r"^\d{6}$", code):
raise HTTPException(status_code=400, detail="股票代码格式错误,需为6位数字") raise HTTPException(status_code=400, detail="股票代码格式错误,需为6位数字")
# 获取股票名称(如果未提供 # 数据源1push2his daykline(主源,无配额限制
stock_name = name data = await eastmoney.fetch_fund_flow_daykline(code, days)
if not stock_name:
quote = await tencent.fetch_quote(code)
if quote:
stock_name = quote.get("name", "")
# MX 请求 90 天以获取尽可能多的数据,最终只取最近 days 条 # 数据源2MX API(备选,push2his 无数据时降级)
fetch_days = max(90, days) 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,
})
# 数据源1MX API if not data:
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="未获取到资金流向数据") raise HTTPException(status_code=404, detail="未获取到资金流向数据")
# 统一排序,只保留最新 days 条 # 从腾讯 K 线补充成交额/收盘价/涨跌幅
parsed_data.sort(key=lambda x: x["date"]) kline_map = await tencent.fetch_kline_map(code, days)
if len(parsed_data) > days: for d in data:
parsed_data = parsed_data[-days:] 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_main = 0
cumulative_retail = 0 cumulative_retail = 0
for d in parsed_data: for d in data:
cumulative_main += d["mainForceNet"] cumulative_main += d.get("mainForceNet", 0)
cumulative_retail += d["retailNet"] cumulative_retail += d.get("retailNet", 0)
d["cumulativeMainNet"] = cumulative_main d["cumulativeMainNet"] = cumulative_main
d["cumulativeRetailNet"] = cumulative_retail d["cumulativeRetailNet"] = cumulative_retail
total_main = sum(d["mainForceNet"] for d in parsed_data) total_main = sum(d.get("mainForceNet", 0) for d in data)
total_turnover = sum(d["turnover"] for d in parsed_data) total_turnover = sum(d.get("turnover", 0) for d in data)
total_large_inflow = sum(d["largeInflow"] for d in parsed_data) total_large_inflow = sum(d.get("largeInflow", 0) for d in data)
positive = sum(1 for d in parsed_data if d["mainForceNet"] > 0) positive = sum(1 for d in data if d.get("mainForceNet", 0) > 0)
negative = sum(1 for d in parsed_data if d["mainForceNet"] < 0) negative = sum(1 for d in data if d.get("mainForceNet", 0) < 0)
return { return {
"data": parsed_data, "data": data,
"count": len(parsed_data), "count": len(data),
"stockCode": code, "stockCode": code,
"summary": { "summary": {
"totalMainNet": total_main, "totalMainNet": total_main,
"totalTurnover": total_turnover, "totalTurnover": total_turnover,
"totalLargeInflow": total_large_inflow, "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, "positiveDays": positive,
"negativeDays": negative, "negativeDays": negative,
}, },
+319 -109
View File
@@ -1,6 +1,7 @@
"""东方财富 API 客户端(资金流向数据)""" """东方财富 API 客户端(资金流向数据)"""
import asyncio import asyncio
import time
import httpx import httpx
import json import json
import os import os
@@ -10,7 +11,8 @@ from typing import Optional, List
from services.cache import get_cache, set_cache from services.cache import get_cache, set_cache
# ---- API Key 轮询 ----
# ---- API Key 轮询(MX 备选源用)----
_api_keys: List[str] = [] _api_keys: List[str] = []
_key_index: int = -1 _key_index: int = -1
@@ -61,18 +63,6 @@ def _rotate_key():
set_cache("_mx_key_index", str(_key_index), ttl_hours=24) 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: def parse_amount(text) -> float:
"""解析金额文本(支持 1.432亿元, -9915万元, 0元)""" """解析金额文本(支持 1.432亿元, -9915万元, 0元)"""
if isinstance(text, (int, float)): if isinstance(text, (int, float)):
@@ -96,7 +86,7 @@ def parse_amount(text) -> float:
return sign * value return sign * value
# ---- MX API ---- # ---- MX API(备选源)----
async def _call_mx_api(api_key: str, name: str, days: int) -> Optional[dict]: 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轮询)""" """调用东方财富妙想MX API获取资金流向(缓存6小时,多key轮询)"""
cache_key = f"mx_fund_flow:{name}:{days}" cache_key = f"mx_fund_flow:{name}:{days}"
# 缓存命中
cached = get_cache(cache_key) cached = get_cache(cache_key)
if cached is not None: if cached is not None:
return json.loads(cached) return json.loads(cached)
# 多 key 轮询:按序尝试,遇到 113(超限) 自动切下一个 key
if not _ensure_keys(): if not _ensure_keys():
return None return None
@@ -207,7 +195,6 @@ async def fetch_mx_api(name: str, days: int) -> Optional[List[dict]]:
_rotate_key() _rotate_key()
continue continue
# 成功
data_list = _parse_mx_response(result) data_list = _parse_mx_response(result)
if data_list: if data_list:
set_cache(cache_key, json.dumps(data_list, ensure_ascii=False)) 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 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 = { SECTOR_MEDIA_MAP = {
"industry": "m:90+t:2", # 行业板块 "industry": "m:90+s:4",
"concept": "m:90+t:3", # 概念板块 "concept": "m:90+t:3",
} }
# 板块列表字段:f12=代码, f14=名称, f3=涨跌幅%, f62=主力净流入, f184=主力净流入占比 # 东方财富 UT 令牌管理
# f66=超大单净流入, f69=超大单净流入占比, f70=成交额, f78=小单净流入 _em_ut: str = "8dec03ba335b81bf4ebdf7b29ec27d15"
SECTOR_FIELDS = "f12,f14,f2,f3,f4,f62,f184,f66,f69,f70,f78" _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 async with httpx.AsyncClient() as client:
import pandas as pd 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]: async def fetch_sector_list(sector_type: str) -> list[dict]:
""" now = time.time()
获取板块资金流向数据 if sector_type in _sector_cache:
sector_type: "industry""concept" data, ts = _sector_cache[sector_type]
返回按主力净流入降序排列的板块列表 if now - ts < _SECTOR_CACHE_TTL:
编码优先东方财富(BKxxxx),降级同花顺(6位数字) 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 APIcurl_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() loop = asyncio.get_event_loop()
def _get_data(): def _get():
# 1) 板块编码映射:东方财富 BK 编码 → 降级同花顺编码
code_map = {} code_map = {}
try: try:
if sector_type == "industry": if sector_type == "industry":
@@ -268,7 +415,6 @@ async def fetch_sector_list(sector_type: str) -> list[dict]:
except Exception: except Exception:
pass pass
# 2) 资金流向(10jqka 同花顺数据源)
if sector_type == "industry": if sector_type == "industry":
df = ak.stock_fund_flow_industry() df = ak.stock_fund_flow_industry()
else: else:
@@ -277,92 +423,39 @@ async def fetch_sector_list(sector_type: str) -> list[dict]:
return code_map, df return code_map, df
try: 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: if df is None or df.empty:
return [] return []
df = df.sort_values("净额", ascending=False) df = df.sort_values("净额", ascending=False)
items = [] items = []
for _, row in df.iterrows(): for _, row in df.iterrows():
name = str(row.get("行业", "")).strip() name = str(row.get("行业", "")).strip()
inflow = float(row.get("流入资金", 0) or 0) * 100000000
outflow = float(row.get("流出资金", 0) or 0) * 100000000
items.append({ items.append({
"code": code_map.get(name, ""), "code": code_map.get(name, ""),
"name": name, "name": name,
"level": _safe_float(row.get("行业指数")), "level": float(row.get("行业指数") or 0),
"changePercent": _safe_float(row.get("行业-涨跌幅")), "changePercent": float(row.get("行业-涨跌幅") or 0),
"changeAmount": None, "changeAmount": None,
"mainNetInflow": _safe_float(row.get("净额", 0)) * 100000000, # 亿→元 "mainNetInflow": float(row.get("净额", 0) or 0) * 100000000,
"mainNetInflowPercent": None, "mainNetInflowPercent": None,
"superLargeInflow": None, "superLargeInflow": None,
"superLargeInflowPercent": None, "superLargeInflowPercent": None,
"turnover": _safe_float(row.get("流入资金", 0)) * 100000000 + _safe_float(row.get("流出资金", 0)) * 100000000, "largeInflow": None,
"smallNetInflow": None, "largeInflowPercent": None,
"mediumInflow": None,
"mediumInflowPercent": None,
"smallInflow": None,
"smallInflowPercent": None,
"turnover": inflow + outflow,
}) })
return items return items
except Exception as e: except Exception as e:
print(f"[eastmoney] 获取{sector_type}板块失败: {e}") print(f"[eastmoney] akshare 获取{sector_type}板块失败: {e}")
return [] 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"} _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 return result
async def fetch_push2his(code: str, days: int) -> Optional[list]: async def fetch_kline_history(code: str, days: int = 90) -> Optional[list[dict]]:
"""回退到东方财富 push2his 接口(自动重试一次)""" """ 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) market = get_eastmoney_market(code)
secid = f"{market}.{code}" secid = f"{market}.{code}"
url = ( url = (
f"https://push2his.eastmoney.com/api/qt/stock/fflow/daykline/get" f"https://push2his.eastmoney.com/api/qt/stock/fflow/daykline/get"
f"?lmt={days}&fields1=f1,f2,f3,f7" 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}" f"&ut=b2884a393a59ad64002292a3e90d46a5&secid={secid}"
) )
headers = { headers = {
@@ -755,19 +922,62 @@ async def fetch_push2his(code: str, days: int) -> Optional[list]:
if resp.status_code != 200: if resp.status_code != 200:
if attempt == 0: if attempt == 0:
continue continue
print(f"[eastmoney] push2his HTTP {resp.status_code}")
return None return None
result = resp.json() result = resp.json()
klines = result.get("data", {}).get("klines", []) klines = result.get("data", {}).get("klines", [])
if not klines: if not klines:
if attempt == 0: if attempt == 0:
continue continue
print(f"[eastmoney] push2his no klines in response")
return None 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: except Exception as e:
if attempt == 0: if attempt == 0:
continue continue
print(f"[eastmoney] push2his error: {e}") print(f"[eastmoney] fetch_fund_flow_daykline error: {e}")
return None return None
return None return None
+21 -4
View File
@@ -192,10 +192,13 @@ async def fetch_quote(code: str) -> Optional[dict]:
async def fetch_history(code: str, days: int = 90) -> List[dict]: async def fetch_history(code: str, days: int = 90) -> List[dict]:
"""获取历史K线(前复权日K),主数据源""" """获取历史K线(前复权日K),含涨跌幅
多请求1天以计算第一条的涨跌幅,最终只返回 days 条。
"""
market = get_market_prefix(code) market = get_market_prefix(code)
stock_code = f"{market}{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 = { headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
"Referer": "https://stockapp.finance.qq.com/", "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): if not isinstance(raw, list):
return [] return []
result = [] result = []
prev_close = 0
for record in raw: for record in raw:
if not isinstance(record, list) or len(record) < 6: if not isinstance(record, list) or len(record) < 6:
continue 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({ result.append({
"date": record[0], "date": record[0],
"open": float(record[1]) if record[1] else 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, "high": float(record[3]) if record[3] else 0,
"low": float(record[4]) if record[4] 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 return result
except Exception: except Exception:
return [] return []
+208
View File
@@ -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** | 同花顺/东方财富 | 板块降级 | 慢但无限制 | ❌ |
+13 -4
View File
@@ -27,6 +27,10 @@ export interface KLineData {
high: number; high: number;
low: number; low: number;
volume: number; volume: number;
changePercent?: number;
turnover?: number;
amplitude?: number;
turnoverRate?: number;
} }
export interface StockSearchResult { export interface StockSearchResult {
@@ -430,11 +434,16 @@ export interface SectorItem {
changePercent: number | null; changePercent: number | null;
changeAmount: number | null; changeAmount: number | null;
mainNetInflow: number; mainNetInflow: number;
mainNetInflowPercent: number; mainNetInflowPercent: number | null;
superLargeInflow: number; superLargeInflow: number | null;
superLargeInflowPercent: number; superLargeInflowPercent: number | null;
largeInflow: number | null;
largeInflowPercent: number | null;
mediumInflow: number | null;
mediumInflowPercent: number | null;
smallInflow: number | null;
smallInflowPercent: number | null;
turnover: number; turnover: number;
smallNetInflow: number;
} }
export type SectorType = "industry" | "concept"; export type SectorType = "industry" | "concept";
+107 -30
View File
@@ -1,7 +1,6 @@
import { createFileRoute } from "@tanstack/react-router"; 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 { fetchSectors, type SectorItem, type SectorType } from "@/lib/stock-api";
import { formatMoney } from "@/lib/utils";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { ArrowLeft, TrendingUp, TrendingDown, RefreshCw } from "lucide-react"; import { ArrowLeft, TrendingUp, TrendingDown, RefreshCw } from "lucide-react";
import { Link } from "@tanstack/react-router"; import { Link } from "@tanstack/react-router";
@@ -15,19 +14,33 @@ const TABS: { key: SectorType; label: string }[] = [
{ key: "concept", label: "概念板块" }, { key: "concept", label: "概念板块" },
]; ];
type SortMode = "mainNetInflow" | "changePercent";
function SectorsPage() { function SectorsPage() {
const [tab, setTab] = useState<SectorType>("industry"); const [tab, setTab] = useState<SectorType>("industry");
const [data, setData] = useState<SectorItem[]>([]); const [data, setData] = useState<SectorItem[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [sort, setSort] = useState<SortMode>("mainNetInflow");
useEffect(() => { const loadData = (t: SectorType) => {
setLoading(true); setLoading(true);
fetchSectors(tab).then((items) => { fetchSectors(t).then((items) => {
setData(items); setData(items);
setLoading(false); setLoading(false);
}); });
};
useEffect(() => {
loadData(tab);
}, [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 ( return (
<div className="min-h-screen bg-background"> <div className="min-h-screen bg-background">
{/* 顶栏 */} {/* 顶栏 */}
@@ -40,10 +53,7 @@ function SectorsPage() {
<h1 className="text-base font-semibold"></h1> <h1 className="text-base font-semibold"></h1>
</div> </div>
<button <button
onClick={() => { onClick={() => loadData(tab)}
setLoading(true);
fetchSectors(tab).then(setData).finally(() => setLoading(false));
}}
className="text-muted-foreground hover:text-foreground transition-colors" className="text-muted-foreground hover:text-foreground transition-colors"
> >
<RefreshCw className={`h-4 w-4 ${loading ? "animate-spin" : ""}`} /> <RefreshCw className={`h-4 w-4 ${loading ? "animate-spin" : ""}`} />
@@ -51,9 +61,9 @@ function SectorsPage() {
</div> </div>
</header> </header>
{/* Tab 切换 */} {/* Tab + Sorting 切换 */}
<div className="max-w-5xl mx-auto px-4 mt-4"> <div className="max-w-5xl mx-auto px-4 mt-4 flex items-center gap-2">
<div className="flex gap-1 bg-muted rounded-lg p-1"> <div className="flex gap-1 bg-muted rounded-lg p-1 flex-1">
{TABS.map((t) => ( {TABS.map((t) => (
<button <button
key={t.key} key={t.key}
@@ -68,26 +78,42 @@ function SectorsPage() {
</button> </button>
))} ))}
</div> </div>
<div className="flex gap-0.5 text-xs border rounded overflow-hidden shrink-0">
<button onClick={() => setSort("mainNetInflow")}
className={`px-2 py-1 transition-colors ${
sort === "mainNetInflow"
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
></button>
<button onClick={() => setSort("changePercent")}
className={`px-2 py-1 transition-colors ${
sort === "changePercent"
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
></button>
</div>
</div> </div>
{/* 数据更新时间 */} {/* 数据信息 */}
<div className="max-w-5xl mx-auto px-4 mt-2"> <div className="max-w-5xl mx-auto px-4 mt-2">
<p className="text-[10px] text-muted-foreground"> <p className="text-[10px] text-muted-foreground">
· · {data.length} {sorted.length} · {sort === "mainNetInflow" ? "主力净流入" : "涨跌幅"}
</p> </p>
</div> </div>
{/* 方块图 */} {/* 卡片网格 */}
<div className="max-w-5xl mx-auto px-4 mt-3 pb-8"> <div className="max-w-5xl mx-auto px-4 mt-3 pb-8">
{loading ? ( {loading ? (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-3"> <div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-3">
{Array.from({ length: 20 }).map((_, i) => ( {Array.from({ length: 20 }).map((_, i) => (
<div key={i} className="animate-pulse rounded-xl bg-muted h-24" /> <div key={i} className="animate-pulse rounded-xl bg-muted h-32" />
))} ))}
</div> </div>
) : ( ) : (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-3"> <div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-3">
{data.map((item) => ( {sorted.map((item) => (
<SectorBlock key={item.code} item={item} /> <SectorBlock key={item.code} item={item} />
))} ))}
</div> </div>
@@ -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 (
<div className="flex items-center gap-1">
<div className="flex-1 h-1.5 rounded-full bg-muted overflow-hidden">
<div
className={`h-full rounded-full transition-all ${
isPos ? "bg-red-500/60" : "bg-green-500/60"
}`}
style={{ width: `${Math.min(Math.abs(pct), 100)}%`, marginLeft: isPos ? "50%" : undefined }}
/>
</div>
<span className={`text-[10px] font-medium tabular-nums w-14 text-right ${
isPos ? "text-red-500" : "text-green-500"
}`}>
{formatInflow(value)}
</span>
</div>
);
}
function SectorBlock({ item }: { item: SectorItem }) { function SectorBlock({ item }: { item: SectorItem }) {
const inflow = item.mainNetInflow; const inflow = item.mainNetInflow;
const isPositive = inflow >= 0; const isPositive = inflow >= 0;
const change = item.changePercent; 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 ( return (
<Card className="rounded-xl hover:shadow-md transition-shadow"> <Card className="rounded-xl hover:shadow-md transition-shadow">
<CardContent className="p-3 space-y-2"> <CardContent className="p-3 space-y-2">
{/* 板块名称 + 编号 */} {/* 板块名称 */}
<div className="flex items-center justify-between gap-1"> <div className="flex items-center justify-between gap-1">
<p className="text-sm font-medium truncate" title={item.name}> <p className="text-sm font-medium truncate" title={item.name}>
{item.name} {item.name}
</p> </p>
{item.code && ( {item.code && (
<span className="shrink-0 text-[9px] text-muted-foreground/60 font-mono"> <span className="shrink-0 text-[9px] text-muted-foreground/60 font-mono">
{item.code} {item.code.replace("BK", "")}
</span> </span>
)} )}
</div> </div>
{/* 涨跌幅 */} {/* 涨跌幅 */}
<div className="flex items-center gap-1"> <div className="flex items-center justify-between">
{change != null && ( {change != null ? (
<span <span
className={`inline-flex items-center gap-0.5 text-xs font-semibold ${ className={`inline-flex items-center gap-0.5 text-xs font-semibold ${
change >= 0 ? "text-red-500" : "text-green-500" change >= 0 ? "text-red-500" : "text-green-500"
@@ -133,20 +198,32 @@ function SectorBlock({ item }: { item: SectorItem }) {
{change >= 0 ? "+" : ""} {change >= 0 ? "+" : ""}
{change.toFixed(2)}% {change.toFixed(2)}%
</span> </span>
) : (
<span className="text-xs text-muted-foreground">--</span>
)} )}
<span className="text-[10px] text-muted-foreground">
{formatInflow(item.turnover)}
</span>
</div> </div>
{/* 资金流向 */} {/* 主力净流入 */}
<div className="pt-1 border-t border-border/50"> <div className="pt-1.5 border-t border-border/40">
<p className="text-[10px] text-muted-foreground"></p> <div className="flex items-center justify-between mb-1">
<p <span className="text-[10px] text-muted-foreground"></span>
className={`text-xs font-bold ${ <span className={`text-xs font-bold tabular-nums ${
isPositive ? "text-red-500" : "text-green-500" isPositive ? "text-red-500" : "text-green-500"
}`} }`}>
> {isPositive ? "+" : ""}{formatInflow(inflow)}
{isPositive ? "+" : ""} </span>
{formatMoney(inflow)} </div>
</p>
{/* 资金流向明细条 */}
<div className="space-y-0.5">
<FundFlowBar value={item.superLargeInflow} maxAbs={maxAbs} />
<FundFlowBar value={item.largeInflow} maxAbs={maxAbs} />
<FundFlowBar value={item.mediumInflow} maxAbs={maxAbs} />
<FundFlowBar value={item.smallInflow} maxAbs={maxAbs} />
</div>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
+3 -8
View File
@@ -46,6 +46,7 @@ interface StockData {
low: number; low: number;
volume: number; volume: number;
isAddedDate: boolean; isAddedDate: boolean;
changePercent: number;
} }
interface StockInfo { interface StockInfo {
@@ -226,6 +227,7 @@ function StockDetail() {
low: kline.low, low: kline.low,
volume: kline.volume, volume: kline.volume,
isAddedDate, isAddedDate,
changePercent: kline.changePercent ?? 0,
}; };
}); });
}; };
@@ -685,14 +687,7 @@ function StockDetail() {
const displayData = chartData.slice(-dailyTableDays).reverse(); const displayData = chartData.slice(-dailyTableDays).reverse();
return displayData.map((item, idx) => { return displayData.map((item, idx) => {
// 计算涨幅:与后一天收盘价比较(因为已倒序,后一天是更早的日期) const changePercent = item.changePercent;
let changePercent = 0;
if (idx < displayData.length - 1) {
const nextClose = displayData[idx + 1].close;
if (nextClose > 0) {
changePercent = ((item.close - nextClose) / nextClose) * 100;
}
}
const isPositive = changePercent >= 0; const isPositive = changePercent >= 0;
// 查找对应的资金流向数据 // 查找对应的资金流向数据