1
This commit is contained in:
parent
45e4c36b50
commit
6ce504dbb4
Binary file not shown.
Binary file not shown.
@ -13,12 +13,14 @@
|
||||
"""
|
||||
|
||||
import logging
|
||||
import httpx
|
||||
import pandas as pd
|
||||
from datetime import datetime
|
||||
|
||||
from app.data.tushare_client import tushare_client
|
||||
from app.data import tencent_client
|
||||
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.technical import add_all_indicators
|
||||
from app.analysis.signals import generate_signals
|
||||
@ -29,14 +31,14 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def intraday_market_temperature(prev_temp: MarketTemperature) -> MarketTemperature:
|
||||
"""盘中市场温度:用腾讯实时行情计算真实涨跌数据"""
|
||||
"""盘中市场温度:用腾讯实时行情计算涨跌数据 + 东方财富统计涨停跌停"""
|
||||
index_data = await tencent_client.get_index_realtime()
|
||||
sh_index = index_data.get("000001.SH")
|
||||
|
||||
if not sh_index:
|
||||
return prev_temp
|
||||
|
||||
# ── 用腾讯实时行情计算真实涨跌数量 ──
|
||||
# ── 用腾讯实时行情计算涨跌数量 ──
|
||||
up_count = prev_temp.up_count
|
||||
down_count = prev_temp.down_count
|
||||
limit_up_count = prev_temp.limit_up_count
|
||||
@ -45,28 +47,65 @@ async def intraday_market_temperature(prev_temp: MarketTemperature) -> MarketTem
|
||||
try:
|
||||
stock_basic = tushare_client.get_stock_basic()
|
||||
if not stock_basic.empty:
|
||||
# 过滤 ST 股票
|
||||
all_codes = stock_basic[~stock_basic["name"].str.contains("ST", na=False)]["ts_code"].tolist()
|
||||
|
||||
if 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)
|
||||
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(
|
||||
f"盘中实时涨跌统计: 上涨={up_count} 下跌={down_count} "
|
||||
f"涨停={limit_up_count} 跌停={limit_down_count} (共{len(quotes)}只)"
|
||||
f"盘中实时涨跌统计: 上涨={up_count} 下跌={down_count} (共{len(quotes)}只)"
|
||||
)
|
||||
except Exception as 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)
|
||||
temp_from_ratio = min(ratio / 3.0 * 25, 25) # 涨跌比维度 (0-25)
|
||||
|
||||
@ -15,7 +15,11 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def calculate_market_temperature(trade_date: str = None) -> MarketTemperature:
|
||||
"""计算指定交易日的市场温度"""
|
||||
"""计算指定交易日的市场温度
|
||||
|
||||
盘中 Tushare 当日数据可能不完整(limit_list_d 只有盘后数据),
|
||||
因此涨停数据先尝试当日,若为空则用前一日数据作为基线。
|
||||
"""
|
||||
if not 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:
|
||||
return MarketTemperature(trade_date=trade_date, temperature=50)
|
||||
|
||||
# 排除 ST 和新股(简单过滤:排除名称含 ST 的,通过涨跌幅>11% 排除)
|
||||
up_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)
|
||||
|
||||
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_down_count = 0
|
||||
max_streak = 0
|
||||
|
||||
Binary file not shown.
@ -1,5 +1,8 @@
|
||||
"""推荐列表 API"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.engine.recommender import (
|
||||
@ -10,6 +13,8 @@ from app.engine.recommender import (
|
||||
)
|
||||
from app.config import is_trading_hours
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/recommendations", tags=["recommendations"])
|
||||
|
||||
|
||||
@ -68,19 +73,55 @@ async def get_latest():
|
||||
|
||||
@router.post("/refresh")
|
||||
async def refresh(scan_session: str = "manual"):
|
||||
"""手动触发一次全量筛选"""
|
||||
result = await refresh_recommendations(scan_session=scan_session)
|
||||
rec_count = len(result.get("recommendations", []))
|
||||
mt = result.get("market_temp")
|
||||
"""手动触发一次全量筛选(后台执行,立即返回)"""
|
||||
from app.engine.recommender import _scan_running, _scan_lock
|
||||
|
||||
if _scan_running:
|
||||
return {
|
||||
"status": "already_running",
|
||||
"message": "扫描正在执行中,请稍候",
|
||||
"is_trading": is_trading_hours(),
|
||||
}
|
||||
|
||||
# 在后台执行扫描,立即返回响应
|
||||
asyncio.create_task(_run_scan_background(scan_session))
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"count": rec_count,
|
||||
"temperature": mt.temperature if mt else 0,
|
||||
"scan_mode": result.get("scan_mode", "unknown"),
|
||||
"status": "scanning",
|
||||
"message": "扫描已启动,完成后自动刷新",
|
||||
"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")
|
||||
async def update_tracking():
|
||||
"""独立更新推荐跟踪数据(不触发新扫描,盘中可单独调用)"""
|
||||
|
||||
Binary file not shown.
@ -6,6 +6,7 @@
|
||||
|
||||
import logging
|
||||
import json
|
||||
import asyncio
|
||||
from datetime import datetime
|
||||
from app.engine.screener import run_screening
|
||||
from app.data.models import Recommendation, MarketTemperature, SectorInfo
|
||||
@ -17,27 +18,40 @@ logger = logging.getLogger(__name__)
|
||||
# 内存中的最新推荐结果
|
||||
_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:
|
||||
"""刷新推荐列表"""
|
||||
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
|
||||
for rec in result.get("recommendations", []):
|
||||
rec.scan_session = scan_session
|
||||
rec.created_at = datetime.now()
|
||||
async with _scan_lock:
|
||||
_scan_running = True
|
||||
try:
|
||||
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()
|
||||
|
||||
# 持久化到数据库
|
||||
await _save_to_db(result)
|
||||
_latest_result = result
|
||||
|
||||
# 更新历史推荐跟踪(检查之前推荐的后续表现)
|
||||
await _update_tracking()
|
||||
# 持久化到数据库
|
||||
await _save_to_db(result)
|
||||
|
||||
return result
|
||||
# 更新历史推荐跟踪(检查之前推荐的后续表现)
|
||||
await _update_tracking()
|
||||
|
||||
return result
|
||||
finally:
|
||||
_scan_running = False
|
||||
|
||||
|
||||
async def _update_tracking():
|
||||
|
||||
Binary file not shown.
@ -10,16 +10,6 @@
|
||||
"static/chunks/main-app.js",
|
||||
"static/css/app/layout.css",
|
||||
"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"
|
||||
]
|
||||
}
|
||||
}
|
||||
2
frontend/.next/cache/.tsbuildinfo
vendored
2
frontend/.next/cache/.tsbuildinfo
vendored
File diff suppressed because one or more lines are too long
@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
{}
|
||||
@ -1,5 +1,3 @@
|
||||
{
|
||||
"/_not-found/page": "app/_not-found/page.js",
|
||||
"/page": "app/page.js",
|
||||
"/sectors/page": "app/sectors/page.js"
|
||||
"/page": "app/page.js"
|
||||
}
|
||||
@ -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="{}"
|
||||
@ -1,5 +1,5 @@
|
||||
{
|
||||
"node": {},
|
||||
"edge": {},
|
||||
"encryptionKey": "3EcBu3/DLHcDJDy/e3ozITIuj0jdVQNPr7Acwbdh1AI="
|
||||
"encryptionKey": "4dlovStTHcLljKBPVgo4Nvr4F//yhFdsjyUsGgBQwBY="
|
||||
}
|
||||
@ -125,7 +125,7 @@
|
||||
/******/
|
||||
/******/ /* webpack/runtime/getFullHash */
|
||||
/******/ (() => {
|
||||
/******/ __webpack_require__.h = () => ("463c86bf832e0923")
|
||||
/******/ __webpack_require__.h = () => ("cd14067d5a0d1652")
|
||||
/******/ })();
|
||||
/******/
|
||||
/******/ /* webpack/runtime/hasOwnProperty shorthand */
|
||||
|
||||
File diff suppressed because one or more lines are too long
@ -56,10 +56,22 @@ export default function RecommendationsPage() {
|
||||
loadData();
|
||||
}, [loadData]);
|
||||
|
||||
// WebSocket 监听扫描结果
|
||||
useWebSocket(
|
||||
useCallback(() => {
|
||||
loadData();
|
||||
}, [loadData])
|
||||
useCallback((data: { type: string; session?: string; count?: number; temperature?: number; scan_mode?: string; message?: string }) => {
|
||||
if (data.type === "scan_update") {
|
||||
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 () => {
|
||||
@ -68,18 +80,21 @@ export default function RecommendationsPage() {
|
||||
try {
|
||||
const res = await postAPI<{
|
||||
status: string;
|
||||
count: number;
|
||||
temperature: number;
|
||||
scan_mode: string;
|
||||
message?: string;
|
||||
is_trading: boolean;
|
||||
}>("/api/recommendations/refresh?scan_session=manual");
|
||||
const modeLabel = res.scan_mode === "intraday" ? "盘中实时" : "盘后";
|
||||
setRefreshResult(`${modeLabel}扫描完成,发现 ${res.count} 只股票`);
|
||||
await loadData();
|
||||
|
||||
if (res.status === "already_running") {
|
||||
setRefreshResult(res.message || "扫描正在执行中,请稍候");
|
||||
setTimeout(() => setRefreshResult(null), 5000);
|
||||
// 不关闭 refreshing,等待 WS 推送完成通知
|
||||
} else if (res.status === "scanning") {
|
||||
setRefreshResult("扫描已启动,完成后自动刷新...");
|
||||
// 保持 refreshing 状态,等待 WS 推送
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("扫描失败:", e);
|
||||
setRefreshResult("扫描失败,请重试");
|
||||
} finally {
|
||||
console.error("触发扫描失败:", e);
|
||||
setRefreshResult("触发扫描失败,请重试");
|
||||
setRefreshing(false);
|
||||
setTimeout(() => setRefreshResult(null), 5000);
|
||||
}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState, useCallback } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
interface WSMessage {
|
||||
type: string;
|
||||
@ -10,13 +10,25 @@ interface WSMessage {
|
||||
export function useWebSocket(onMessage?: (data: WSMessage) => void, messageTypes?: string[]) {
|
||||
const [connected, setConnected] = useState(false);
|
||||
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 ws = new WebSocket(`${protocol}//${window.location.host}/ws`);
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.onopen = () => {
|
||||
if (!mountedRef.current) return;
|
||||
setConnected(true);
|
||||
// 心跳
|
||||
const heartbeat = setInterval(() => {
|
||||
@ -29,29 +41,41 @@ export function useWebSocket(onMessage?: (data: WSMessage) => void, messageTypes
|
||||
if (event.data === "pong") return;
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
if (messageTypes && !messageTypes.includes(data.type)) return;
|
||||
onMessage?.(data);
|
||||
const types = messageTypesRef.current;
|
||||
if (types && !types.includes(data.type)) return;
|
||||
onMessageRef.current?.(data);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
if (!mountedRef.current) return;
|
||||
setConnected(false);
|
||||
reconnectTimer.current = setTimeout(connect, 5000);
|
||||
wsRef.current = null;
|
||||
// 5秒后重连
|
||||
reconnectTimerRef.current = setTimeout(connect, 5000);
|
||||
};
|
||||
|
||||
ws.onerror = () => ws.close();
|
||||
wsRef.current = ws;
|
||||
}, [onMessage, messageTypes]);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
connect();
|
||||
|
||||
return () => {
|
||||
clearTimeout(reconnectTimer.current);
|
||||
wsRef.current?.close();
|
||||
mountedRef.current = false;
|
||||
if (reconnectTimerRef.current) {
|
||||
clearTimeout(reconnectTimerRef.current);
|
||||
reconnectTimerRef.current = null;
|
||||
}
|
||||
if (wsRef.current) {
|
||||
wsRef.current.close();
|
||||
wsRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [connect]);
|
||||
}, []);
|
||||
|
||||
return { connected };
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user