使用push2 数据接口,优化代码
This commit is contained in:
+79
-121
@@ -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位数字")
|
||||||
|
|
||||||
# 获取股票名称(如果未提供)
|
# 数据源1:push2his 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 条
|
# 数据源2:MX 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,
|
||||||
|
})
|
||||||
|
|
||||||
# 数据源1:MX API
|
if not data:
|
||||||
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:
|
|
||||||
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
@@ -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 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()
|
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
|
||||||
|
|||||||
@@ -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 []
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
// 查找对应的资金流向数据
|
// 查找对应的资金流向数据
|
||||||
|
|||||||
Reference in New Issue
Block a user