This commit is contained in:
aaron 2026-04-16 17:18:16 +08:00
parent 45e4c36b50
commit 6ce504dbb4
19 changed files with 229 additions and 139 deletions

View File

@ -13,12 +13,14 @@
""" """
import logging import logging
import httpx
import pandas as pd import pandas as pd
from datetime import datetime from datetime import datetime
from app.data.tushare_client import tushare_client from app.data.tushare_client import tushare_client
from app.data import tencent_client from app.data import tencent_client
from app.data.models import SectorInfo, Recommendation, MarketTemperature, StockQuote from app.data.models import SectorInfo, Recommendation, MarketTemperature, StockQuote
from app.data.eastmoney_client import SECTOR_LIST_URL, SECTOR_HEADERS
from app.analysis.sector_scanner import scan_hot_sectors from app.analysis.sector_scanner import scan_hot_sectors
from app.analysis.technical import add_all_indicators from app.analysis.technical import add_all_indicators
from app.analysis.signals import generate_signals from app.analysis.signals import generate_signals
@ -29,14 +31,14 @@ logger = logging.getLogger(__name__)
async def intraday_market_temperature(prev_temp: MarketTemperature) -> MarketTemperature: async def intraday_market_temperature(prev_temp: MarketTemperature) -> MarketTemperature:
"""盘中市场温度:用腾讯实时行情计算真实涨跌数据""" """盘中市场温度:用腾讯实时行情计算涨跌数据 + 东方财富统计涨停跌停"""
index_data = await tencent_client.get_index_realtime() index_data = await tencent_client.get_index_realtime()
sh_index = index_data.get("000001.SH") sh_index = index_data.get("000001.SH")
if not sh_index: if not sh_index:
return prev_temp return prev_temp
# ── 用腾讯实时行情计算真实涨跌数量 ── # ── 用腾讯实时行情计算涨跌数量 ──
up_count = prev_temp.up_count up_count = prev_temp.up_count
down_count = prev_temp.down_count down_count = prev_temp.down_count
limit_up_count = prev_temp.limit_up_count limit_up_count = prev_temp.limit_up_count
@ -45,28 +47,65 @@ async def intraday_market_temperature(prev_temp: MarketTemperature) -> MarketTem
try: try:
stock_basic = tushare_client.get_stock_basic() stock_basic = tushare_client.get_stock_basic()
if not stock_basic.empty: if not stock_basic.empty:
# 过滤 ST 股票
all_codes = stock_basic[~stock_basic["name"].str.contains("ST", na=False)]["ts_code"].tolist() all_codes = stock_basic[~stock_basic["name"].str.contains("ST", na=False)]["ts_code"].tolist()
if all_codes: if all_codes:
quotes = await tencent_client.get_realtime_quotes_batch(all_codes) quotes = await tencent_client.get_realtime_quotes_batch(all_codes)
up_count = sum(1 for q in quotes.values() if q.pct_chg > 0) up_count = sum(1 for q in quotes.values() if q.pct_chg > 0)
down_count = sum(1 for q in quotes.values() if q.pct_chg < 0) down_count = sum(1 for q in quotes.values() if q.pct_chg < 0)
limit_up_count = sum(
1 for q in quotes.values()
if q.limit_up and q.price >= q.limit_up * 0.995
)
limit_down_count = sum(
1 for q in quotes.values()
if q.limit_down and q.price <= q.limit_down * 1.005
)
logger.info( logger.info(
f"盘中实时涨跌统计: 上涨={up_count} 下跌={down_count} " f"盘中实时涨跌统计: 上涨={up_count} 下跌={down_count} (共{len(quotes)}只)"
f"涨停={limit_up_count} 跌停={limit_down_count} (共{len(quotes)}只)"
) )
except Exception as e: except Exception as e:
logger.warning(f"获取盘中实时涨跌统计失败,使用上一日数据: {e}") logger.warning(f"获取盘中实时涨跌统计失败,使用上一日数据: {e}")
# ── 用东方财富 clist API 统计涨停跌停(比腾讯涨停价字段更可靠) ──
try:
limit_up_count = 0
limit_down_count = 0
for fs, threshold in [
("m:0+t:6,m:0+t:80,m:0+t:81+s:2048", 9.9), # 主板 10%
("m:1+t:2,m:1+t:23", 19.9), # 创业板/科创板 20%
]:
async with httpx.AsyncClient() as client:
# 涨停:按涨幅降序取 top 200
params_up = {
"pn": "1", "pz": "200", "po": "1", "np": "1",
"ut": "b1f8f8f8", "fltt": "2", "invt": "2",
"fid": "f3", "fs": fs,
"fields": "f3,f12,f14",
}
resp = await client.get(SECTOR_LIST_URL, params=params_up, headers=SECTOR_HEADERS, timeout=10)
items = resp.json().get("data", {}).get("diff", []) if resp.json().get("data") else []
for item in items:
pct = item.get("f3")
if pct == "-" or pct is None:
continue
if float(pct) >= threshold:
limit_up_count += 1
# 跌停:按涨幅升序取 top 200
params_down = {
"pn": "1", "pz": "200", "po": "0", "np": "1",
"ut": "b1f8f8f8", "fltt": "2", "invt": "2",
"fid": "f3", "fs": fs,
"fields": "f3,f12,f14",
}
resp_down = await client.get(SECTOR_LIST_URL, params=params_down, headers=SECTOR_HEADERS, timeout=10)
items_down = resp_down.json().get("data", {}).get("diff", []) if resp_down.json().get("data") else []
neg_threshold = -threshold
for item in items_down:
pct = item.get("f3")
if pct == "-" or pct is None:
continue
if float(pct) <= neg_threshold:
limit_down_count += 1
logger.info(f"东方财富盘中涨跌停: 涨停={limit_up_count} 跌停={limit_down_count}")
except Exception as e:
logger.warning(f"东方财富涨跌停统计失败,使用基线数据: {e}")
# ── 温度分数:基于实时涨跌比重新计算 ── # ── 温度分数:基于实时涨跌比重新计算 ──
ratio = up_count / max(down_count, 1) ratio = up_count / max(down_count, 1)
temp_from_ratio = min(ratio / 3.0 * 25, 25) # 涨跌比维度 (0-25) temp_from_ratio = min(ratio / 3.0 * 25, 25) # 涨跌比维度 (0-25)

