perf(intraday): 分时链路增量传输 + SSE 续画 + 视口感知

- minute-batch 支持 since 增量响应: 只回 >= since 的K (含动态最后一根),
  客户端按 (symbol, datetime) upsert 合并, 轮询流量从全天序列降到每轮一两根
- 自选分时 SSE 续画: enriched tick 前端续写分钟序列最后一根/滚动追加新K
  (纯视图叠加不写缓存, 服务端定版值始终权威); tick 新鲜时轮询降为 30s 兜底
- 视口感知: 从虚拟器直读渲染中 symbol 集合 (可见+overscan), 数据层只拉视口内;
  since 只按本轮请求集合取最旧尾部, 视口外落后不放大窗口; 集合变化 300ms
  防抖补拉; 剔除指数保持端点契约
- since 过滤专项测试: 含 since 当根 / 早于全部K / 无 since 三态
This commit is contained in:
shy3130
2026-08-31 21:45:06 +08:00
parent 869c49bb0b
commit 320969497a
6 changed files with 207 additions and 10 deletions
+22 -1
View File
@@ -583,6 +583,15 @@ def get_minute_batch(request: Request, body: dict):
symbols: list[str] = body.get("symbols", [])
trade_date_str: str | None = body.get("date")
# 增量响应: since (ISO datetime) 之前的K不回传, 客户端本地缓存合并。
# since 应传客户端已持有的最后一根时间 — 形成中的动态K >= since, 每轮覆盖。
since_str = body.get("since")
since_dt: datetime | None = None
if since_str:
try:
since_dt = datetime.fromisoformat(str(since_str))
except ValueError:
since_dt = None
# 自选分时本地优先标志: 全量分钟服务健康时, 股票缺口不再批量补拉
# (本地分区由服务按间隔持续写入, 下一轮自然补全; 停牌/临停票补拉也是空, 无损)。
# ETF 不在全量分钟 universe 内, 恒走补拉。服务不健康时回落现状补拉兜底。
@@ -757,8 +766,20 @@ def get_minute_batch(request: Request, body: dict):
if sym not in result:
result[sym] = live.to_dicts()
# since 增量过滤: 只回 >= since 的K (含动态最后一根), 无新增的 symbol 不回
if since_dt is not None:
result = {
sym: [r for r in rows if r["datetime"] >= since_dt]
for sym, rows in result.items()
}
result = {sym: rows for sym, rows in result.items() if rows}
# full_minute_local: 本轮 prefer_local 生效 (本地分区由全量分钟服务供给, 股票未做补拉)
return {"data": result, "full_minute_local": full_minute_healthy}
return {
"data": result,
"full_minute_local": full_minute_healthy,
"incremental": since_dt is not None,
}
@router.get("/minute-range")
+28
View File
@@ -413,6 +413,34 @@ def test_minute_batch_fresh_local_skips_pull(monkeypatch):
assert len(result["data"]["600519.SH"]) == 240
def test_minute_batch_since_returns_only_new_bars(monkeypatch):
"""since 增量响应: 只回 >= since 的K (含 since 本身 — 形成中的动态K需覆盖),
无新增的 symbol 不出现在 data; incremental 标志为 True。"""
from app.api import kline as kline_api
dts = ([datetime(2026, 1, 15, 9, 31) + timedelta(minutes=i) for i in range(120)]
+ [datetime(2026, 1, 15, 13, 1) + timedelta(minutes=i) for i in range(120)])
local = _bars("600519.SH", dts) # fresh: 不触发补拉, 直读本地后做 since 过滤
_, _, req = _endpoint_mocks(monkeypatch, local, sync_ret=pl.DataFrame())
result = kline_api.get_minute_batch(req, {
"symbols": ["600519.SH"], "date": "2026-01-15",
"since": "2026-01-15T15:00:00",
})
rows = result["data"]["600519.SH"]
assert [r["datetime"] for r in rows] == [datetime(2026, 1, 15, 15, 0)] # 含 since 当根
assert result["incremental"] is True
# since 早于全部K → 全量返回; 无 since → incremental False
full = kline_api.get_minute_batch(req, {
"symbols": ["600519.SH"], "date": "2026-01-15", "since": "2026-01-15T09:00:00",
})
assert len(full["data"]["600519.SH"]) == 240
plain = kline_api.get_minute_batch(req, {"symbols": ["600519.SH"], "date": "2026-01-15"})
assert plain["incremental"] is False
assert len(plain["data"]["600519.SH"]) == 240
# ---------- 测试 10: sync_minute_batch 自定义源成功时调 on_segment (Issue 1) ----------
def test_sync_minute_batch_custom_calls_on_segment(monkeypatch):
+3 -3
View File
@@ -2052,10 +2052,10 @@ export const api = {
method: 'POST',
body: JSON.stringify({ symbols, days }),
}),
klineMinuteBatch: (symbols: string[], date?: string, preferLocal?: boolean) =>
request<{ data: Record<string, MinuteKlineRow[]>; full_minute_local?: boolean }>('/api/kline/minute-batch', {
klineMinuteBatch: (symbols: string[], date?: string, preferLocal?: boolean, since?: string) =>
request<{ data: Record<string, MinuteKlineRow[]>; full_minute_local?: boolean; incremental?: boolean }>('/api/kline/minute-batch', {
method: 'POST',
body: JSON.stringify({ symbols, date, ...(preferLocal ? { prefer_local: true } : {}) }),
body: JSON.stringify({ symbols, date, ...(preferLocal ? { prefer_local: true } : {}), ...(since ? { since } : {}) }),
}),
instrumentSearch: (q: string, limit = 20, assetTypes?: string) =>
request<{ results: { symbol: string; name: string; code: string; asset_type?: string }[] }>(
@@ -0,0 +1,53 @@
import type { QueryClient } from '@tanstack/react-query'
import { api, type MinuteKlineRow } from '@/lib/api'
// 分钟批量分时的增量轮询助手:
// 后端 /api/kline/minute-batch 已支持 since 增量 (只回 >= since 的K, 含形成中
// 动态最后一根)。这里在 queryFn 内读取 react-query 缓存里的上一轮全量序列,
// 以"各 symbol 最后一根的最旧时间"为 since 请求增量, 并按 (symbol, datetime)
// upsert 合并后返回 — 组件层拿到的仍是完整序列, 代码零改动。
// 缓存不存在 (首次/换标的池/换日) 时不带 since, 全量拉取。
type MinuteBatchData = Record<string, MinuteKlineRow[]>
function lastBarTs(data: MinuteBatchData, symbols?: string[]): number | null {
// since 只按本轮请求的 symbol 取最旧最后一根: 视口感知下不可见 symbol 可能
// 落后很多分钟, 把它们计入会把整个批量窗口拉大重拉
const scope = symbols ? new Set(symbols) : null
let min: number | null = null
for (const [sym, rows] of Object.entries(data)) {
if (scope && !scope.has(sym)) continue
const last = rows[rows.length - 1]
if (!last) continue
const t = new Date(last.datetime).getTime()
if (Number.isFinite(t) && (min === null || t < min)) min = t
}
return min
}
function mergeInto(base: MinuteBatchData, patch: MinuteBatchData): MinuteBatchData {
const merged: MinuteBatchData = { ...base }
for (const [sym, rows] of Object.entries(patch)) {
const cache = merged[sym] ?? []
const byTs = new Map(cache.map(r => [r.datetime, r]))
for (const r of rows) byTs.set(r.datetime, r) // 新值覆盖 (动态K定版)
merged[sym] = Array.from(byTs.values())
.sort((a, b) => (a.datetime < b.datetime ? -1 : a.datetime > b.datetime ? 1 : 0))
}
return merged
}
/** queryFn 用: 读缓存 → 增量请求 → 合并返回 { data: 完整序列 } (保持端点响应形状, 下游零改动) */
export async function fetchMinuteBatchIncremental(
qc: QueryClient,
cacheKey: readonly unknown[],
symbols: string[],
preferLocal?: boolean,
): Promise<{ data: MinuteBatchData }> {
const prev = qc.getQueryData<{ data: MinuteBatchData }>(cacheKey)?.data
const minTs = prev ? lastBarTs(prev, symbols) : null
const since = minTs !== null ? new Date(minTs).toISOString() : undefined
const resp = await api.klineMinuteBatch(symbols, undefined, preferLocal, since)
if (!since || !resp.incremental) return { data: resp.data ?? {} }
return { data: mergeInto(prev ?? {}, resp.data ?? {}) }
}
+3 -1
View File
@@ -3,6 +3,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { motion } from 'framer-motion'
import { ScanSearch, Clock, TrendingUp, Star, Filter, Layers, Network, Sparkles, RefreshCw, Settings2, Store, RotateCcw, X } from 'lucide-react'
import { api, genRuleId, type ScreenerStrategy, type ScreenerResult } from '@/lib/api'
import { fetchMinuteBatchIncremental } from '@/lib/minuteBatchIncremental'
import { DEFAULT_STRATEGY_NOTIFY_EVENTS } from '@/lib/strategyMonitorEvents'
import { toast } from '@/components/Toast'
import { useDataStatus, usePreferences, useCapabilities, useQuoteStatus } from '@/lib/useSharedQueries'
@@ -460,7 +461,8 @@ export function Screener() {
const minuteBatch = useQuery({
queryKey: QK.minuteBatch(intradaySymbolsKey),
queryFn: () => api.klineMinuteBatch(intradayRequestSymbols),
// 增量轮询: 读缓存以最后一根为 since 只拉新增, 本地合并为完整序列
queryFn: () => fetchMinuteBatchIncremental(qc, QK.minuteBatch(intradaySymbolsKey), intradayRequestSymbols),
enabled: intradayVisible && intradayRequestSymbols.length > 0,
staleTime: 10_000,
placeholderData: previousData => previousData,
+98 -5
View File
@@ -5,6 +5,7 @@ import { useVirtualizer } from '@tanstack/react-virtual'
import { motion, AnimatePresence } from 'framer-motion'
import { Trash2, RefreshCw, Star, X, Search, LayoutGrid, List, Rows3, BarChart3, Settings2, Plus, Check, Filter, Eye, EyeOff, Minus, ChevronsUp, Clock, RotateCcw, ImagePlus, FolderOpen, FolderMinus, FolderPlus } from 'lucide-react'
import { api, type KlineRow, type MinuteKlineRow, type WatchlistGroup, type WatchlistGroupColor } from '@/lib/api'
import { fetchMinuteBatchIncremental } from '@/lib/minuteBatchIncremental'
import { QK } from '@/lib/queryKeys'
import { storage } from '@/lib/storage'
import { fmtPrice, fmtPct, fmtBigNum, priceColorClass, formatExtNumber } from '@/lib/format'
@@ -878,16 +879,74 @@ export function Watchlist() {
const { data: prefsData } = usePreferences()
const intradayRefreshEnabled = prefsData?.minute_intraday_refresh ?? false
const intradayRefreshInterval = prefsData?.minute_intraday_refresh_interval ?? 6
// 视口感知: 虚拟器渲染中的 symbol 集合 (可见 + overscan 缓冲), 由下方虚拟器
// 区域的 effect 写入; null = 未就绪/非虚拟化视图, 沿用全列表。首轮全量播种
// 缓存 (queryFn 在挂载时先于 ref 填充执行), 之后各轮只拉视口集合。
const minuteRequestSymbolsRef = useRef<string[] | null>(null)
const minuteBatch = useQuery({
queryKey: QK.minuteBatch(minuteSymbolsKey),
// prefer_local: 全量分钟服务健康时股票缺口不再批量补拉 (本地分区由服务持续写入),
// 大自选(数百上千只)不再持续打批量分钟接口
queryFn: () => api.klineMinuteBatch(minuteSymbols, undefined, true),
// 增量轮询 (prefer_local): 读缓存以最后一根为 since 只拉新增, 本地合并为
// 完整序列; 全量分钟健康时服务端零补拉, 不健康时也只增量。
// 请求集合 = 视口内 symbol (缓存按 symbol upsert, 视口外保留旧序列)
queryFn: () => {
const reqSymbols = minuteRequestSymbolsRef.current ?? minuteSymbols
return fetchMinuteBatchIncremental(qc, QK.minuteBatch(minuteSymbolsKey), reqSymbols, true)
},
enabled: intradayVisible && minuteSymbols.length > 0 && !groupCardsOpen,
staleTime: 10_000,
refetchInterval: (intradayRefreshEnabled && realtimeRunning) ? intradayRefreshInterval * 1000 : false,
// SSE tick 新鲜 (enriched 10s 内被行情推送刷新过) → 分时图已由下方续画本地
// 跳动, 轮询降为 30s 兜底校准; tick 断流时回到用户设定间隔
refetchInterval: () => {
if (!(intradayRefreshEnabled && realtimeRunning)) return false
const tickFresh = Date.now() - enriched.dataUpdatedAt < 10_000
return tickFresh ? 30_000 : intradayRefreshInterval * 1000
},
})
const minuteData = intradayVisible ? (minuteBatch.data?.data ?? {}) : {}
// 分时图 SSE 续画: enriched 行情列每 tick 刷新 (SSE 触发), 前端本地续写分钟序列
// 的最后一根 / 分钟滚动追加新K — 轮询间隔内分时图也随实时价跳动, 零额外请求。
// 纯视图叠加不写回缓存: 轮询拉回的服务端定版K始终是权威值, 覆盖续画值。
const minuteData = useMemo(() => {
const base: Record<string, MinuteKlineRow[]> = intradayVisible ? (minuteBatch.data?.data ?? {}) : {}
const liveRows = enriched.data?.rows
if (!intradayVisible || !liveRows?.length) return base
const liveBySymbol = new Map<string, any>(liveRows.map((r: any) => [r.symbol, r]))
// 仅连续竞价时段续画 (9:31-11:30, 13:01-15:00); 收盘后 rt_price 即收盘价无需续写
const now = new Date()
const hh = now.getHours(), mm = now.getMinutes()
const inSession =
(hh === 9 && mm >= 31) || hh === 10 ||
(hh === 11 && mm <= 30) || (hh === 13 && mm >= 1) || hh === 14
if (!inSession) return base
// 与服务端同构的 naive 北京时间戳 (手工拼本地时间, 不能用 toISOString — 那是 UTC)
const pad = (n: number) => String(n).padStart(2, '0')
const barTs = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}T${pad(hh)}:${pad(mm)}:00`
const patched: Record<string, MinuteKlineRow[]> = {}
for (const sym of Object.keys(base)) {
const arr = base[sym]
if (!Array.isArray(arr) || arr.length === 0) { patched[sym] = arr; continue }
const live = liveBySymbol.get(sym)
const price = live?.rt_price ?? live?.close
if (typeof price !== 'number' || !Number.isFinite(price)) { patched[sym] = arr; continue }
const last = arr[arr.length - 1]
if (last.datetime === barTs) {
patched[sym] = [...arr.slice(0, -1), {
...last,
close: price,
high: Math.max(last.high, price),
low: Math.min(last.low, price),
}]
} else if (barTs > last.datetime) {
patched[sym] = [...arr, {
datetime: barTs, open: price, high: price, low: price, close: price,
volume: 0, amount: 0, // 量/额由下一轮轮询定版覆盖
}]
} else {
patched[sym] = arr // 轮询数据已新于本地时钟 (钟差兜底), 不动
}
}
return patched
}, [intradayVisible, minuteBatch.data, enriched.data])
const addMutation = useMutation({
mutationFn: ({ symbol, groupId }: { symbol: string; groupId: string | null }) =>
@@ -1214,6 +1273,40 @@ export function Watchlist() {
scrollMargin: cardScrollMargin,
})
// 视口感知 (数据层): 从虚拟器派生"正在渲染的 symbol" (可见 + overscan 缓冲,
// 滚动前已就绪)。非虚拟化视图 (小列表/表格/分组) 为 null → 沿用全列表。
// 每次渲染直读 getVirtualItems (滚动不换 deps, 不能 useMemo), 副作用集中在 effect。
const visibleCardSymbols = (() => {
if (!virtualizeCards) return null
// 与 minuteSymbols 同口径: 剔除指数 (minute-batch 契约只收股票/ETF)
const scope = new Set(minuteSymbols as string[])
const items = cardRowVirtualizer.getVirtualItems()
const out: string[] = []
for (const item of items) {
for (let i = item.index * cardColumns; i < (item.index + 1) * cardColumns && i < sortedRows.length; i++) {
const s = (sortedRows[i] as any)?.symbol
if (typeof s === 'string' && scope.has(s)) out.push(s)
}
}
return out.length ? out : null
})()
const minuteVisibleKey = visibleCardSymbols?.join(',') ?? ''
const lastVisibleKeyRef = useRef<string | null>(null)
useEffect(() => {
minuteRequestSymbolsRef.current = visibleCardSymbols
if (!minuteVisibleKey) return
if (lastVisibleKeyRef.current === minuteVisibleKey) return
const prevKey = lastVisibleKeyRef.current
lastVisibleKeyRef.current = minuteVisibleKey
if (prevKey === null) return // 首次: 挂载播种轮已按全列表发出, 不额外触发
// 视口集合变化 (滚动到新区段) → 防抖 300ms 补拉一次, 新滚入的 symbol 即时就绪
const t = setTimeout(() => {
qc.invalidateQueries({ queryKey: QK.minuteBatch(minuteSymbolsKey) })
}, 300)
return () => clearTimeout(t)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [minuteVisibleKey, minuteSymbolsKey])
// 可见的 ext 列(卡片视图使用)
const visibleExtCols = useMemo(
() => visibleColumns.filter(c => c.source.type === 'ext'),