perf(kline): update detail chart with latest row

This commit is contained in:
0112020179
2026-09-03 17:35:16 +08:00
parent 4cb30e48aa
commit 8721bd2e83
9 changed files with 263 additions and 44 deletions
+1 -4
View File
@@ -44,8 +44,6 @@ interface Props {
onPriceDoubleClick?: (price: number, currentPrice: number) => void
/** 扩展数据列参数(逗号分隔 config_id.field_name),透传给 klineDaily 接口 */
extColumns?: string
/** 日K自动刷新间隔(ms)。undefined = 不轮询(默认)。个股对话框实时刷新时传入, 盘中今日蜡烛随之更新 */
refetchIntervalMs?: number
}
function isValidRow(r: any): boolean {
@@ -121,7 +119,6 @@ export function StockDailyKChart({
onDateClick,
onPriceDoubleClick,
extColumns,
refetchIntervalMs,
}: Props) {
const [activeIndicators, setActiveIndicators] = useState<string[]>(['vol'])
const [showMarkers, setShowMarkers] = useState(true)
@@ -131,7 +128,7 @@ export function StockDailyKChart({
const dateRange = externalDateRange ?? getDefaultRange()
// 查询配置统一来自 klineDailyQueryOptions, 与 StockPanel 信息条/邻近预取共享同一 cache key (只发一次请求)
const kline = useQuery({ ...klineDailyQueryOptions(symbol, dateRange, extColumns), enabled: !!symbol, refetchInterval: refetchIntervalMs })
const kline = useQuery({ ...klineDailyQueryOptions(symbol, dateRange, extColumns), enabled: !!symbol })
const rows = useMemo(() => toOHLC(kline.data?.rows ?? []), [kline.data?.rows])
const stockInfo = kline.data?.stock_info
@@ -40,7 +40,7 @@ export function StockIntradayChart({
// 避免读到分钟增量落盘的上一轮本地分区; 历史日期后端自行忽略 live。
...klineMinuteQueryOptions(symbol, date ?? undefined, refetchIntervalMs != null),
enabled: !!symbol && !!date,
refetchInterval: refetchIntervalMs,
refetchInterval: query => query.state.data?.source === 'none' ? false : refetchIntervalMs,
})
const fetchMinute = useMutation({
-1
View File
@@ -215,7 +215,6 @@ export function StockPanel({
onPriceDoubleClick={onPriceDoubleClick}
visibleBars={showIntraday ? 40 : 60}
extColumns={extColumns}
refetchIntervalMs={refetchIntervalMs}
/>
{showIntraday && selectedDate && !intradayDismissed && (
+19 -7
View File
@@ -234,6 +234,20 @@ export interface KlineRow {
[key: string]: any
}
export interface KlineDailyResponse {
symbol: string
name?: string
stock_info?: { name?: string; total_shares?: number; float_shares?: number; ext?: Record<string, unknown> }
rows: KlineRow[]
source?: string
}
export interface KlineDailyLatestResponse {
symbol: string
row: KlineRow | null
source: 'live' | 'none'
}
// ===== Watchlist =====
export interface WatchlistEntry {
symbol: string
@@ -2082,18 +2096,16 @@ export const api = {
request<CapabilitiesResponse>('/api/capabilities/redetect', { method: 'POST' }),
klineDaily: (symbol: string, days = 120, dateRange?: { start: string; end: string }, extColumns?: string) =>
request<{
symbol: string
name?: string
stock_info?: { name?: string; total_shares?: number; float_shares?: number; ext?: Record<string, unknown> }
rows: KlineRow[]
source?: string
}>(
request<KlineDailyResponse>(
(dateRange
? `/api/kline/daily?symbol=${encodeURIComponent(symbol)}&start_date=${dateRange.start}&end_date=${dateRange.end}`
: `/api/kline/daily?symbol=${encodeURIComponent(symbol)}&days=${days}`)
+ (extColumns ? `&ext_columns=${encodeURIComponent(extColumns)}` : ''),
),
klineDailyLatest: (symbol: string) =>
request<KlineDailyLatestResponse>(
`/api/kline/daily/latest?symbol=${encodeURIComponent(symbol)}`,
),
klineDailyBatch: (symbols: string[], days = 12) =>
request<{ data: Record<string, KlineRow[]> }>('/api/kline/daily-batch', {
method: 'POST',
+29 -1
View File
@@ -7,7 +7,7 @@
* placeholderData 内置"仅同 symbol 占位"守卫: 改日期范围/扩展字段时旧数据可暂显(不闪),
* 切股时不透传上一只股票的数据(不误显示)。
*/
import { api } from '@/lib/api'
import { api, type KlineDailyLatestResponse, type KlineDailyResponse } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
/** 分时 tab 多日分时默认周期 (StockPanel 预取与弹窗存储回退共用, 避免魔数两处漂移) */
@@ -29,6 +29,34 @@ export function klineDailyQueryOptions(
}
}
export function klineDailyLatestQueryOptions(symbol: string) {
return {
queryKey: QK.klineLatest(symbol),
queryFn: () => api.klineDailyLatest(symbol),
}
}
export function mergeLatestKlineRow(
current: KlineDailyResponse | undefined,
latest: KlineDailyLatestResponse,
): KlineDailyResponse | undefined {
if (!current || !latest.row || current.symbol !== latest.symbol) return current
const latestDate = String(latest.row.date).slice(0, 10)
const last = current.rows.at(-1)
if (!last) return { ...current, rows: [{ ...latest.row, date: latestDate }] }
const lastDate = String(last.date).slice(0, 10)
if (latestDate < lastDate) return current
if (latestDate === lastDate) {
return {
...current,
rows: [...current.rows.slice(0, -1), { ...last, ...latest.row, date: latestDate }],
}
}
return { ...current, rows: [...current.rows, { ...latest.row, date: latestDate }] }
}
/**
* 单日分时查询配置 — 与 klineDailyQueryOptions 同风格的单源 options (date 为空 = 最新日内)。
*
+1
View File
@@ -79,6 +79,7 @@ export const QK = {
// Kline
kline: (symbol: string, start: string, end: string, extColumns?: string) =>
['kline', symbol, start, end, extColumns ?? ''] as const,
klineLatest: (symbol: string) => ['kline-latest', symbol] as const,
stockLevels: (symbol: string, days?: number) => ['stock-levels', symbol, days ?? 120] as const,
klineMinute: (symbol: string, date: string) =>
['kline-minute', symbol, date] as const,
+22 -6
View File
@@ -1,11 +1,12 @@
import { useEffect, useRef, useCallback, useSyncExternalStore } from 'react'
import { useQueryClient } from '@tanstack/react-query'
import { SSE_INVALIDATE_PREFIXES, QK } from './queryKeys'
import { klineDailyLatestQueryOptions, mergeLatestKlineRow } from './kline'
import { getQueryConfig } from './useQueryConfig'
import { toast } from '@/components/Toast'
import { pushAlertToasts } from '@/components/AlertToast'
import { feedReviewEvent } from './reviewStore'
import type { StrategyAlertEvent } from './api'
import type { KlineDailyResponse, StrategyAlertEvent } from './api'
// ===== 全局 SSE 连接状态 (模块级 store, 仿 AlertToast.tsx 模式) =====
// 实时行情 SSE 断开时 UI 无感知 → 会漏掉策略告警。这里暴露连接状态,
@@ -47,8 +48,8 @@ export function useQuoteStreamStatus(): QuoteStreamStatus {
}
// ===== 焦点股票注册表 (个股对话框用) =====
// 个股对话框打开时注册当前 symbol, SSE quotes_updated 推送时精准 invalidate
// 该 symbol 的日K查询 (['kline', symbol]), 让日K最后一根蜡烛随实时价变化
// 个股对话框打开时注册当前 symbol, SSE quotes_updated 推送时只取当日最新行,
// 再原位更新该 symbol 的日K查询缓存,避免重复下载整段历史
// 不加进 SSE_INVALIDATE_PREFIXES 全局列表 —— 避免回测弹窗等也每秒重拉。
let _focusSymbol: string | null = null
@@ -161,10 +162,25 @@ export function useQuoteStream(
),
})
}
// 焦点股票日K精准刷新: 个股对话框打开时, 日K最后一根蜡烛随实时价变化
// 后端 _maybe_inject_live_candle 只读内存缓存, 不调 TickFlow, 秒级重拉零额外成本。
// 焦点股票日K增量刷新: 只取内存中的当日行并合并缓存尾部
if (_focusSymbol) {
qc.invalidateQueries({ queryKey: ['kline', _focusSymbol] })
const symbol = _focusSymbol
void qc.fetchQuery({ ...klineDailyLatestQueryOptions(symbol), staleTime: 0 })
.then((latest) => {
if (_focusSymbol !== symbol || !latest.row) return
const latestDate = String(latest.row.date).slice(0, 10)
const queries = qc.getQueryCache().findAll({ queryKey: ['kline', symbol] })
for (const query of queries) {
const start = String(query.queryKey[2] ?? '')
const end = String(query.queryKey[3] ?? '')
if (latestDate < start || latestDate > end) continue
qc.setQueryData<KlineDailyResponse>(
query.queryKey,
current => mergeLatestKlineRow(current, latest),
)
}
})
.catch(() => {})
}
})