View File

@ -15,7 +15,11 @@ logger = logging.getLogger(__name__)
def calculate_market_temperature(trade_date: str = None) -> MarketTemperature: def calculate_market_temperature(trade_date: str = None) -> MarketTemperature:
"""计算指定交易日的市场温度""" """计算指定交易日的市场温度
盘中 Tushare 当日数据可能不完整limit_list_d 只有盘后数据
因此涨停数据先尝试当日若为空则用前一日数据作为基线
"""
if not trade_date: if not trade_date:
trade_date = tushare_client.get_latest_trade_date() trade_date = tushare_client.get_latest_trade_date()
@ -24,14 +28,21 @@ def calculate_market_temperature(trade_date: str = None) -> MarketTemperature:
if daily_df.empty: if daily_df.empty:
return MarketTemperature(trade_date=trade_date, temperature=50) return MarketTemperature(trade_date=trade_date, temperature=50)
# 排除 ST 和新股(简单过滤:排除名称含 ST 的,通过涨跌幅>11% 排除)
up_count = len(daily_df[daily_df["pct_chg"] > 0]) up_count = len(daily_df[daily_df["pct_chg"] > 0])
down_count = len(daily_df[daily_df["pct_chg"] < 0]) down_count = len(daily_df[daily_df["pct_chg"] < 0])
flat_count = len(daily_df[daily_df["pct_chg"] == 0])
# 2. 涨跌停数据 # 2. 涨跌停数据 — 先尝试当日,若为空则用前一日
limit_df = tushare_client.get_limit_list(trade_date) limit_df = tushare_client.get_limit_list(trade_date)
if limit_df.empty:
# 当日无涨跌停数据(盘中),用前一日作为基线
prev_dates = tushare_client.get_trade_dates()
prev_dates = [d for d in prev_dates if d < trade_date]
if prev_dates:
prev_date = prev_dates[-1]
limit_df = tushare_client.get_limit_list(prev_date)
logger.info(f"当日涨跌停数据为空,使用前一日 {prev_date} 作为基线")
limit_up_count = 0 limit_up_count = 0
limit_down_count = 0 limit_down_count = 0
max_streak = 0 max_streak = 0

