mirror of
https://ghfast.top/https://github.com/aeroxw/tick-stock-panel.git
synced 2026-09-12 15:34:16 +08:00
分时图实时刷新: 日期修复 + 间隔可配 + 对话框实时 + 自选刷新图标 (#114)
* fix(kline): 分时图盘中显示昨天而非今天 get_minute_batch 的日期选择存在死循环: trade_date=date.today() 后立即 if trade_date==date.today() (恒真), 无条件用 latest_minute_date_global() 覆盖今天; 但批量实时补拉不落库, 该值盘中恒返回昨天 -> 永远回退到昨天 -> expected=240 判定昨日完整 -> 不触发今天补拉。 改为仅在真正非交易日回退 (周末 + 收盘后无今日日K 的节假日), 盘中保持 今天让 completeness 检查触发实时补拉。同时全量 date.today()/datetime.now() 改用北京时间 cn_today()/cn_now(), 与 quote_service/monitor 等一致。 影响: 自选列表分时列 + 单股详情分时图。 * feat(settings): 分时图刷新间隔可配置 (默认6s, 范围3-60s) 原写死 15s 轮询, 现可在 实时监控设置 -> 分时图刷新 卡片用滑块调节。 复用 minute_intraday_refresh 偏好链路, 新增 minute_intraday_refresh_interval 字段 (preferences + settings API + 前端 Preferences 接口)。 - preferences.py: getter 默认6s clamp[3,60] + set/get config 接入 - settings.py: RealtimeMonitorConfigIn 字段 + get_preferences 返回 - Monitoring.tsx: 滑块控件 (参考行情轮询滑块, 2s 防抖保存) - Watchlist/Screener: 15_000 -> interval*1000, fallback ?? 6 * feat(dialog): 个股对话框日K/分时实时刷新 日K走 SSE 精准刷新: 对话框打开时注册焦点股票, quotes_updated 推送时 invalidate ['kline', symbol], 后端 _maybe_inject_live_candle 只读内存 不调 TickFlow, 秒级零成本。关闭/切股自动注销焦点。 分时走轮询: 复用分时刷新开关 + 间隔偏好, 经 StockPanel.refetchIntervalMs 透传到 StockIntradayChart。分时端点数据不完整会调 TickFlow, 不接 SSE 避免打爆限流。 不影响回测弹窗 (不注册焦点、不传间隔)。 * feat(watchlist): 分时列表头加刷新图标 (与策略列表一致) 分时列可见 + 未自动刷新时显示手动刷新按钮 (点击 refetch, 加载旋转); 自动刷新中显示持续旋转小图标提示。逻辑/样式对齐 ScreenerTable。 * fix(badge): 真假涨停修正弹层被父级裁剪遮挡 弹出层原用 absolute 定位, 被看板卡片父级的 overflow-hidden + backdrop-blur containing block 裁剪/遮挡。改用 createPortal 渲染到 body, 基于徽章 getBoundingClientRect 计算弹层坐标, 脱离父级裁剪。 --------- Co-authored-by: shy3130 <shy3130@users.noreply.github.com>
This commit is contained in:
+47
-17
@@ -8,6 +8,7 @@ from typing import Optional
|
||||
from fastapi import APIRouter, HTTPException, Query, Request
|
||||
|
||||
from app.indicators.pipeline import compute_enriched, compute_enriched_single
|
||||
from app.market_time import cn_now, cn_today
|
||||
from app.services import kline_sync
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -405,17 +406,30 @@ def get_minute_batch(request: Request, body: dict):
|
||||
if not capset.has(Cap.KLINE_MINUTE_BATCH):
|
||||
raise HTTPException(status_code=403, detail="需要 Pro+ 权限 (kline.minute.batch)")
|
||||
|
||||
trade_date = date.fromisoformat(trade_date_str) if trade_date_str else date.today()
|
||||
trade_date = date.fromisoformat(trade_date_str) if trade_date_str else cn_today()
|
||||
|
||||
# 非交易日(周末/节假日)回退到最近有数据的交易日, 否则前端显示空白。
|
||||
# 优先用本地分钟K最近日期; 本地从未同步过分钟K时, 回退到日K最近交易日
|
||||
# (enriched 最新日一定有, 作为兜底), 确保 TickFlow 能拉到有效数据。
|
||||
if trade_date == date.today():
|
||||
recent_date = repo.latest_minute_date_global()
|
||||
if recent_date is None:
|
||||
recent_date = repo.latest_daily_date()
|
||||
if recent_date is not None:
|
||||
trade_date = recent_date
|
||||
# 非交易日(周末/节假日)才回退到最近有数据的交易日; 否则盘中会显示昨天而非今天。
|
||||
# 注意: 不能用 latest_minute_date_global() 判断盘中是否为交易日 —— 批量实时补拉
|
||||
# 不落库 (见下方 sync_minute_batch 无 on_segment), 盘中它恒返回上次全量同步日,
|
||||
# 用它做判据会导致 trade_date 永久回退到昨天, 再因 expected=240 判定昨日"完整"
|
||||
# 而不再补拉今天, 形成永远显示昨日的死循环。
|
||||
# 判据改为: 周末必回退; 工作日收盘后(>=15:30)仍无今日日K → 节假日, 回退。
|
||||
if not trade_date_str:
|
||||
today = cn_today()
|
||||
need_fallback = today.weekday() >= 5 # 周六/周日必非交易日
|
||||
if not need_fallback:
|
||||
now_cn = cn_now()
|
||||
after_close = now_cn.hour > 15 or (now_cn.hour == 15 and now_cn.minute >= 30)
|
||||
if after_close:
|
||||
latest_daily = repo.latest_daily_date()
|
||||
if latest_daily is None or latest_daily < today:
|
||||
need_fallback = True
|
||||
if need_fallback:
|
||||
recent_date = repo.latest_minute_date_global()
|
||||
if recent_date is None:
|
||||
recent_date = repo.latest_daily_date()
|
||||
if recent_date is not None:
|
||||
trade_date = recent_date
|
||||
|
||||
# Step 1: 本地优先 — 一次 scan 读全部 symbol 当日分钟K (股票 / ETF 分钟数据分开存储)
|
||||
etf_set = repo.get_etf_symbol_set()
|
||||
@@ -430,9 +444,9 @@ def get_minute_batch(request: Request, body: dict):
|
||||
df_local = pl.concat([df_local, df_etf], how="diagonal_relaxed")
|
||||
|
||||
# 期望条数 (盘中按当前时刻估算, 盘后 240)
|
||||
now = datetime.now()
|
||||
now = cn_now()
|
||||
h, m = now.hour, now.minute
|
||||
if trade_date != date.today():
|
||||
if trade_date != cn_today():
|
||||
expected = 240
|
||||
elif h < 9 or (h == 9 and m < 30):
|
||||
expected = 0
|
||||
@@ -496,10 +510,27 @@ def get_minute(
|
||||
stock_name = stock_info.get("name")
|
||||
|
||||
if trade_date is None:
|
||||
trade_date = repo.latest_minute_date(symbol, asset_type=asset_type)
|
||||
# 默认看今天, 而不是本地落盘的最近日 (盘中后者是昨天)。
|
||||
# 非交易日(周末/节假日)才回退到本地最近有数据的交易日。
|
||||
today = cn_today()
|
||||
need_fallback = today.weekday() >= 5 # 周六/周日必非交易日
|
||||
if not need_fallback:
|
||||
now_cn = cn_now()
|
||||
after_close = now_cn.hour > 15 or (now_cn.hour == 15 and now_cn.minute >= 30)
|
||||
if after_close:
|
||||
latest_daily = repo.latest_daily_date()
|
||||
if latest_daily is None or latest_daily < today:
|
||||
need_fallback = True
|
||||
if need_fallback:
|
||||
recent = repo.latest_minute_date(symbol, asset_type=asset_type)
|
||||
if recent is None:
|
||||
recent = repo.latest_daily_date()
|
||||
trade_date = recent if recent is not None else today
|
||||
else:
|
||||
trade_date = today
|
||||
if trade_date is None:
|
||||
# 本地无任何分钟K,尝试从 TickFlow 拉取当天
|
||||
trade_date = date.today()
|
||||
trade_date = cn_today()
|
||||
df = kline_sync.fetch_minute_single(symbol, trade_date)
|
||||
return {
|
||||
"symbol": symbol, "name": stock_name, "stock_info": stock_info,
|
||||
@@ -510,10 +541,9 @@ def get_minute(
|
||||
|
||||
# 完整交易日应有 240 条分钟K;如果是今天(盘中),期望条数按已交易分钟估算
|
||||
expected = 240
|
||||
today = date.today()
|
||||
today = cn_today()
|
||||
if trade_date == today:
|
||||
from datetime import datetime as _dt
|
||||
now = _dt.now()
|
||||
now = cn_now()
|
||||
h, m = now.hour, now.minute
|
||||
if h < 9 or (h == 9 and m < 30):
|
||||
expected = 0 # 还没开盘
|
||||
|
||||
@@ -429,6 +429,7 @@ def get_preferences() -> dict:
|
||||
"webhook_default_channels": preferences.get_webhook_default_channels(),
|
||||
"sidebar_index_symbols": preferences.get_sidebar_index_symbols(),
|
||||
"minute_intraday_refresh": preferences.get_minute_intraday_refresh(),
|
||||
"minute_intraday_refresh_interval": preferences.get_minute_intraday_refresh_interval(),
|
||||
"nav_order": preferences.get_nav_order(),
|
||||
"nav_hidden": preferences.get_nav_hidden(),
|
||||
"screener_auto_run": preferences.get_screener_auto_run(),
|
||||
@@ -754,6 +755,7 @@ class RealtimeMonitorConfigIn(BaseModel):
|
||||
sidebar_index_symbols: list[str] | None = None
|
||||
screener_auto_run: bool | None = None
|
||||
minute_intraday_refresh: bool | None = None
|
||||
minute_intraday_refresh_interval: int | None = None
|
||||
|
||||
|
||||
@router.put("/preferences/realtime-monitor")
|
||||
@@ -1311,7 +1313,12 @@ def run_limit_ladder_fix(request: Request) -> dict:
|
||||
depth_svc = getattr(request.app.state, "depth_service", None)
|
||||
if not depth_svc:
|
||||
raise HTTPException(status_code=503, detail="depth 服务未初始化")
|
||||
return depth_svc.run_once()
|
||||
result = depth_svc.run_once()
|
||||
# sealed 数据变了, 清看板总览缓存, 否则看板在 TTL 窗口内仍返回旧的 limit_up/fake 等
|
||||
if result.get("ok"):
|
||||
from app.api.overview import invalidate_overview_cache
|
||||
invalidate_overview_cache()
|
||||
return result
|
||||
|
||||
|
||||
class DepthPollingIntervalIn(BaseModel):
|
||||
|
||||
@@ -107,6 +107,18 @@ def get_minute_intraday_refresh() -> bool:
|
||||
return False
|
||||
|
||||
|
||||
# 分时图实时刷新间隔允许范围 (秒)。下限 3s, 上限 60s。
|
||||
_INTRADAY_REFRESH_INTERVAL_MIN = 3
|
||||
_INTRADAY_REFRESH_INTERVAL_MAX = 60
|
||||
|
||||
|
||||
def get_minute_intraday_refresh_interval() -> int:
|
||||
"""分时图实时刷新轮询间隔 (秒)。默认 6s, 范围 [3, 60]。"""
|
||||
return max(_INTRADAY_REFRESH_INTERVAL_MIN,
|
||||
min(_INTRADAY_REFRESH_INTERVAL_MAX,
|
||||
int(load().get("minute_intraday_refresh_interval", 6))))
|
||||
|
||||
|
||||
def get_minute_sync_days() -> int:
|
||||
return max(1, min(30, load().get("minute_sync_days", 5)))
|
||||
|
||||
@@ -628,6 +640,11 @@ def set_realtime_monitor_config(cfg: dict) -> dict:
|
||||
updates["screener_auto_run"] = bool(cfg["screener_auto_run"])
|
||||
if "minute_intraday_refresh" in cfg:
|
||||
updates["minute_intraday_refresh"] = bool(cfg["minute_intraday_refresh"])
|
||||
if "minute_intraday_refresh_interval" in cfg:
|
||||
# clamp 到 [5, 60], 与 getter 一致, 防前端传越界值
|
||||
updates["minute_intraday_refresh_interval"] = max(
|
||||
_INTRADAY_REFRESH_INTERVAL_MIN,
|
||||
min(_INTRADAY_REFRESH_INTERVAL_MAX, int(cfg["minute_intraday_refresh_interval"])))
|
||||
if updates:
|
||||
save(updates)
|
||||
return get_realtime_monitor_config()
|
||||
@@ -642,6 +659,7 @@ def get_realtime_monitor_config() -> dict:
|
||||
"sidebar_index_symbols": get_sidebar_index_symbols(),
|
||||
"screener_auto_run": get_screener_auto_run(),
|
||||
"minute_intraday_refresh": get_minute_intraday_refresh(),
|
||||
"minute_intraday_refresh_interval": get_minute_intraday_refresh_interval(),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from 'react'
|
||||
import { useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
@@ -54,6 +55,8 @@ export function SealedBadge({ degraded, hasDepth, isHistorical, sealedReady, sea
|
||||
invalidateKeys?: string[]
|
||||
}) {
|
||||
const [showHint, setShowHint] = useState(false)
|
||||
const [hintPos, setHintPos] = useState<{ left: number; top: number } | null>(null)
|
||||
const btnRef = useRef<HTMLButtonElement>(null)
|
||||
const navigate = useNavigate()
|
||||
const qc = useQueryClient()
|
||||
const runFix = useMutation({
|
||||
@@ -74,70 +77,91 @@ export function SealedBadge({ degraded, hasDepth, isHistorical, sealedReady, sea
|
||||
const label = degraded ? '降级' : '修正'
|
||||
|
||||
return (
|
||||
<div className="relative inline-flex items-center">
|
||||
<button
|
||||
onClick={() => setShowHint(v => !v)}
|
||||
className="group inline-flex items-center gap-1 h-5 px-2 rounded-full bg-yellow-500/10 border border-yellow-500/30 cursor-help transition-all hover:bg-yellow-500/20 hover:border-yellow-500/50"
|
||||
>
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-yellow-500" />
|
||||
<span className="text-[10px] font-medium text-yellow-600 dark:text-yellow-500 leading-none">{label}</span>
|
||||
<HelpCircle className="h-3 w-3 text-yellow-500/70 group-hover:text-yellow-500 transition-colors" />
|
||||
</button>
|
||||
<AnimatePresence>
|
||||
{showHint && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-40" onClick={() => setShowHint(false)} />
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -4, scale: 0.95 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: -4, scale: 0.95 }}
|
||||
className="absolute top-full left-0 mt-1 z-50 w-64 bg-surface border border-border rounded-md shadow-xl p-3 text-[11px] text-secondary leading-relaxed"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
{degraded ? (
|
||||
<>
|
||||
<div className="font-medium text-foreground mb-1.5">真假涨停判定降级</div>
|
||||
{reasons.map((r, i) => (
|
||||
<div key={i} className="flex gap-1 mb-1">
|
||||
<span className="text-yellow-500 shrink-0">·</span>
|
||||
<span>{r}</span>
|
||||
<>
|
||||
<div className="relative inline-flex items-center">
|
||||
<button
|
||||
ref={btnRef}
|
||||
onClick={() => {
|
||||
// 基于徽章位置算弹层坐标, 通过 portal 渲染到 body
|
||||
// 脱离父级 overflow-hidden 裁剪 + backdrop-blur 的 containing block
|
||||
const rect = btnRef.current?.getBoundingClientRect()
|
||||
if (rect) {
|
||||
const W = 256 // w-64 = 16rem = 256px
|
||||
const H = 240 // 预估高度 (降级原因/修正明细 + 按钮区)
|
||||
const left = Math.max(8, Math.min(rect.left, window.innerWidth - W - 8))
|
||||
const top = rect.bottom + H > window.innerHeight
|
||||
? Math.max(8, rect.top - H - 4)
|
||||
: rect.bottom + 4
|
||||
setHintPos({ left, top })
|
||||
}
|
||||
setShowHint(v => !v)
|
||||
}}
|
||||
className="group inline-flex items-center gap-1 h-5 px-2 rounded-full bg-yellow-500/10 border border-yellow-500/30 cursor-help transition-all hover:bg-yellow-500/20 hover:border-yellow-500/50"
|
||||
>
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-yellow-500" />
|
||||
<span className="text-[10px] font-medium text-yellow-600 dark:text-yellow-500 leading-none">{label}</span>
|
||||
<HelpCircle className="h-3 w-3 text-yellow-500/70 group-hover:text-yellow-500 transition-colors" />
|
||||
</button>
|
||||
</div>
|
||||
{createPortal(
|
||||
<AnimatePresence>
|
||||
{showHint && hintPos && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-40" onClick={() => setShowHint(false)} />
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -4, scale: 0.95 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: -4, scale: 0.95 }}
|
||||
style={{ left: hintPos.left, top: hintPos.top }}
|
||||
className="fixed z-50 w-64 bg-surface border border-border rounded-md shadow-xl p-3 text-[11px] text-secondary leading-relaxed"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
{degraded ? (
|
||||
<>
|
||||
<div className="font-medium text-foreground mb-1.5">真假涨停判定降级</div>
|
||||
{reasons.map((r, i) => (
|
||||
<div key={i} className="flex gap-1 mb-1">
|
||||
<span className="text-yellow-500 shrink-0">·</span>
|
||||
<span>{r}</span>
|
||||
</div>
|
||||
))}
|
||||
<div className="mt-1.5 pt-1.5 border-t border-border text-muted">
|
||||
真假板判定依赖五档盘口实时快照(卖一/买一量)。Pro+ 套餐的当天数据在收盘后自动恢复。
|
||||
</div>
|
||||
))}
|
||||
<div className="mt-1.5 pt-1.5 border-t border-border text-muted">
|
||||
真假板判定依赖五档盘口实时快照(卖一/买一量)。Pro+ 套餐的当天数据在收盘后自动恢复。
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="font-medium text-foreground mb-1.5">五档盘口修正结果</div>
|
||||
<SealedDirBlock title="涨停" color="bull" counts={sealedCountsUp} rawTotal={rawUp} />
|
||||
<SealedDirBlock title="跌停" color="bear" counts={sealedCountsDown} rawTotal={rawDown} />
|
||||
<div className="mt-1.5 pt-1.5 border-t border-border text-muted">
|
||||
真封板显示封单量,假涨停/假跌停已归入炸板/翘板视图。{sealedReady && '数据为盘中快照,收盘后自动定版。'}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="mt-2 flex gap-1.5">
|
||||
{hasDepth && !isHistorical && (
|
||||
<button
|
||||
onClick={() => { runFix.mutate(); setShowHint(false) }}
|
||||
disabled={runFix.isPending}
|
||||
className="flex-1 px-2 py-1.5 rounded text-[11px] bg-accent/15 text-accent hover:bg-accent/25 transition-colors text-center disabled:opacity-50"
|
||||
>
|
||||
{runFix.isPending ? '修正中…' : '立即修正'}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="font-medium text-foreground mb-1.5">五档盘口修正结果</div>
|
||||
<SealedDirBlock title="涨停" color="bull" counts={sealedCountsUp} rawTotal={rawUp} />
|
||||
<SealedDirBlock title="跌停" color="bear" counts={sealedCountsDown} rawTotal={rawDown} />
|
||||
<div className="mt-1.5 pt-1.5 border-t border-border text-muted">
|
||||
真封板显示封单量,假涨停/假跌停已归入炸板/翘板视图。{sealedReady && '数据为盘中快照,收盘后自动定版。'}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
onClick={() => { setShowHint(false); navigate('/settings?tab=monitoring&highlight=depth-fix') }}
|
||||
className={`${hasDepth && !isHistorical ? '' : 'w-full'} px-2 py-1.5 rounded text-[11px] bg-elevated text-secondary hover:text-foreground transition-colors text-center`}
|
||||
>
|
||||
去设置 →
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
<div className="mt-2 flex gap-1.5">
|
||||
{hasDepth && !isHistorical && (
|
||||
<button
|
||||
onClick={() => { runFix.mutate(); setShowHint(false) }}
|
||||
disabled={runFix.isPending}
|
||||
className="flex-1 px-2 py-1.5 rounded text-[11px] bg-accent/15 text-accent hover:bg-accent/25 transition-colors text-center disabled:opacity-50"
|
||||
>
|
||||
{runFix.isPending ? '修正中…' : '立即修正'}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => { setShowHint(false); navigate('/settings?tab=monitoring&highlight=depth-fix') }}
|
||||
className={`${hasDepth && !isHistorical ? '' : 'w-full'} px-2 py-1.5 rounded text-[11px] bg-elevated text-secondary hover:text-foreground transition-colors text-center`}
|
||||
>
|
||||
去设置 →
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>,
|
||||
document.body
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@ interface Props {
|
||||
prevClose?: number
|
||||
className?: string
|
||||
onPriceHover?: (price: number | null) => void
|
||||
/** 自动刷新间隔(ms)。undefined/0 = 不轮询(默认)。个股对话框盘中实时刷新时传入。 */
|
||||
refetchIntervalMs?: number
|
||||
}
|
||||
|
||||
export function StockIntradayChart({
|
||||
@@ -21,6 +23,7 @@ export function StockIntradayChart({
|
||||
prevClose,
|
||||
className,
|
||||
onPriceHover,
|
||||
refetchIntervalMs,
|
||||
}: Props) {
|
||||
const qc = useQueryClient()
|
||||
const [minuteDismissed, setMinuteDismissed] = useState(false)
|
||||
@@ -29,6 +32,7 @@ export function StockIntradayChart({
|
||||
queryKey: QK.klineMinute(symbol, date ?? ''),
|
||||
queryFn: () => api.klineMinute(symbol, date ?? undefined),
|
||||
enabled: !!symbol && !!date,
|
||||
refetchInterval: refetchIntervalMs,
|
||||
})
|
||||
|
||||
const fetchMinute = useMutation({
|
||||
|
||||
@@ -32,6 +32,8 @@ interface Props {
|
||||
/** 加自选 (传入后信息条显示 Star 图标) */
|
||||
inWatchlist?: boolean
|
||||
onToggleWatchlist?: () => void
|
||||
/** 分时图自动刷新间隔(ms)。undefined = 不轮询。个股对话框盘中实时刷新时传入。 */
|
||||
refetchIntervalMs?: number
|
||||
}
|
||||
|
||||
export { getDefaultRange }
|
||||
@@ -51,6 +53,7 @@ export function StockPanel({
|
||||
onMonitor,
|
||||
inWatchlist,
|
||||
onToggleWatchlist,
|
||||
refetchIntervalMs,
|
||||
}: Props) {
|
||||
const [linkedPrice, setLinkedPrice] = useState<number | null>(null)
|
||||
const [selectedDate, setSelectedDate] = useState<string | null>(null)
|
||||
@@ -158,6 +161,7 @@ export function StockPanel({
|
||||
prevClose={prevClose}
|
||||
onPriceHover={setLinkedPrice}
|
||||
className="flex-1 min-w-0 border-l border-border pl-3"
|
||||
refetchIntervalMs={refetchIntervalMs}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -8,6 +8,8 @@ import { cnSignal } from '@/lib/signals'
|
||||
import { StockPanel, getDefaultRange } from '@/components/StockPanel'
|
||||
import { DatePicker } from '@/components/DatePicker'
|
||||
import { RuleEditor } from '@/components/monitor/RuleEditor'
|
||||
import { usePreferences, useQuoteStatus } from '@/lib/useSharedQueries'
|
||||
import { setFocusSymbol, clearFocusSymbol } from '@/lib/useQuoteStream'
|
||||
|
||||
interface Props {
|
||||
symbol: string | null
|
||||
@@ -69,6 +71,25 @@ export function StockPreviewDialog({ symbol, name, onClose, triggerInfo }: Props
|
||||
return () => document.removeEventListener('keydown', handler)
|
||||
}, [symbol, onClose])
|
||||
|
||||
// 焦点股票注册: SSE quotes_updated 推送时精准 invalidate 当前股票日K,
|
||||
// 让对话框日K最后一根蜡烛随实时价变化 (后端只读内存, 不调 TickFlow)。
|
||||
// 关闭/切股时清除, 避免无谓刷新。
|
||||
useEffect(() => {
|
||||
if (!symbol) return
|
||||
setFocusSymbol(symbol)
|
||||
return () => clearFocusSymbol()
|
||||
}, [symbol])
|
||||
|
||||
// 分时图实时轮询: 复用自选列表的「分时刷新开关 + 间隔」偏好。
|
||||
// 仅实时行情运行 且 用户开启分时刷新时才轮询; 否则 undefined (定格)。
|
||||
const { data: prefs } = usePreferences()
|
||||
const { data: quoteStatus } = useQuoteStatus()
|
||||
const realtimeRunning = quoteStatus?.running ?? false
|
||||
const intradayRefreshOn = prefs?.minute_intraday_refresh ?? false
|
||||
const intradayRefetchMs = (intradayRefreshOn && realtimeRunning)
|
||||
? (prefs?.minute_intraday_refresh_interval ?? 6) * 1000
|
||||
: undefined
|
||||
|
||||
const handleRefresh = () => {
|
||||
if (!symbol) return
|
||||
qc.invalidateQueries({ queryKey: ['kline', symbol!] })
|
||||
@@ -241,6 +262,7 @@ export function StockPreviewDialog({ symbol, name, onClose, triggerInfo }: Props
|
||||
onMonitor={() => setShowMonitorEditor(true)}
|
||||
inWatchlist={inWatchlist}
|
||||
onToggleWatchlist={() => toggleWatchlist.mutate()}
|
||||
refetchIntervalMs={intradayRefetchMs}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -846,6 +846,7 @@ export interface Preferences {
|
||||
nav_hidden: string[]
|
||||
screener_auto_run: boolean
|
||||
minute_intraday_refresh: boolean
|
||||
minute_intraday_refresh_interval: number
|
||||
}
|
||||
export interface StrategyAlertEvent {
|
||||
source: 'strategy' | 'depth'
|
||||
@@ -1024,6 +1025,7 @@ export const api = {
|
||||
sidebar_index_symbols?: string[]
|
||||
screener_auto_run?: boolean
|
||||
minute_intraday_refresh?: boolean
|
||||
minute_intraday_refresh_interval?: number
|
||||
}) =>
|
||||
request<{
|
||||
sse_refresh_pages: Record<string, boolean>
|
||||
@@ -1032,6 +1034,7 @@ export const api = {
|
||||
sidebar_index_symbols: string[]
|
||||
screener_auto_run: boolean
|
||||
minute_intraday_refresh: boolean
|
||||
minute_intraday_refresh_interval: number
|
||||
}>('/api/settings/preferences/realtime-monitor', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(cfg),
|
||||
|
||||
@@ -46,6 +46,22 @@ export function useQuoteStreamStatus(): QuoteStreamStatus {
|
||||
return useSyncExternalStore(_subscribeStatus, _getStatus, () => 'disconnected' as const)
|
||||
}
|
||||
|
||||
// ===== 焦点股票注册表 (个股对话框用) =====
|
||||
// 个股对话框打开时注册当前 symbol, SSE quotes_updated 推送时精准 invalidate
|
||||
// 该 symbol 的日K查询 (['kline', symbol]), 让日K最后一根蜡烛随实时价变化。
|
||||
// 不加进 SSE_INVALIDATE_PREFIXES 全局列表 —— 避免回测弹窗等也每秒重拉。
|
||||
let _focusSymbol: string | null = null
|
||||
|
||||
/** 注册当前焦点股票 (个股对话框打开时调用)。 */
|
||||
export function setFocusSymbol(symbol: string): void {
|
||||
_focusSymbol = symbol
|
||||
}
|
||||
|
||||
/** 清除焦点股票 (个股对话框关闭时调用)。 */
|
||||
export function clearFocusSymbol(): void {
|
||||
_focusSymbol = null
|
||||
}
|
||||
|
||||
/**
|
||||
* 全局 SSE hook: 监听后端行情更新推送 + 策略监控通知。
|
||||
*
|
||||
@@ -138,6 +154,11 @@ export function useQuoteStream(
|
||||
),
|
||||
})
|
||||
}
|
||||
// 焦点股票日K精准刷新: 个股对话框打开时, 日K最后一根蜡烛随实时价变化。
|
||||
// 后端 _maybe_inject_live_candle 只读内存缓存, 不调 TickFlow, 秒级重拉零额外成本。
|
||||
if (_focusSymbol) {
|
||||
qc.invalidateQueries({ queryKey: ['kline', _focusSymbol] })
|
||||
}
|
||||
})
|
||||
|
||||
es.addEventListener('strategy_results_updated', () => {
|
||||
|
||||
@@ -364,12 +364,13 @@ export function Screener() {
|
||||
// 分时数据加载策略 (与自选页一致, 简洁优先):
|
||||
// - 全量加载当前列表 symbol, 但按套餐 batch 上限截断 (Pro=100 / Expert=200),
|
||||
// 超出时只取前 batch 只并提示用户, 避免一次性发太多请求打爆 rpm 配额
|
||||
// - 刷新: minute_intraday_refresh 偏好开启时盘中 15s 轮询; 否则仅首次加载,
|
||||
// - 刷新: minute_intraday_refresh 偏好开启时按用户设定间隔轮询; 否则仅首次加载,
|
||||
// 用户可点表头刷新按钮手动更新
|
||||
const minuteBatchCap = caps.data?.capabilities?.['kline.minute.batch']?.batch ?? 100
|
||||
const quoteStatus = useQuoteStatus()
|
||||
const realtimeRunning = quoteStatus.data?.running ?? false
|
||||
const intradayRefreshEnabled = prefs?.minute_intraday_refresh ?? false
|
||||
const intradayRefreshInterval = prefs?.minute_intraday_refresh_interval ?? 6
|
||||
|
||||
const allIntradaySymbols = useMemo(
|
||||
() => displayRows.map((r: any) => r.symbol),
|
||||
@@ -391,7 +392,7 @@ export function Screener() {
|
||||
enabled: intradayVisible && intradaySymbols.length > 0,
|
||||
staleTime: 10_000,
|
||||
// 仅当开启分时刷新偏好 且 盘中实时行情运行时 才轮询 (省 rpm)
|
||||
refetchInterval: (intradayRefreshEnabled && realtimeRunning) ? 15_000 : false,
|
||||
refetchInterval: (intradayRefreshEnabled && realtimeRunning) ? intradayRefreshInterval * 1000 : false,
|
||||
})
|
||||
const minuteData = intradayVisible ? (minuteBatch.data?.data ?? {}) : {}
|
||||
|
||||
|
||||
@@ -626,15 +626,16 @@ export function Watchlist() {
|
||||
|
||||
// 批量分时数据 (Pro+ 用户, 列可见时才拉)
|
||||
// 刷新策略: 仅当实时行情运行 且 用户在实时监控设置里开启 minute_intraday_refresh 时
|
||||
// 按 15s 轮询 (不接 SSE 高频, 避免每秒拉 TickFlow 触限流); 与 Screener / 设置卡片描述一致。
|
||||
// 按用户设定的间隔轮询 (不接 SSE 高频, 避免每秒拉 TickFlow 触限流); 与 Screener / 设置卡片描述一致。
|
||||
const { data: prefsData } = usePreferences()
|
||||
const intradayRefreshEnabled = prefsData?.minute_intraday_refresh ?? false
|
||||
const intradayRefreshInterval = prefsData?.minute_intraday_refresh_interval ?? 6
|
||||
const minuteBatch = useQuery({
|
||||
queryKey: QK.minuteBatch(symbolsKey),
|
||||
queryFn: () => api.klineMinuteBatch(symbols),
|
||||
enabled: intradayVisible && symbols.length > 0,
|
||||
staleTime: 10_000,
|
||||
refetchInterval: (intradayRefreshEnabled && realtimeRunning) ? 15_000 : false,
|
||||
refetchInterval: (intradayRefreshEnabled && realtimeRunning) ? intradayRefreshInterval * 1000 : false,
|
||||
})
|
||||
const minuteData = intradayVisible ? (minuteBatch.data?.data ?? {}) : {}
|
||||
|
||||
@@ -1063,6 +1064,7 @@ export function Watchlist() {
|
||||
)
|
||||
}
|
||||
if (col.source.type === 'builtin' && col.source.key === 'intraday') {
|
||||
const intradayAutoRefresh = intradayRefreshEnabled && realtimeRunning
|
||||
return (
|
||||
<span className="inline-flex items-center justify-center gap-1.5">
|
||||
<span>{col.label}</span>
|
||||
@@ -1079,6 +1081,23 @@ export function Watchlist() {
|
||||
>
|
||||
{intradayChartVisible ? <Eye className="h-3.5 w-3.5" /> : <EyeOff className="h-3.5 w-3.5" />}
|
||||
</button>
|
||||
{/* 分时图显示 且 未开自动轮询时, 提供手动刷新按钮 */}
|
||||
{intradayChartVisible && !intradayAutoRefresh && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => { event.stopPropagation(); minuteBatch.refetch() }}
|
||||
disabled={minuteBatch.isFetching}
|
||||
className="inline-flex items-center justify-center w-5 h-5 rounded text-muted hover:text-accent hover:bg-accent/10 transition-colors disabled:opacity-40"
|
||||
title="刷新分时数据"
|
||||
aria-label="刷新分时数据"
|
||||
>
|
||||
<RefreshCw className={`h-3.5 w-3.5 ${minuteBatch.isFetching ? 'animate-spin' : ''}`} />
|
||||
</button>
|
||||
)}
|
||||
{/* 自动轮询中: 显示旋转图标提示正在实时刷新 */}
|
||||
{intradayChartVisible && intradayAutoRefresh && (
|
||||
<RefreshCw className="h-3 w-3 text-accent/60 animate-spin" aria-label="实时刷新中" />
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -51,6 +51,10 @@ export function SettingsMonitoringPanel({ highlight }: { highlight?: string } =
|
||||
const isNoneTier = tier < 0
|
||||
const isFreeTier = tier === 0
|
||||
const realtimeEnabled = prefs?.realtime_quotes_enabled ?? false
|
||||
// 分时图实时刷新间隔 (秒), 与后端 [3,60] clamp 对齐; 默认 6
|
||||
const intradayInterval = prefs?.minute_intraday_refresh_interval ?? 6
|
||||
// 滑块本地草稿: 拖动时即时反馈, 停顿 2s 后落库 (与行情轮询滑块一致)
|
||||
const [intradayIntervalDraft, setIntradayIntervalDraft] = useState(intradayInterval)
|
||||
const refreshPages = prefs?.sse_refresh_pages ?? {}
|
||||
const limitLadderMonitor = prefs?.limit_ladder_monitor_enabled ?? false
|
||||
const hasDepth = !!caps?.capabilities?.['depth5.batch']
|
||||
@@ -245,6 +249,20 @@ export function SettingsMonitoringPanel({ highlight }: { highlight?: string } =
|
||||
return () => window.clearTimeout(t)
|
||||
}, [intervalDraft, interval, updateInterval])
|
||||
|
||||
// 分时刷新间隔: 服务端值变化时同步本地草稿
|
||||
useEffect(() => {
|
||||
setIntradayIntervalDraft(intradayInterval)
|
||||
}, [intradayInterval])
|
||||
|
||||
// 分时刷新间隔: 草稿与已保存值不同时, 2s 防抖落库
|
||||
useEffect(() => {
|
||||
if (intradayIntervalDraft === intradayInterval) return
|
||||
const t = window.setTimeout(() => {
|
||||
save({ minute_intraday_refresh_interval: intradayIntervalDraft })
|
||||
}, 2000)
|
||||
return () => window.clearTimeout(t)
|
||||
}, [intradayIntervalDraft, intradayInterval, save])
|
||||
|
||||
// highlight=depth-fix 时闪烁高亮连板梯队修正卡片
|
||||
const [flash, setFlash] = useState(false)
|
||||
const flashedRef = useRef(false)
|
||||
@@ -387,14 +405,41 @@ export function SettingsMonitoringPanel({ highlight }: { highlight?: string } =
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 自选列表分时图实时刷新 (默认关闭, 开启后盘中 15s 轮询刷新分时数据) */}
|
||||
{/* 自选列表分时图实时刷新 (默认关闭, 开启后盘中按设定间隔轮询刷新分时数据) */}
|
||||
<Card icon={Activity} title="分时图刷新">
|
||||
<ToggleRow
|
||||
label="自选/策略分时图实时刷新"
|
||||
desc="开启后自选与策略列表的分时图盘中每 15 秒自动刷新(需 Pro+ 权限 + 实时行情运行)。关闭时仅打开页面时拉取一次, 可点表头刷新按钮手动更新。"
|
||||
desc={`开启后自选与策略列表的分时图盘中每 ${intradayInterval} 秒自动刷新(需 Pro+ 权限 + 实时行情运行)。关闭时仅打开页面时拉取一次, 可点表头刷新按钮手动更新。`}
|
||||
checked={prefs?.minute_intraday_refresh ?? false}
|
||||
onChange={(v) => save({ minute_intraday_refresh: v })}
|
||||
/>
|
||||
<div className="mt-3 pt-3 border-t border-border">
|
||||
<div className="flex items-center justify-between gap-4 py-1">
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm text-foreground">刷新间隔</div>
|
||||
<div className="text-[11px] text-muted">
|
||||
间隔越短更新越及时, 但越耗数据源配额 (rpm)
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-[11px] font-mono text-foreground shrink-0 tabular-nums">
|
||||
{intradayIntervalDraft}s
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mt-2">
|
||||
<input
|
||||
type="range"
|
||||
min={3}
|
||||
max={60}
|
||||
step={1}
|
||||
value={intradayIntervalDraft}
|
||||
onChange={(e) => setIntradayIntervalDraft(parseInt(e.target.value, 10))}
|
||||
className="flex-1 h-1 accent-accent cursor-pointer"
|
||||
/>
|
||||
<span className="text-[10px] text-muted shrink-0">
|
||||
{intradayIntervalDraft !== intradayInterval ? '2秒后保存' : '3s — 60s'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{!isFreeTier && (
|
||||
|
||||
Reference in New Issue
Block a user