diff --git a/frontend/src/components/StockIntradayChart.tsx b/frontend/src/components/StockIntradayChart.tsx index ba6aed9..839ce01 100644 --- a/frontend/src/components/StockIntradayChart.tsx +++ b/frontend/src/components/StockIntradayChart.tsx @@ -3,7 +3,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { Loader2 } from 'lucide-react' import { api, type MinuteKlineRow } from '@/lib/api' import { QK } from '@/lib/queryKeys' -import { klineMinuteQueryOptions } from '@/lib/kline' +import { klineMinuteQueryOptions, minuteRefetchInterval } from '@/lib/kline' import { EChartsIntraday } from '@/components/EChartsIntraday' interface Props { @@ -40,7 +40,7 @@ export function StockIntradayChart({ // 避免读到分钟增量落盘的上一轮本地分区; 历史日期后端自行忽略 live。 ...klineMinuteQueryOptions(symbol, date ?? undefined, refetchIntervalMs != null), enabled: !!symbol && !!date, - refetchInterval: query => query.state.data?.source === 'none' ? false : refetchIntervalMs, + refetchInterval: minuteRefetchInterval(refetchIntervalMs), }) const fetchMinute = useMutation({ diff --git a/frontend/src/components/StockMultiDayIntradayChart.tsx b/frontend/src/components/StockMultiDayIntradayChart.tsx index 6f71c85..a108798 100644 --- a/frontend/src/components/StockMultiDayIntradayChart.tsx +++ b/frontend/src/components/StockMultiDayIntradayChart.tsx @@ -2,7 +2,7 @@ import { useEffect, useMemo, useRef } from 'react' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { Download, Loader2, RefreshCw } from 'lucide-react' import { api, type MinuteKlineSession } from '@/lib/api' -import { klineMinuteQueryOptions, klineMinuteRangeQueryOptions } from '@/lib/kline' +import { klineMinuteQueryOptions, klineMinuteRangeQueryOptions, minuteRefetchInterval } from '@/lib/kline' import { toast } from '@/components/Toast' import { EChartsMultiDayIntraday } from '@/components/EChartsMultiDayIntraday' @@ -36,7 +36,7 @@ export function StockMultiDayIntradayChart({ // live: 当日盘中直接实时拉取, 不被分钟增量落盘的本地分区(≥60s一轮)拖慢 ...klineMinuteQueryOptions(symbol, undefined, true), enabled: !!symbol, - refetchInterval: refetchIntervalMs, + refetchInterval: minuteRefetchInterval(refetchIntervalMs), }) const sessions = useMemo(() => { diff --git a/frontend/src/lib/kline.ts b/frontend/src/lib/kline.ts index 6bc7ac1..8d5e430 100644 --- a/frontend/src/lib/kline.ts +++ b/frontend/src/lib/kline.ts @@ -70,6 +70,59 @@ export function klineMinuteQueryOptions(symbol: string, date?: string, live?: bo } } +/** 收盘分钟标签: 连续竞价全天最后一根分钟K的时间。 */ +const CLOSE_MINUTE = '15:00' + +/** 全天真正定版时刻: 15:00 收盘后仍可按收盘价成交 (盘后固定价) 至 15:30, + * 其间成交量/额仍可能变化 —— 与后端 quote_service 定版重试窗口终点 + * (15:30) 同口径; 盘后管道默认 15:35 在此之后启动。 */ +const MARKET_ALL_OVER = '15:30' + +/** 北京时间今日 YYYY-MM-DD (与后端 cn_today 同口径; 不用 UTC toISOString, 避免傍晚错日)。 */ +function cnToday(): string { + return new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Shanghai' }).format(new Date()) +} + +/** 北京时间此刻 'HH:MM'。 */ +function cnNowHHMM(): string { + return new Intl.DateTimeFormat('en-GB', { timeZone: 'Asia/Shanghai', hour: '2-digit', minute: '2-digit', hour12: false }).format(new Date()) +} + +/** 行 datetime ('YYYY-MM-DD HH:MM:SS' 北京墙钟契约) → 'HH:MM'; 非常规格式返回 ''。 */ +function rowMinute(r: { datetime?: string } | undefined): string { + const s = String(r?.datetime ?? '') + return s.length >= 16 ? s.slice(11, 16) : '' +} + +/** + * 分时轮询停表 — 数据不可变时停止, 消除无效请求 (周末/盘后/翻看历史日期)。 + * + * - 未传间隔 → 不轮询 (调用方默认关闭) + * - 无数据 (source=none) → 停 + * - 响应日期 < 北京今天 → 停: 历史日的本地分钟K不可变 (周末"最新"会被 + * 后端回退到上一交易日, 同样命中此规则) + * - 当日: 收盘根 (≥15:00 的K) 已出现 **且** 已过 15:30 → 停。 + * 完整性**只认收盘根, 不按根数** —— 实测本系统全天 241 根 (首根 09:30 + * 竞价根 + 15:00 收盘根), 按根数阈值会在第 240 根 (14:59) 到位时误停, + * 恰好错过 15:00 收盘根; 收盘根判据还天然兼容盘中缺根与部分交易日。 + * 而 15:00 收盘根出现后**不能立即停**: 盘后按收盘价成交可持续到 15:30, + * 其间量/额仍可能更新 (当前数据源分钟K止于 15:00, 此窗口为防御性保留, + * 与后端"15:30 才允许定版/重建"的边界一致)。 + * - 其余 (当日 15:30 前) → 按间隔继续 + */ +export function minuteRefetchInterval(ms?: number) { + return (query: any): number | false => { + if (ms == null) return false + const d = query.state.data + if (!d) return ms + if (d.source === 'none') return false + if (typeof d.date === 'string' && d.date < cnToday()) return false + const closeBarArrived = (d.rows ?? []).some((r: { datetime?: string }) => rowMinute(r) >= CLOSE_MINUTE) + if (closeBarArrived && cnNowHHMM() >= MARKET_ALL_OVER) return false + return ms + } +} + /** 多日分时查询配置 — 分时 tab 的 StockMultiDayIntradayChart 与 邻近预取 共用。 * 内嵌「仅同 symbol 占位」守卫 (key 结构 ['kline-minute-range', symbol, days], index 1 为 symbol), * 与 klineDailyQueryOptions 同源, 调用点不再各自手写。 */