View File

@ -1,5 +1,8 @@
"""推荐列表 API""" """推荐列表 API"""
import asyncio
import logging
from datetime import datetime
from fastapi import APIRouter from fastapi import APIRouter
from app.engine.recommender import ( from app.engine.recommender import (
@ -10,6 +13,8 @@ from app.engine.recommender import (
) )
from app.config import is_trading_hours from app.config import is_trading_hours
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/recommendations", tags=["recommendations"]) router = APIRouter(prefix="/api/recommendations", tags=["recommendations"])
@ -68,19 +73,55 @@ async def get_latest():
@router.post("/refresh") @router.post("/refresh")
async def refresh(scan_session: str = "manual"): async def refresh(scan_session: str = "manual"):
"""手动触发一次全量筛选""" """手动触发一次全量筛选(后台执行,立即返回)"""
result = await refresh_recommendations(scan_session=scan_session) from app.engine.recommender import _scan_running, _scan_lock
rec_count = len(result.get("recommendations", []))
mt = result.get("market_temp") if _scan_running:
return {
"status": "already_running",
"message": "扫描正在执行中,请稍候",
"is_trading": is_trading_hours(),
}
# 在后台执行扫描,立即返回响应
asyncio.create_task(_run_scan_background(scan_session))
return { return {
"status": "ok", "status": "scanning",
"count": rec_count, "message": "扫描已启动,完成后自动刷新",
"temperature": mt.temperature if mt else 0,
"scan_mode": result.get("scan_mode", "unknown"),
"is_trading": is_trading_hours(), "is_trading": is_trading_hours(),
} }
async def _run_scan_background(scan_session: str):
"""后台执行扫描并推送结果"""
from app.engine.recommender import refresh_recommendations
from app.api.websocket import broadcast_update
try:
result = await refresh_recommendations(scan_session=scan_session)
rec_count = len(result.get("recommendations", []))
mt = result.get("market_temp")
# 通过 WebSocket 推送扫描完成通知
await broadcast_update({
"type": "scan_update",
"session": scan_session,
"count": rec_count,
"temperature": mt.temperature if mt else 0,
"scan_mode": result.get("scan_mode", "unknown"),
"timestamp": datetime.now().isoformat(),
})
except Exception as e:
logger.error(f"后台扫描失败: {e}")
await broadcast_update({
"type": "scan_error",
"session": scan_session,
"message": str(e),
"timestamp": datetime.now().isoformat(),
})
@router.post("/update-tracking") @router.post("/update-tracking")
async def update_tracking(): async def update_tracking():
"""独立更新推荐跟踪数据(不触发新扫描,盘中可单独调用)""" """独立更新推荐跟踪数据(不触发新扫描,盘中可单独调用)"""

View File

@ -6,6 +6,7 @@
import logging import logging
import json import json
import asyncio
from datetime import datetime from datetime import datetime
from app.engine.screener import run_screening from app.engine.screener import run_screening
from app.data.models import Recommendation, MarketTemperature, SectorInfo from app.data.models import Recommendation, MarketTemperature, SectorInfo
@ -17,27 +18,40 @@ logger = logging.getLogger(__name__)
# 内存中的最新推荐结果 # 内存中的最新推荐结果
_latest_result: dict | None = None _latest_result: dict | None = None
# 扫描锁:防止同时触发两次扫描
_scan_lock = asyncio.Lock()
_scan_running = False
async def refresh_recommendations(trade_date: str = None, scan_session: str = "manual") -> dict: async def refresh_recommendations(trade_date: str = None, scan_session: str = "manual") -> dict:
"""刷新推荐列表""" """刷新推荐列表(带扫描锁防止并发)"""
global _latest_result global _latest_result, _scan_running
result = await run_screening(trade_date) if _scan_lock.locked():
logger.warning("扫描已在执行中,跳过本次触发")
return _latest_result or {"market_temp": None, "hot_sectors": [], "recommendations": []}
# 给每条推荐添加 scan_session async with _scan_lock:
for rec in result.get("recommendations", []): _scan_running = True
rec.scan_session = scan_session try:
rec.created_at = datetime.now() result = await run_screening(trade_date)
_latest_result = result # 给每条推荐添加 scan_session
for rec in result.get("recommendations", []):
rec.scan_session = scan_session
rec.created_at = datetime.now()
# 持久化到数据库 _latest_result = result
await _save_to_db(result)
# 更新历史推荐跟踪(检查之前推荐的后续表现) # 持久化到数据库
await _update_tracking() await _save_to_db(result)
return result # 更新历史推荐跟踪(检查之前推荐的后续表现)
await _update_tracking()
return result
finally:
_scan_running = False
async def _update_tracking(): async def _update_tracking():

Binary file not shown.

View File

@ -10,16 +10,6 @@
"static/chunks/main-app.js", "static/chunks/main-app.js",
"static/css/app/layout.css", "static/css/app/layout.css",
"static/chunks/app/layout.js" "static/chunks/app/layout.js"
],
"/sectors/page": [
"static/chunks/webpack.js",
"static/chunks/main-app.js",
"static/chunks/app/sectors/page.js"
],
"/_not-found/page": [
"static/chunks/webpack.js",
"static/chunks/main-app.js",
"static/chunks/app/_not-found/page.js"
] ]
} }
} }

