mirror of
https://ghfast.top/https://github.com/aeroxw/tick-stock-panel.git
synced 2026-09-12 16:44:15 +08:00
* fix(concurrency): 共享缓存/任务表加锁, 全局限速, depth 原子写, 认证热路径缓存 修复多线程下的竞态与阻塞: - overview/strategy_cache/PanelCache/StrategyMonitor._watching 四处共享状态加锁, 消除 "dict/OrderedDict mutated" 与丢更新/半写读取 - strategy_cache/depth parquet 改临时文件 + os.replace 原子写 - rate_limits 改进程级共享时间轴限速, 并发同步不再聚合超过单能力 rpm; scheduler 令牌账目与 sleep 分离, sleep 不再独占锁串行化其他请求 - auth.is_configured() 内存缓存, 认证中间件不再每请求读盘阻塞事件循环 - api/backtest 任务清理/取消全程持 _jobs_lock, 并用 Semaphore(2) 限并发重回测 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * perf(data): limit_ladder 去 N+1 全市场重算, 指标裁剪, factor 向量化 - limit_ladder 前一日 consecutive 改窄读单日 parquet 存储列 (谓词/投影下推), 替代 range(1,10) 逐日 _load_enriched_for_date 全市场指标重算 (最坏 9x) - compute_indicators 新增可选 needed 裁剪 (默认 None 行为逐位不变, 已对照验证), factor 只算所需因子列 - factor._calc_period_return 用 Polars join 替代 Python 逐行 price_map 循环, _add_groups 去 map_elements 改纯表达式 (输出逐位一致) - screener ext value_map 按 parquet mtime 记忆化, 免每请求磁盘重读 (DuckDB 过滤仍用隔离 :memory: 连接, 不扩大注入面) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(backend): 报表存储去重, 删死代码, DuckDB 视图重建收敛, 管道失败如实标记 - 三份近乎逐字复制的 *_reports.py 收敛到共享 JsonReportStore (原子写 + 锁), 各模块公有 API/id 格式/上限/落盘 schema 完全保持不变 - 删除 ext_pull.py 中字节相同的死 _run_loop (Python 只绑第二个) 及无用 import - 13 张 DuckDB 视图重建收敛为唯一权威 repository.rebuild_views(), daily_pipeline 与 /api/data/clear 改为调用 (修好 clear 路径漏挂视图的漂移) - daily_pipeline 累积 stage_errors 并在末尾抛出, 部分失败不再误报成功; free/None 模式的能力门控跳过不计入失败 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(frontend): SSE 连接态, 路由代码分割, 查询失效修复, 三态与无障碍 - 实时行情 SSE: 连接态 store + 指数退避 + 断线徽标/toast (避免静默丢告警); 回测 SSE 断线有界重连 + 可重试, 不再永久卡住进度条 - router 全部 React.lazy + Suspense, vite manualChunks 拆图表库 (echarts 变独立 1MB 懒加载 chunk, 首屏包显著减小) - 修 Data 清库后其它页显示旧数据 (改回广域失效); 修 Watchlist kline 失效键 永不匹配; query key 收敛到 QK 工厂 (新增 strategyDetail) - Monitor/Analysis/StockAnalysis/ExtPages/CustomSignals 补 loading 门控与 error/empty 三态区分 - 新增共享 Modal 原语 (焦点陷阱/ESC/焦点还原/aria), 改造 3 个高频弹窗; Toast/AlertToast 加 aria-live 与键盘可达; Watchlist/LimitUpLadder 卡片 memo 修复本轮 review 发现的缺陷: - Modal 焦点 effect 依赖 onClose 致每次输入抢焦点 → 改 ref 只装一次 - StrategySettingsDialog 删除确认框被 Modal 面板裁剪 → 移出作兄弟节点 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(quant): 修正 ST 板块限价套错 与 因子 Sharpe 年化频率 两个不报错但会算错数的领域 bug: 1. ST 5% 涨跌停限幅被无条件套到创业板/科创板 ST 股: 注册制改革后 创业板(300/301)、科创板(688/689) 的风险警示股仍执行 20%, 北交所 30%, 只有主板 ST 才是 5%。原代码 _is_st 先判且覆盖板块限幅, 导致 创业板/科创板 ST 的涨停价按 5% 计算 → +5% 被误报涨停、真 +20% 涨停被漏报, 污染 signal_limit_up / consecutive_limit_ups / 连板梯队 / near_limit_up。 修正: ST 5% 仅在 ~(创业板|科创板|北交所) 时生效 (EOD + 盘中两条路径 + near_limit_up)。 2. 因子回测 Sharpe 一律乘 √252, 但 group_nav 每点是一个调仓周期收益: 月频调仓下是月收益, 乘 √252 会把 Sharpe 高估 √(252/12) ≈ 4.6x (周频 ≈2.2x), 使无效因子显示成明星因子, 废掉"先筛无效指标"的用途。 修正: 年化系数按 config.rebalance 取 √252/√52/√12。 新增 tests/test_st_limit_and_sharpe.py (5 例) 覆盖两处修正。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
209 lines
7.3 KiB
TypeScript
209 lines
7.3 KiB
TypeScript
import { useEffect, useRef, useCallback, useSyncExternalStore } from 'react'
|
|
import { useQueryClient } from '@tanstack/react-query'
|
|
import { SSE_INVALIDATE_PREFIXES, QK } from './queryKeys'
|
|
import { getQueryConfig } from './useQueryConfig'
|
|
import { toast } from '@/components/Toast'
|
|
import { pushAlertToasts } from '@/components/AlertToast'
|
|
import { feedReviewEvent } from './reviewStore'
|
|
import type { StrategyAlertEvent } from './api'
|
|
|
|
// ===== 全局 SSE 连接状态 (模块级 store, 仿 AlertToast.tsx 模式) =====
|
|
// 实时行情 SSE 断开时 UI 无感知 → 会漏掉策略告警。这里暴露连接状态,
|
|
// 供 Layout 顶部渲染徽标、连续失败 N 次后弹一次 toast。
|
|
export type QuoteStreamStatus = 'connected' | 'reconnecting' | 'disconnected'
|
|
|
|
let _streamStatus: QuoteStreamStatus = 'disconnected'
|
|
const _statusListeners = new Set<() => void>()
|
|
|
|
// 连续失败到达该阈值后弹一次 toast (只弹一次, 恢复后重置)
|
|
const FAILS_BEFORE_TOAST = 3
|
|
// 指数退避上限
|
|
const BACKOFF_CAP_MS = 60_000
|
|
|
|
function _emitStatus() {
|
|
_statusListeners.forEach((fn) => fn())
|
|
}
|
|
|
|
function _setStatus(s: QuoteStreamStatus) {
|
|
if (_streamStatus === s) return
|
|
_streamStatus = s
|
|
_emitStatus()
|
|
}
|
|
|
|
function _subscribeStatus(fn: () => void) {
|
|
_statusListeners.add(fn)
|
|
return () => {
|
|
_statusListeners.delete(fn)
|
|
}
|
|
}
|
|
|
|
function _getStatus() {
|
|
return _streamStatus
|
|
}
|
|
|
|
/** React hook: 读取全局实时行情 SSE 连接状态 (供 Layout 徽标使用) */
|
|
export function useQuoteStreamStatus(): QuoteStreamStatus {
|
|
return useSyncExternalStore(_subscribeStatus, _getStatus, () => 'disconnected' as const)
|
|
}
|
|
|
|
/**
|
|
* 全局 SSE hook: 监听后端行情更新推送 + 策略监控通知。
|
|
*
|
|
* - 行情更新 (quotes_updated): 根据 sseRefreshPages 配置过滤 invalidation
|
|
* - 策略监控通知 (strategy_alert): 通过 onAlert 回调弹 toast
|
|
*
|
|
* 应在顶层 Layout 中调用一次。
|
|
*/
|
|
export function useQuoteStream(
|
|
enabled: boolean,
|
|
sseRefreshPages: Record<string, boolean> | undefined,
|
|
onAlert?: (alerts: StrategyAlertEvent[]) => void,
|
|
) {
|
|
const qc = useQueryClient()
|
|
const esRef = useRef<EventSource | null>(null)
|
|
const retryRef = useRef<ReturnType<typeof setTimeout>>()
|
|
const pagesRef = useRef(sseRefreshPages)
|
|
pagesRef.current = sseRefreshPages
|
|
|
|
const handleAlerts = useCallback((alerts: StrategyAlertEvent[]) => {
|
|
// depth 系统接管通知: 单独处理, 不走 strategy 回调
|
|
const depthAlerts = alerts.filter(a => a.source === 'depth')
|
|
const strategyAlerts = alerts.filter(a => a.source !== 'depth')
|
|
|
|
// depth 通知直接 toast(防刷屏: 后端已在状态切换时才推)
|
|
for (const a of depthAlerts.slice(0, 1)) {
|
|
toast(a.message, 'success')
|
|
}
|
|
|
|
// 监控告警: 用专用 AlertToast (整批只响一声, 每条都弹, 受 maxVisible 上限保护)
|
|
if (strategyAlerts.length > 0) {
|
|
// 有 onAlert 回调时走回调, 否则弹 AlertToast
|
|
if (onAlert) {
|
|
onAlert(strategyAlerts)
|
|
}
|
|
// 批量弹通知 (去掉了 slice(0,2) 截断, 让每只新命中都弹 toast; 声音整批只响一次)
|
|
pushAlertToasts(strategyAlerts as any)
|
|
}
|
|
}, [onAlert])
|
|
|
|
const enabledRef = useRef(enabled)
|
|
enabledRef.current = enabled
|
|
|
|
useEffect(() => {
|
|
// SSE 始终连接 — 监控告警不依赖实时行情开关
|
|
// (quotes_updated 行情刷新受 enabled 控制, strategy_alert 始终处理)
|
|
|
|
// 连续失败计数 (用于指数退避 + 到阈值弹一次 toast)
|
|
let failCount = 0
|
|
let toastFired = false
|
|
|
|
const connect = () => {
|
|
_setStatus(failCount > 0 ? 'reconnecting' : _streamStatus)
|
|
const es = new EventSource('/api/intraday/stream')
|
|
esRef.current = es
|
|
|
|
es.onopen = () => {
|
|
// 连接成功: 重置退避与 toast 标记
|
|
failCount = 0
|
|
toastFired = false
|
|
_setStatus('connected')
|
|
}
|
|
|
|
// sse-starlette ping 心跳走 SSE comment,不会到达这里
|
|
|
|
es.addEventListener('quotes_updated', () => {
|
|
// 实时行情未开启时不处理行情刷新
|
|
if (!enabledRef.current) return
|
|
// 根据用户配置过滤 invalidation
|
|
const pages = pagesRef.current
|
|
if (pages) {
|
|
// 只 invalidate 开启的页面对应的 prefix
|
|
const activePrefixes = SSE_INVALIDATE_PREFIXES.filter((p) => {
|
|
// 'quote-status' 始终刷新 (全局状态)
|
|
if (p === 'quote-status') return true
|
|
return pages[p] !== false
|
|
})
|
|
qc.invalidateQueries({
|
|
predicate: (query) =>
|
|
activePrefixes.some(
|
|
(prefix) => String(query.queryKey[0]).startsWith(prefix),
|
|
),
|
|
})
|
|
} else {
|
|
// 无配置时全部刷新 (向后兼容)
|
|
qc.invalidateQueries({
|
|
predicate: (query) =>
|
|
SSE_INVALIDATE_PREFIXES.some(
|
|
(prefix) => String(query.queryKey[0]).startsWith(prefix),
|
|
),
|
|
})
|
|
}
|
|
})
|
|
|
|
es.addEventListener('depth_updated', () => {
|
|
// 五档修正完成: 刷新连板梯队 + 看板封单数据。
|
|
// 不受实时行情开关限制 — 修正轮询独立于行情轮询, 用户开了修正就想看实时封单。
|
|
qc.invalidateQueries({ queryKey: ['limit-ladder'] })
|
|
qc.invalidateQueries({ queryKey: ['overview-market'] })
|
|
})
|
|
|
|
es.addEventListener('strategy_alert', (e: MessageEvent) => {
|
|
try {
|
|
const data = JSON.parse(e.data)
|
|
const alerts: StrategyAlertEvent[] = data.alerts || []
|
|
if (alerts.length > 0) {
|
|
handleAlerts(alerts)
|
|
// 实时刷新触发记录列表 + 监控中心徽标
|
|
qc.invalidateQueries({ queryKey: ['alerts'] })
|
|
qc.invalidateQueries({ queryKey: ['alerts-total'] })
|
|
}
|
|
} catch {
|
|
// 忽略解析错误
|
|
}
|
|
})
|
|
|
|
// 定时复盘流式进度: 后端到点生成时把 meta/delta/done 推来, 喂进 reviewStore
|
|
// 开着复盘页可看到「边生成边显示」, 切走再回来也能看到生成中/已生成
|
|
es.addEventListener('review_progress', (e: MessageEvent) => {
|
|
try {
|
|
const evt = JSON.parse(e.data)
|
|
feedReviewEvent(evt)
|
|
// done(后端已归档) → 刷新历史列表, 让新报告出现并可查看
|
|
if (evt.type === 'done') {
|
|
qc.invalidateQueries({ queryKey: QK.reviewReports })
|
|
}
|
|
} catch {
|
|
// 忽略解析错误
|
|
}
|
|
})
|
|
|
|
es.onerror = () => {
|
|
es.close()
|
|
esRef.current = null
|
|
failCount += 1
|
|
_setStatus('reconnecting')
|
|
// 连续失败到阈值 → 弹一次 toast (漏行情=可能漏策略告警, 需明确告知)
|
|
if (failCount >= FAILS_BEFORE_TOAST && !toastFired) {
|
|
toastFired = true
|
|
toast('实时连接已断开,正在重连…', 'error')
|
|
}
|
|
// 指数退避 (base * 2^(n-1), 上限 60s), 替代原来固定 5s
|
|
const base = getQueryConfig().sse.reconnectDelay
|
|
const delay = Math.min(base * 2 ** (failCount - 1), BACKOFF_CAP_MS)
|
|
retryRef.current = setTimeout(connect, delay)
|
|
}
|
|
}
|
|
|
|
connect()
|
|
|
|
return () => {
|
|
clearTimeout(retryRef.current)
|
|
if (esRef.current) {
|
|
esRef.current.close()
|
|
esRef.current = null
|
|
}
|
|
_setStatus('disconnected')
|
|
}
|
|
}, [qc, handleAlerts])
|
|
}
|