Skip to content

Commit 481bdf3

Browse files
Ruicursoragent
andcommitted
fix(market): 右侧面板详情行情 stale 回退与补价稳定性
避免实时失败时基本行情闪空:Hub/Engine 增加缓存与 K 线合成回退,客户端按标的缓存快照并在 quote 缺失时 fresh 补价。 Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent d1dd9ce commit 481bdf3

14 files changed

Lines changed: 459 additions & 127 deletions

client-ui/src/api/client.ts

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -418,12 +418,16 @@ export const research = {
418418
})
419419
},
420420

421-
stockDetail: async (codeOrRef: string | InstrumentRef) => {
421+
stockDetail: async (codeOrRef: string | InstrumentRef, opts?: { fresh?: boolean }) => {
422422
const instrument = cnEquityRef(codeOrRef)
423423
const code = hubInstrumentCode(instrument)
424+
const body = {
425+
...hubInstrumentBody(instrument),
426+
...(opts?.fresh ? { fresh: true } : {}),
427+
}
424428
const resp = await postInstrument<StockDetailData | UnifiedInstrumentSnapshotDto>(
425429
'/instruments/snapshot',
426-
hubInstrumentBody(instrument),
430+
body,
427431
undefined,
428432
30000,
429433
)
@@ -453,6 +457,7 @@ export const research = {
453457

454458
etfSnapshot: async (
455459
instrument: InstrumentRef,
460+
opts?: { fresh?: boolean },
456461
signal?: AbortSignal,
457462
): Promise<
458463
import('../types/schemas').ApiResponse<import('../types/market').EtfSnapshotData>
@@ -464,9 +469,13 @@ export const research = {
464469
nav: null,
465470
quote: null,
466471
}
472+
const body = {
473+
...hubInstrumentBody(instrument),
474+
...(opts?.fresh ? { fresh: true } : {}),
475+
}
467476
const resp = await apiCall<
468477
import('../types/market').EtfSnapshotData | UnifiedInstrumentSnapshotDto
469-
>('etf_snapshot', hubInstrumentBody(instrument), { signal }, 20000)
478+
>('etf_snapshot', body, { signal }, 20000)
470479
if (resp.success && resp.data && isUnifiedSnapshot(resp.data)) {
471480
return toApiResponse('etf_snapshot', resp, fallback, unifiedSnapshotToEtfSnapshot(resp.data))
472481
}
@@ -597,8 +606,13 @@ export const research = {
597606
counts: { cn_stocks: number; cn_etfs: number; us: number; crypto: number }
598607
} }>('/instruments/summary'),
599608

600-
instrumentSnapshot: async (instrument: InstrumentRef, signal?: AbortSignal) => {
601-
const resp = await postInstrument<UnifiedInstrumentSnapshotDto>('/instruments/snapshot', hubInstrumentBody(instrument), signal)
609+
instrumentSnapshot: async (
610+
instrument: InstrumentRef,
611+
opts?: { fresh?: boolean },
612+
signal?: AbortSignal,
613+
) => {
614+
const body = { ...hubInstrumentBody(instrument), ...(opts?.fresh ? { fresh: true } : {}) }
615+
const resp = await postInstrument<UnifiedInstrumentSnapshotDto>('/instruments/snapshot', body, signal)
602616
if (resp.success && resp.data && isUnifiedSnapshot(resp.data)) {
603617
return {
604618
...resp,

client-ui/src/market/CrossMarketSnapshotDetail.tsx

Lines changed: 47 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
22
import { Spinner, Text, makeStyles, mergeClasses } from '@fluentui/react-components'
33
import { EditRegular } from '@fluentui/react-icons'
44
import { research } from '../api/client'
5+
import { quoteDtoToCrossMarket } from './instrument-adapters'
56
import type { CryptoSnapshotData, UsSnapshotData, WatchlistItem } from '../types/market'
67
import type { InstrumentRef } from '../types/instrument'
78
import {
@@ -25,6 +26,7 @@ import { hasApplicationCapability } from './capabilities'
2526
import { isWatchlistItemWithinQuoteGrace } from './watchlistQuotes'
2627
import TradingViewChart from './TradingViewChart'
2728
import { DETAIL_PANEL_CHART_MAX_HEIGHT_PX } from './chartViewConfig'
29+
import { mergeSnapshotPreserveQuote } from './detailSnapshotUtils'
2830
import { opptrixTokens, opptrixCssVars } from '../theme/tokens'
2931
import { ghostInteractive } from '../theme/mixins'
3032
import { listRowKey } from '../utils/listRowKey'
@@ -269,25 +271,33 @@ function MiniKline({
269271
)
270272
}
271273

272-
async function loadSnapshot(ref: InstrumentRef): Promise<EquityDetail | CryptoSnapshotData> {
274+
async function loadSnapshot(
275+
ref: InstrumentRef,
276+
opts?: { fresh?: boolean },
277+
): Promise<EquityDetail | CryptoSnapshotData> {
273278
if (!hasApplicationCapability(ref, 'snapshot')) {
274279
throw new Error('该标的暂不支持快照')
275280
}
276-
const resp = await research.instrumentSnapshot(ref)
281+
const resp = await research.instrumentSnapshot(ref, { fresh: opts?.fresh })
277282
if (!resp.success || !resp.data || typeof resp.data !== 'object') {
278283
throw new Error(resp.message || SNAPSHOT_LOAD_ERROR_COPY)
279284
}
280-
return resp.data as EquityDetail | CryptoSnapshotData
281-
}
282-
283-
/** 刷新成功但新 quote 为空时保留上一份,避免摘要闪没 */
284-
function mergeSnapshotPreserveQuote(
285-
prev: EquityDetail | CryptoSnapshotData | null,
286-
next: EquityDetail | CryptoSnapshotData,
287-
): EquityDetail | CryptoSnapshotData {
288-
if (next.quote != null) return next
289-
if (prev?.quote == null) return next
290-
return { ...next, quote: prev.quote }
285+
const data = resp.data as EquityDetail | CryptoSnapshotData
286+
if (!data.quote && (ref.market === 'US' || ref.market === 'HK')) {
287+
try {
288+
const quoteResp = await research.instrumentQuote(ref, { fresh: true })
289+
const q = quoteResp.success && quoteResp.data?.quote
290+
? quoteResp.data.quote
291+
: null
292+
if (q) {
293+
return {
294+
...data,
295+
quote: quoteDtoToCrossMarket(q),
296+
}
297+
}
298+
} catch { /* ignore */ }
299+
}
300+
return data
291301
}
292302

293303
function detailFootnote(ref: InstrumentRef, quote: { quoteSession?: string; sessionLabel?: string } | null): string {
@@ -346,34 +356,43 @@ export default function CrossMarketSnapshotDetail({
346356
const isCrypto = ref?.market === 'CRYPTO'
347357
const isEquity = ref?.market === 'US' || ref?.market === 'HK'
348358

349-
const [snapshot, setSnapshot] = useState<EquityDetail | CryptoSnapshotData | null>(null)
359+
const [snapshotByKey, setSnapshotByKey] = useState<Record<string, EquityDetail | CryptoSnapshotData>>({})
350360
const [fetching, setFetching] = useState(false)
351361
const [error, setError] = useState<string | null>(null)
352362
const [, setGraceTick] = useState(0)
353363
const loadSeqRef = useRef(0)
354-
const snapshotRef = useRef<EquityDetail | CryptoSnapshotData | null>(null)
355364
const initialRetryRef = useRef(0)
356-
snapshotRef.current = snapshot
365+
const freshLoadedRef = useRef<Set<string>>(new Set())
366+
const snapshotByKeyRef = useRef(snapshotByKey)
367+
snapshotByKeyRef.current = snapshotByKey
368+
const snapshot = snapshotByKey[instrumentIdentity] ?? null
357369

358-
const load = useCallback(async () => {
370+
const load = useCallback(async (opts?: { fresh?: boolean }) => {
359371
if (!ref) return
360372
const seq = ++loadSeqRef.current
361373
setFetching(true)
362374
let scheduleRetry = false
363375
try {
364-
const data = await loadSnapshot(ref)
376+
const data = await loadSnapshot(ref, { fresh: opts?.fresh })
365377
if (seq !== loadSeqRef.current) return
366-
setSnapshot(prev => mergeSnapshotPreserveQuote(prev, data))
378+
setSnapshotByKey(prev => {
379+
const prior = prev[instrumentIdentity] ?? null
380+
return {
381+
...prev,
382+
[instrumentIdentity]: mergeSnapshotPreserveQuote(prior, data),
383+
}
384+
})
367385
setError(null)
368386
initialRetryRef.current = 0
387+
if (opts?.fresh) freshLoadedRef.current.add(instrumentIdentity)
369388
} catch (e) {
370389
if (seq !== loadSeqRef.current) return
371-
const hadQuote = snapshotRef.current?.quote != null
390+
const hadQuote = snapshotByKeyRef.current[instrumentIdentity]?.quote != null
372391
if (!hadQuote && initialRetryRef.current < SNAPSHOT_INITIAL_MAX_RETRIES) {
373392
initialRetryRef.current += 1
374393
scheduleRetry = true
375394
window.setTimeout(() => {
376-
void load()
395+
void load({ fresh: true })
377396
}, SNAPSHOT_INITIAL_RETRY_MS * initialRetryRef.current)
378397
return
379398
}
@@ -383,22 +402,22 @@ export default function CrossMarketSnapshotDetail({
383402
setFetching(false)
384403
}
385404
}
386-
}, [ref])
405+
}, [ref, instrumentIdentity])
387406

388407
useEffect(() => {
389-
setSnapshot(null)
390-
setError(null)
391-
initialRetryRef.current = 0
392408
loadSeqRef.current += 1
409+
initialRetryRef.current = 0
410+
setError(null)
393411
}, [instrumentIdentity])
394412

395413
useEffect(() => {
396414
if (!ref) return undefined
397-
void load()
415+
const fresh = !freshLoadedRef.current.has(instrumentIdentity)
416+
void load({ fresh })
398417
const ms = isCrypto ? 30_000 : 90_000
399-
const timer = window.setInterval(() => { void load() }, ms)
418+
const timer = window.setInterval(() => { void load({ fresh: false }) }, ms)
400419
return () => window.clearInterval(timer)
401-
}, [load, isCrypto, instrumentIdentity])
420+
}, [load, isCrypto, instrumentIdentity, ref])
402421

403422
useEffect(() => {
404423
if (!isWatchlistItemWithinQuoteGrace(stock)) return undefined

client-ui/src/market/EtfDetailTab.tsx

Lines changed: 36 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useEffect, useMemo, useState } from 'react'
1+
import { useEffect, useMemo, useRef, useState } from 'react'
22
import { Spinner, Tab, TabList, Text, makeStyles, mergeClasses } from '@fluentui/react-components'
33
import { research } from '../api/client'
44
import type {
@@ -17,6 +17,8 @@ import {
1717
resolveDisplayStockName,
1818
} from './format'
1919
import { resolveWatchlistInstrument, watchlistItemKey, normalizeWatchlistItem } from './instrument'
20+
import { mergeDetailPreserveQuote } from './detailSnapshotUtils'
21+
import { patchEtfSnapshotQuoteIfMissing } from './detailQuoteFallback'
2022
import TradingViewChart from './TradingViewChart'
2123
import { DETAIL_PANEL_CHART_MAX_HEIGHT_PX } from './chartViewConfig'
2224
import EtfDecisionCard from './EtfDecisionCard'
@@ -302,7 +304,11 @@ function performanceRows(profile: EtfProfileData | null): Array<{ label: string;
302304
export default function EtfDetailTab({ stock }: Props) {
303305
const s = useStyles()
304306
const [tab, setTab] = useState<EtfTab>('overview')
305-
const [snapshot, setSnapshot] = useState<EtfSnapshotData | null>(null)
307+
const [snapshotByKey, setSnapshotByKey] = useState<Record<string, EtfSnapshotData>>({})
308+
const freshLoadedRef = useRef<Set<string>>(new Set())
309+
const loadSeqRef = useRef(0)
310+
const snapshotByKeyRef = useRef(snapshotByKey)
311+
snapshotByKeyRef.current = snapshotByKey
306312
const [navRows, setNavRows] = useState<EtfNavPoint[]>([])
307313
const [holdings, setHoldings] = useState<EtfHoldingRow[]>([])
308314
const [scorecard, setScorecard] = useState<EtfScorecardData | null>(null)
@@ -349,41 +355,54 @@ export default function EtfDetailTab({ stock }: Props) {
349355
})
350356
}
351357

358+
const snapshot = stockKey ? (snapshotByKey[stockKey] ?? null) : null
359+
352360
useEffect(() => {
353-
if (!chartInstrument) {
354-
setSnapshot(null)
355-
setNavRows([])
356-
setHoldings([])
357-
setScorecard(null)
361+
if (!chartInstrument || !stockKey) {
358362
setError('')
359363
setScorecardError('')
360364
return undefined
361365
}
362366
let cancelled = false
367+
const seq = ++loadSeqRef.current
368+
const fresh = !freshLoadedRef.current.has(stockKey)
363369
setTab('overview')
364370
setLoading(true)
365371
setError('')
366-
research.etfSnapshot(chartInstrument)
367-
.then(resp => {
368-
if (cancelled) return
372+
research.etfSnapshot(chartInstrument, { fresh })
373+
.then(async resp => {
374+
if (cancelled || seq !== loadSeqRef.current) return
369375
if (!resp.success || !resp.data) {
370-
setError(resp.message || '暂时无法加载 ETF 信息,请稍后再试')
371-
setSnapshot(null)
376+
if (!snapshotByKeyRef.current[stockKey]) {
377+
setError(resp.message || '暂时无法加载 ETF 信息,请稍后再试')
378+
}
372379
return
373380
}
374-
setSnapshot(resp.data)
381+
if (fresh) freshLoadedRef.current.add(stockKey)
382+
const patched = await patchEtfSnapshotQuoteIfMissing(chartInstrument, resp.data)
383+
if (cancelled || seq !== loadSeqRef.current) return
384+
setSnapshotByKey(prev => ({
385+
...prev,
386+
[stockKey]: mergeDetailPreserveQuote(prev[stockKey] ?? null, patched),
387+
}))
388+
setError('')
375389
})
376390
.catch(e => {
377-
if (!cancelled) {
391+
if (cancelled || seq !== loadSeqRef.current) return
392+
if (!snapshotByKeyRef.current[stockKey]) {
378393
setError(e instanceof Error ? e.message : '加载失败')
379-
setSnapshot(null)
380394
}
381395
})
382396
.finally(() => {
383-
if (!cancelled) setLoading(false)
397+
if (!cancelled && seq === loadSeqRef.current) setLoading(false)
384398
})
385399
return () => { cancelled = true }
386-
}, [chartInstrument])
400+
}, [chartInstrument, stockKey])
401+
402+
useEffect(() => {
403+
loadSeqRef.current += 1
404+
setError('')
405+
}, [stockKey])
387406

388407
useEffect(() => {
389408
if (!stockCode || tab !== 'decision') return undefined

0 commit comments

Comments
 (0)