File diff suppressed because one or more lines are too long

View File

@ -1,8 +1 @@
{ {}
"app/sectors/page.tsx -> echarts": {
"id": "app/sectors/page.tsx -> echarts",
"files": [
"static/chunks/_app-pages-browser_node_modules_echarts_index_js.js"
]
}
}

View File

@ -1,5 +1,3 @@
{ {
"/_not-found/page": "app/_not-found/page.js", "/page": "app/page.js"
"/page": "app/page.js",
"/sectors/page": "app/sectors/page.js"
} }

View File

@ -1 +1 @@
self.__REACT_LOADABLE_MANIFEST="{\"app/sectors/page.tsx -> echarts\":{\"id\":\"app/sectors/page.tsx -> echarts\",\"files\":[\"static/chunks/_app-pages-browser_node_modules_echarts_index_js.js\"]}}" self.__REACT_LOADABLE_MANIFEST="{}"

View File

@ -1,5 +1,5 @@
{ {
"node": {}, "node": {},
"edge": {}, "edge": {},
"encryptionKey": "3EcBu3/DLHcDJDy/e3ozITIuj0jdVQNPr7Acwbdh1AI=" "encryptionKey": "4dlovStTHcLljKBPVgo4Nvr4F//yhFdsjyUsGgBQwBY="
} }

View File

@ -125,7 +125,7 @@
/******/ /******/
/******/ /* webpack/runtime/getFullHash */ /******/ /* webpack/runtime/getFullHash */
/******/ (() => { /******/ (() => {
/******/ __webpack_require__.h = () => ("463c86bf832e0923") /******/ __webpack_require__.h = () => ("cd14067d5a0d1652")
/******/ })(); /******/ })();
/******/ /******/
/******/ /* webpack/runtime/hasOwnProperty shorthand */ /******/ /* webpack/runtime/hasOwnProperty shorthand */

File diff suppressed because one or more lines are too long

View File

@ -56,10 +56,22 @@ export default function RecommendationsPage() {
loadData(); loadData();
}, [loadData]); }, [loadData]);
// WebSocket 监听扫描结果
useWebSocket( useWebSocket(
useCallback(() => { useCallback((data: { type: string; session?: string; count?: number; temperature?: number; scan_mode?: string; message?: string }) => {
loadData(); if (data.type === "scan_update") {
}, [loadData]) const modeLabel = data.scan_mode === "intraday" ? "盘中实时" : "盘后";
setRefreshResult(`${modeLabel}扫描完成,发现 ${data.count ?? 0} 只股票`);
setRefreshing(false);
loadData();
setTimeout(() => setRefreshResult(null), 5000);
} else if (data.type === "scan_error") {
setRefreshResult("扫描失败,请重试");
setRefreshing(false);
setTimeout(() => setRefreshResult(null), 5000);
}
}, [loadData]),
["scan_update", "scan_error"]
); );
const handleRefresh = async () => { const handleRefresh = async () => {
@ -68,18 +80,21 @@ export default function RecommendationsPage() {
try { try {
const res = await postAPI<{ const res = await postAPI<{
status: string; status: string;
count: number; message?: string;
temperature: number;
scan_mode: string;
is_trading: boolean; is_trading: boolean;
}>("/api/recommendations/refresh?scan_session=manual"); }>("/api/recommendations/refresh?scan_session=manual");
const modeLabel = res.scan_mode === "intraday" ? "盘中实时" : "盘后";
setRefreshResult(`${modeLabel}扫描完成,发现 ${res.count} 只股票`); if (res.status === "already_running") {
await loadData(); setRefreshResult(res.message || "扫描正在执行中,请稍候");
setTimeout(() => setRefreshResult(null), 5000);
// 不关闭 refreshing等待 WS 推送完成通知
} else if (res.status === "scanning") {
setRefreshResult("扫描已启动,完成后自动刷新...");
// 保持 refreshing 状态,等待 WS 推送
}
} catch (e) { } catch (e) {
console.error("扫描失败:", e); console.error("触发扫描失败:", e);
setRefreshResult("扫描失败,请重试"); setRefreshResult("触发扫描失败,请重试");
} finally {
setRefreshing(false); setRefreshing(false);
setTimeout(() => setRefreshResult(null), 5000); setTimeout(() => setRefreshResult(null), 5000);
} }

View File

@ -1,6 +1,6 @@
"use client"; "use client";
import { useEffect, useRef, useState, useCallback } from "react"; import { useEffect, useRef, useState } from "react";
interface WSMessage { interface WSMessage {
type: string; type: string;
@ -10,13 +10,25 @@ interface WSMessage {
export function useWebSocket(onMessage?: (data: WSMessage) => void, messageTypes?: string[]) { export function useWebSocket(onMessage?: (data: WSMessage) => void, messageTypes?: string[]) {
const [connected, setConnected] = useState(false); const [connected, setConnected] = useState(false);
const wsRef = useRef<WebSocket | null>(null); const wsRef = useRef<WebSocket | null>(null);
const reconnectTimer = useRef<NodeJS.Timeout>(); const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const onMessageRef = useRef(onMessage);
const messageTypesRef = useRef(messageTypes);
const mountedRef = useRef(true);
// 用 ref 存储 callback避免引用变化导致 WebSocket 重连
onMessageRef.current = onMessage;
messageTypesRef.current = messageTypes;
function connect() {
if (!mountedRef.current) return;
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) return;
const connect = useCallback(() => {
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const ws = new WebSocket(`${protocol}//${window.location.host}/ws`); const ws = new WebSocket(`${protocol}//${window.location.host}/ws`);
wsRef.current = ws;
ws.onopen = () => { ws.onopen = () => {
if (!mountedRef.current) return;
setConnected(true); setConnected(true);
// 心跳 // 心跳
const heartbeat = setInterval(() => { const heartbeat = setInterval(() => {
@ -29,29 +41,41 @@ export function useWebSocket(onMessage?: (data: WSMessage) => void, messageTypes
if (event.data === "pong") return; if (event.data === "pong") return;
try { try {
const data = JSON.parse(event.data); const data = JSON.parse(event.data);
if (messageTypes && !messageTypes.includes(data.type)) return; const types = messageTypesRef.current;
onMessage?.(data); if (types && !types.includes(data.type)) return;
onMessageRef.current?.(data);
} catch { } catch {
// ignore // ignore
} }
}; };
ws.onclose = () => { ws.onclose = () => {
if (!mountedRef.current) return;
setConnected(false); setConnected(false);
reconnectTimer.current = setTimeout(connect, 5000); wsRef.current = null;
// 5秒后重连
reconnectTimerRef.current = setTimeout(connect, 5000);
}; };
ws.onerror = () => ws.close(); ws.onerror = () => ws.close();
wsRef.current = ws; }
}, [onMessage, messageTypes]);
useEffect(() => { useEffect(() => {
mountedRef.current = true;
connect(); connect();
return () => { return () => {
clearTimeout(reconnectTimer.current); mountedRef.current = false;
wsRef.current?.close(); if (reconnectTimerRef.current) {
clearTimeout(reconnectTimerRef.current);
reconnectTimerRef.current = null;
}
if (wsRef.current) {
wsRef.current.close();
wsRef.current = null;
}
}; };
}, [connect]); }, []);
return { connected }; return { connected };
} }