From 5cc6ccaaa9cc9a5468d9be151c6262208f29ca01 Mon Sep 17 00:00:00 2001 From: limichange Date: Mon, 17 Aug 2026 18:15:46 +0800 Subject: [PATCH 01/15] fix: handle sparse minute kline history OK-60116 --- .../data/useTradingViewNativeKLine.test.ts | 258 ++++++- .../data/useTradingViewNativeKLine.ts | 666 ++++++++++++++---- 2 files changed, 769 insertions(+), 155 deletions(-) diff --git a/packages/kit/src/components/TradingView/TradingViewNative/data/useTradingViewNativeKLine.test.ts b/packages/kit/src/components/TradingView/TradingViewNative/data/useTradingViewNativeKLine.test.ts index 14b37cea7489..294047957b86 100644 --- a/packages/kit/src/components/TradingView/TradingViewNative/data/useTradingViewNativeKLine.test.ts +++ b/packages/kit/src/components/TradingView/TradingViewNative/data/useTradingViewNativeKLine.test.ts @@ -717,20 +717,242 @@ describe('TradingViewNative K-line data state machine', () => { }); }); - it('does not paginate when the initial batch is shorter than requested', async () => { - mockHistoryBatchSize = 299; - mockHistoryRequestCandleCount = 2000; - mockFetchHistory.mockResolvedValue( - buildSequentialResponse({ count: 298, firstTimestamp: 1_000_000 }), - ); + it.each([ + ['1', 60], + ['5', 5 * 60], + ] as const)( + 'backfills %s-minute Market history after a sparse initial page', + async (activeInterval, intervalSeconds) => { + mockHistoryBatchSize = 299; + mockHistoryRequestCandleCount = 2000; + mockReadTradingViewNativeActiveInterval.mockReturnValue(activeInterval); + let activeIntervalRequestCount = 0; + const firstScan = + createDeferred(); + mockFetchHistory.mockImplementation( + async ({ interval, timeFrom, timeTo }) => { + if (interval.value === '1W') { + return buildResponse(50, 100_000); + } + if (interval.value === '1D') { + return buildResponse(60, 110_000); + } + + activeIntervalRequestCount += 1; + if (activeIntervalRequestCount === 1) { + return buildMultiPointResponse([ + { close: 100, timestamp: 1_000_000 }, + { close: 110, timestamp: 1_000_000 + intervalSeconds }, + ]); + } + if (activeIntervalRequestCount === 2) { + expect({ timeFrom, timeTo }).toEqual({ + timeFrom: 913_599, + timeTo: 999_999, + }); + return firstScan.promise; + } + expect({ timeFrom, timeTo }).toEqual({ + timeFrom: 827_198, + timeTo: 913_598, + }); + return buildMultiPointResponse( + Array.from({ length: 196 }, (_, index) => ({ + close: 90 + index, + timestamp: 830_000 + index * intervalSeconds, + })), + ); + }, + ); + const { result } = renderHook(() => + useTradingViewNativeKLine({ source: buildMarketSource() }), + ); + + await waitFor(() => expect(result.current.points).toHaveLength(2)); + act(() => firstScan.resolve({ points: [], total: 0 })); + await waitFor(() => expect(result.current.points).toHaveLength(198)); + expect(mockFetchHistory).toHaveBeenCalledTimes(5); + expect(result.current.points[0]?.t).toBe(830_000); + }, + ); + + it.each([ + ['1', 60], + ['5', 5 * 60], + ] as const)( + 'recovers %s-minute Market history across an empty time window', + async (activeInterval, intervalSeconds) => { + mockHistoryBatchSize = 2; + mockHistoryRequestCandleCount = 2; + mockReadTradingViewNativeActiveInterval.mockReturnValue(activeInterval); + jest.spyOn(Date, 'now').mockReturnValue(2_000_000_000); + let activeIntervalRequestCount = 0; + mockFetchHistory.mockImplementation(async ({ interval }) => { + if (interval.value === '1W') { + return buildResponse(50, 100_000); + } + if (interval.value === '1D') { + return buildResponse(60, 110_000); + } + + activeIntervalRequestCount += 1; + if (activeIntervalRequestCount === 1) { + return buildMultiPointResponse([ + { close: 100, timestamp: 1_000_000 }, + { close: 110, timestamp: 1_000_000 + intervalSeconds }, + ]); + } + if (activeIntervalRequestCount === 2) { + return { points: [], total: 0 }; + } + return buildMultiPointResponse( + Array.from({ length: 99 }, (_, index) => ({ + close: 70 + index, + timestamp: 920_100 + index * intervalSeconds, + })), + ); + }); + const { result } = renderHook(() => + useTradingViewNativeKLine({ source: buildMarketSource() }), + ); + + await waitFor(() => expect(result.current.points).toHaveLength(2)); + act(() => + result.current.handleVisiblePointRangeChange({ startIndex: 0 }), + ); + + await waitFor(() => expect(result.current.points).toHaveLength(101)); + expect(result.current.points[0]?.t).toBe(920_100); + const recoveryTimeTo = 999_999 - 99 * intervalSeconds - 1; + expect( + mockFetchHistory.mock.calls.filter( + ([request]) => request.interval.value === activeInterval, + )[2]?.[0], + ).toEqual( + expect.objectContaining({ + timeFrom: recoveryTimeTo - 86_400, + timeTo: recoveryTimeTo, + }), + ); + expect(result.current.calendarAvailableTimeRange).toEqual({ + from: 110_000, + }); + expect( + mockFetchHistory.mock.calls.filter( + ([request]) => request.interval.value === '1W', + ), + ).toHaveLength(1); + expect( + mockFetchHistory.mock.calls.filter( + ([request]) => request.interval.value === '1D', + ), + ).toHaveLength(1); + }, + ); + + it.each([ + ['1', 60], + ['5', 5 * 60], + ] as const)( + 'continues %s-minute Market load-more after a short non-empty page', + async (activeInterval, intervalSeconds) => { + mockHistoryBatchSize = 2; + mockHistoryRequestCandleCount = 2; + mockReadTradingViewNativeActiveInterval.mockReturnValue(activeInterval); + let activeIntervalRequestCount = 0; + mockFetchHistory.mockImplementation(async ({ interval }) => { + if (interval.value === '1W') { + return buildResponse(50, 100_000); + } + if (interval.value === '1D') { + return buildResponse(60, 110_000); + } + + activeIntervalRequestCount += 1; + if (activeIntervalRequestCount === 1) { + return buildMultiPointResponse([ + { close: 100, timestamp: 1_000_000 }, + { close: 110, timestamp: 1_000_000 + intervalSeconds }, + ]); + } + if (activeIntervalRequestCount === 2) { + return buildResponse(90, 995_000); + } + return buildMultiPointResponse( + Array.from({ length: 98 }, (_, index) => ({ + close: 70 + index, + timestamp: 920_100 + index * intervalSeconds, + })), + ); + }); + const { result } = renderHook(() => + useTradingViewNativeKLine({ source: buildMarketSource() }), + ); + + await waitFor(() => expect(result.current.points).toHaveLength(2)); + act(() => + result.current.handleVisiblePointRangeChange({ startIndex: 0 }), + ); + + await waitFor(() => expect(result.current.points).toHaveLength(101)); + expect(result.current.points[0]?.t).toBe(920_100); + const recoveryTimeTo = 999_999 - 99 * intervalSeconds - 1; + expect( + mockFetchHistory.mock.calls.filter( + ([request]) => request.interval.value === activeInterval, + )[2]?.[0], + ).toEqual( + expect.objectContaining({ + timeFrom: recoveryTimeTo - 86_400, + timeTo: recoveryTimeTo, + }), + ); + expect(result.current.points.some((point) => point.t === 995_000)).toBe( + true, + ); + expect(mockFetchHistory).toHaveBeenCalledTimes(5); + }, + ); + + it('stops sparse Market pagination at the refined daily boundary', async () => { + mockHistoryBatchSize = 2; + mockHistoryRequestCandleCount = 2; + mockReadTradingViewNativeActiveInterval.mockReturnValue('1'); + jest.spyOn(Date, 'now').mockReturnValue(2_000_000_000); + mockFetchHistory.mockImplementation(async ({ interval }) => { + if (interval.value === '1W') { + return buildResponse(50, 100_000); + } + if (interval.value === '1D') { + return buildResponse(60, 110_000); + } + if (interval.value === '1') { + return mockFetchHistory.mock.calls.filter( + ([request]) => request.interval.value === '1', + ).length === 1 + ? buildMultiPointResponse([ + { close: 100, timestamp: 110_000 }, + { close: 110, timestamp: 110_060 }, + ]) + : { points: [], total: 0 }; + } + return { points: [], total: 0 }; + }); const { result } = renderHook(() => useTradingViewNativeKLine({ source: buildMarketSource() }), ); - await waitFor(() => expect(result.current.points).toHaveLength(298)); + await waitFor(() => expect(result.current.points).toHaveLength(2)); act(() => result.current.handleVisiblePointRangeChange({ startIndex: 0 })); + await waitFor(() => + expect(result.current.calendarAvailableTimeRange).toEqual({ + from: 110_000, + }), + ); + expect(mockFetchHistory).toHaveBeenCalledTimes(4); - expect(mockFetchHistory).toHaveBeenCalledTimes(1); + act(() => result.current.handleVisiblePointRangeChange({ startIndex: 0 })); + expect(mockFetchHistory).toHaveBeenCalledTimes(4); }); it('does not paginate CoinGecko fallback data as Market history', async () => { @@ -3222,7 +3444,8 @@ describe('TradingViewNative K-line data state machine', () => { firstTimestamp: 7_850_800, startingClose: 400, }), - ); + ) + .mockResolvedValueOnce(buildResponse(399, 7_847_200)); const { result } = renderHook(() => useTradingViewNativeKLine({ source: buildMarketSource() }), ); @@ -3247,10 +3470,17 @@ describe('TradingViewNative K-line data state machine', () => { ); await waitFor(() => expect(result.current.points).toHaveLength(896)); act(() => result.current.handleVisiblePointRangeChange({ startIndex: 0 })); - expect(mockFetchHistory).toHaveBeenCalledTimes(3); + await waitFor(() => expect(mockFetchHistory).toHaveBeenCalledTimes(4)); + expect(mockFetchHistory.mock.calls[3]?.[0]).toEqual( + expect.objectContaining({ + timeFrom: 650_799, + timeTo: 7_850_799, + }), + ); + await waitFor(() => expect(result.current.points).toHaveLength(897)); }); - it('continues native Market pagination after a full 200-point page', async () => { + it('continues native Market pagination after a sparse 199-point page', async () => { mockHistoryBatchSize = 200; mockHistoryRequestCandleCount = 2000; mockFetchHistory @@ -3267,7 +3497,8 @@ describe('TradingViewNative K-line data state machine', () => { firstTimestamp: 9_283_600, startingClose: 800, }), - ); + ) + .mockResolvedValueOnce(buildResponse(799, 9_280_000)); const { result } = renderHook(() => useTradingViewNativeKLine({ source: buildMarketSource({ tokenAddress: '' }), @@ -3280,7 +3511,8 @@ describe('TradingViewNative K-line data state machine', () => { await waitFor(() => expect(result.current.points).toHaveLength(399)); act(() => result.current.handleVisiblePointRangeChange({ startIndex: 0 })); - expect(mockFetchHistory).toHaveBeenCalledTimes(2); + await waitFor(() => expect(mockFetchHistory).toHaveBeenCalledTimes(3)); + await waitFor(() => expect(result.current.points).toHaveLength(400)); }); it('loads older history through the Hyperliquid provider path', async () => { diff --git a/packages/kit/src/components/TradingView/TradingViewNative/data/useTradingViewNativeKLine.ts b/packages/kit/src/components/TradingView/TradingViewNative/data/useTradingViewNativeKLine.ts index 36ce5869b621..a46936bb97c2 100644 --- a/packages/kit/src/components/TradingView/TradingViewNative/data/useTradingViewNativeKLine.ts +++ b/packages/kit/src/components/TradingView/TradingViewNative/data/useTradingViewNativeKLine.ts @@ -57,6 +57,11 @@ const HISTORY_BOUNDARY_PREFETCH_CACHE_MAX_SIZE = 100; const HISTORY_BOUNDARY_PREFETCH_CACHE_TTL = 24 * 60 * 60 * 1000; const HISTORY_BOUNDARY_SEARCH_INTERVAL_VALUE: ITradingViewNativeChartInterval = '1W'; +const SPARSE_MARKET_HISTORY_SCAN_WINDOW_SECONDS = 24 * 60 * 60; +const MAX_SPARSE_MARKET_HISTORY_SCAN_PAGE_COUNT = 60; +const MARKET_MINUTE_HISTORY_LOAD_MORE_TARGET_POINT_COUNT = Math.ceil( + TRADING_VIEW_NATIVE_TIME_RANGE_MAX_CANDLE_COUNT / 2, +); const HISTORY_RETRY_DELAYS = [1000, 3000] as const; const MAX_VIEWPORT_HISTORY_PAGE_COUNT = 20; const MAX_VIEWPORT_HISTORY_BOUNDARY_SEARCH_COUNT = 32; @@ -186,6 +191,14 @@ interface IHistoryBoundaryPrefetchRequest { promise: Promise; } +interface IHistoryGapRecoveryResult { + boundaryTimestamp: number; + cursorTimestamp: number; + hasMoreBefore: boolean; + historySource?: 'fallback'; + points: IMarketTokenKLineDataPoint[]; +} + interface IScopedVisiblePointRange { endIndex: number; interval: ITradingViewNativeChartInterval; @@ -328,6 +341,30 @@ function isAbortError(error: unknown) { return error instanceof Error && error.name === 'AbortError'; } +function getHasPotentialEarlierHistory({ + earliestTimestamp, + historyBoundaryTimestamp, + historySource, + pageHasMoreHistory, + sourceKind, +}: { + earliestTimestamp?: number; + historyBoundaryTimestamp?: number; + historySource?: 'fallback'; + pageHasMoreHistory: boolean; + sourceKind: ITradingViewNativeSource['kind']; +}) { + if (sourceKind === 'market' && historySource !== 'fallback') { + // A short Market page only exhausts its requested time window. Sparse + // tokens may still have candles before that window. + return ( + earliestTimestamp !== undefined && + earliestTimestamp > (historyBoundaryTimestamp ?? 0) + ); + } + return pageHasMoreHistory; +} + function getHistoryBoundaryPrefetchCacheKey(seriesKey: string) { return `${seriesKey}:${HISTORY_BOUNDARY_SEARCH_INTERVAL_VALUE}`; } @@ -630,6 +667,117 @@ function prefetchHistoryBoundaryPage({ return promise; } +async function recoverOlderHistoryFromBoundary({ + historyProvider, + interval, + maxPageCount = MAX_SPARSE_MARKET_HISTORY_SCAN_PAGE_COUNT, + onProgress, + seriesKey, + signal, + targetPointCount = 1, + timeTo, +}: { + historyProvider: ITradingViewNativeDataProvider; + interval: ITradingViewNativeKLineInterval; + maxPageCount?: number; + onProgress?: (result: IHistoryGapRecoveryResult) => void; + seriesKey: string; + signal: AbortSignal; + targetPointCount?: number; + timeTo: number; +}): Promise { + const boundaryPage = await (getHistoryBoundaryPrefetchPage(seriesKey) ?? + prefetchHistoryBoundaryPage({ historyProvider, seriesKey })); + if ( + signal.aborted || + !boundaryPage || + boundaryPage.hasMoreBefore || + boundaryPage.earliestTimestamp === undefined + ) { + return null; + } + + const boundaryTimestamp = boundaryPage.earliestTimestamp; + if (boundaryTimestamp > timeTo) { + return { + boundaryTimestamp, + cursorTimestamp: boundaryTimestamp, + hasMoreBefore: false, + points: [], + }; + } + + const normalizedTargetPointCount = Math.max(Math.floor(targetPointCount), 1); + let cursorTimeTo = Math.floor(timeTo); + let historySource: 'fallback' | undefined; + let points: IMarketTokenKLineDataPoint[] = []; + const buildResult = (): IHistoryGapRecoveryResult => { + const hasMoreBefore = cursorTimeTo >= boundaryTimestamp; + return { + boundaryTimestamp, + cursorTimestamp: hasMoreBefore ? cursorTimeTo + 1 : boundaryTimestamp, + hasMoreBefore, + historySource, + points, + }; + }; + for ( + let pageIndex = 0; + pageIndex < maxPageCount && + cursorTimeTo >= boundaryTimestamp && + points.length < normalizedTargetPointCount; + pageIndex += 1 + ) { + const pageTimeFrom = Math.max( + cursorTimeTo - SPARSE_MARKET_HISTORY_SCAN_WINDOW_SECONDS, + boundaryTimestamp, + ); + const data = await fetchRequiredHistoryPage({ + historyProvider, + request: { + interval, + signal, + timeFrom: pageTimeFrom, + timeTo: cursorTimeTo, + }, + unavailableMessage: + 'No candle history response is available for sparse history recovery', + }); + if (signal.aborted) { + return null; + } + + historySource = data.historySource; + if (historySource === 'fallback') { + return { + boundaryTimestamp, + cursorTimestamp: pageTimeFrom, + hasMoreBefore: false, + historySource, + points: [], + }; + } + const mergedPoints = mergeKLinePoints( + points, + normalizeKLinePointsInRange({ + from: pageTimeFrom, + points: data.points, + to: cursorTimeTo, + }), + ); + if (mergedPoints.length >= normalizedTargetPointCount) { + points = mergedPoints.slice(-normalizedTargetPointCount); + cursorTimeTo = (points[0]?.t ?? pageTimeFrom) - 1; + } else { + points = mergedPoints; + cursorTimeTo = pageTimeFrom - 1; + } + onProgress?.(buildResult()); + } + + return buildResult(); +} + function waitForHistoryRetry(delay: number, signal: AbortSignal) { if (signal.aborted) { return Promise.resolve(); @@ -1793,13 +1941,23 @@ export function useTradingViewNativeKLine({ if (!providerIsReady) { return; } - const cacheKey = getHistoryBoundaryPrefetchCacheKey(seriesKey); - const cachedEarliestTimestamp = getCachedHistoryBoundaryTimestamp(cacheKey); - if (cachedEarliestTimestamp !== undefined) { + const publishBoundaryTimestamp = (earliestTimestamp: number) => { setHistoryBoundaryAvailableTimeRange({ - from: cachedEarliestTimestamp, + from: earliestTimestamp, seriesKey, }); + const pagination = historyPaginationRef.current; + if ( + pagination.seriesKey === seriesKey && + pagination.earliestTimestamp !== undefined + ) { + pagination.hasMore = pagination.earliestTimestamp > earliestTimestamp; + } + }; + const cacheKey = getHistoryBoundaryPrefetchCacheKey(seriesKey); + const cachedEarliestTimestamp = getCachedHistoryBoundaryTimestamp(cacheKey); + if (cachedEarliestTimestamp !== undefined) { + publishBoundaryTimestamp(cachedEarliestTimestamp); return; } void prefetchHistoryBoundaryPage({ @@ -1815,10 +1973,7 @@ export function useTradingViewNativeKLine({ ); return; } - setHistoryBoundaryAvailableTimeRange({ - from: page.earliestTimestamp, - seriesKey, - }); + publishBoundaryTimestamp(page.earliestTimestamp); }); }, [historyProvider, providerIsReady, seriesKey]); @@ -2973,8 +3128,13 @@ export function useTradingViewNativeKLine({ } const timeTo = earliestTimestamp - 1; + const isMarketMinuteHistory = + sourceKind === 'market' && + (interval.value === '1' || interval.value === '5'); const timeFrom = getHistoryTimeFrom({ - candleCount: historyProvider.getHistoryRequestCandleCount(interval), + candleCount: isMarketMinuteHistory + ? MARKET_MINUTE_HISTORY_LOAD_MORE_TARGET_POINT_COUNT + : historyProvider.getHistoryRequestCandleCount(interval), intervalSeconds: interval.seconds, timeTo, }); @@ -2988,90 +3148,187 @@ export function useTradingViewNativeKLine({ pagination.isLoading = true; const loadOlderHistory = async () => { - let lastError: unknown; try { - for ( - let attempt = 0; - attempt <= HISTORY_RETRY_DELAYS.length; - attempt += 1 + const data = await fetchRequiredHistoryPage({ + historyProvider, + request: { + interval, + signal: abortController.signal, + timeFrom, + timeTo, + }, + unavailableMessage: 'No older candle history response is available', + }); + if ( + abortController.signal.aborted || + historyPaginationRef.current !== pagination ) { - try { - const data = await historyProvider.fetchHistory({ - interval, - signal: abortController.signal, - timeFrom, - timeTo, - }); + return; + } + + const historySource = data.historySource; + const receivedOlderPoints = normalizeKLinePoints(data.points).filter( + (point) => point.t < earliestTimestamp, + ); + let olderPoints = isMarketMinuteHistory + ? receivedOlderPoints.slice( + -MARKET_MINUTE_HISTORY_LOAD_MORE_TARGET_POINT_COUNT, + ) + : receivedOlderPoints; + let paginationCursorTimestamp = olderPoints[0]?.t; + const pageHasMoreHistory = historyProvider.hasMoreHistory({ + historySource, + interval, + receivedPointCount: receivedOlderPoints.length, + }); + let hasMoreHistory = getHasPotentialEarlierHistory({ + earliestTimestamp: olderPoints[0]?.t, + historyBoundaryTimestamp: getCachedHistoryBoundaryTimestamp( + getHistoryBoundaryPrefetchCacheKey(seriesKey), + ), + historySource, + pageHasMoreHistory, + sourceKind, + }); + const shouldRecoverSparseHistory = + sourceKind === 'market' && + historySource !== 'fallback' && + (interval.value === '1' || interval.value === '5') && + !pageHasMoreHistory && + olderPoints.length < + MARKET_MINUTE_HISTORY_LOAD_MORE_TARGET_POINT_COUNT; + const publishOlderPoints = ( + pointsToPublish: IMarketTokenKLineDataPoint[], + ) => { + if (!pointsToPublish.length) { + return; + } + setChartData((currentData) => { if ( - abortController.signal.aborted || - historyPaginationRef.current !== pagination + currentData?.seriesKey !== seriesKey || + currentData.interval !== activeInterval ) { - return; - } - if (!data) { - throw new OneKeyLocalError( - 'No older candle history response is available', - ); + return currentData; } + return { + ...currentData, + chartPictureVersion: currentData.chartPictureVersion + 1, + points: mergeKLinePoints(currentData.points, pointsToPublish), + }; + }); + }; - const olderPoints = normalizeKLinePoints(data.points).filter( - (point) => point.t < earliestTimestamp, - ); - if (!olderPoints.length) { - pagination.hasMore = false; + if (!olderPoints.length || shouldRecoverSparseHistory) { + addHistoryCoverageRange({ + coverageState: historyCoverageRef.current, + from: timeFrom, + interval: activeInterval, + intervalSeconds: interval.seconds, + seriesKey, + to: timeTo, + }); + if (!shouldRecoverSparseHistory) { + pagination.hasMore = false; + return; + } + } + + if (shouldRecoverSparseHistory) { + publishOlderPoints(olderPoints); + const recoveryTimeTo = Math.max(timeFrom - 1, 0); + let appliedRecoveryCursorTimestamp: number | undefined; + const applyRecoveryProgress = ( + recovery: IHistoryGapRecoveryResult, + ) => { + if ( + abortController.signal.aborted || + historyPaginationRef.current !== pagination || + recovery.historySource === 'fallback' || + appliedRecoveryCursorTimestamp === recovery.cursorTimestamp + ) { return; } - + appliedRecoveryCursorTimestamp = recovery.cursorTimestamp; + pagination.earliestTimestamp = recovery.cursorTimestamp; + pagination.hasMore = recovery.hasMoreBefore; addHistoryCoverageRange({ coverageState: historyCoverageRef.current, - from: olderPoints[0]?.t, + from: recovery.cursorTimestamp, interval: activeInterval, intervalSeconds: interval.seconds, seriesKey, - to: olderPoints[olderPoints.length - 1]?.t, - }); - pagination.earliestTimestamp = olderPoints[0].t; - pagination.hasMore = historyProvider.hasMoreHistory({ - historySource: data.historySource, - interval, - receivedPointCount: olderPoints.length, - }); - setChartData((currentData) => { - if ( - currentData?.seriesKey !== seriesKey || - currentData.interval !== activeInterval - ) { - return currentData; - } - return { - ...currentData, - chartPictureVersion: currentData.chartPictureVersion + 1, - points: mergeKLinePoints(currentData.points, olderPoints), - }; + to: timeTo, }); + publishOlderPoints(recovery.points); + }; + const recovery = await recoverOlderHistoryFromBoundary({ + historyProvider, + interval, + onProgress: applyRecoveryProgress, + seriesKey, + signal: abortController.signal, + targetPointCount: Math.max( + MARKET_MINUTE_HISTORY_LOAD_MORE_TARGET_POINT_COUNT - + olderPoints.length, + 1, + ), + timeTo: recoveryTimeTo, + }); + if ( + abortController.signal.aborted || + historyPaginationRef.current !== pagination + ) { return; - } catch (error) { - if (abortController.signal.aborted || isAbortError(error)) { + } + if (!recovery) { + if (!olderPoints.length) { + pagination.hasMore = false; return; } - lastError = error; - const retryDelay = HISTORY_RETRY_DELAYS[attempt]; - if (retryDelay === undefined) { - break; + } else { + setHistoryBoundaryAvailableTimeRange({ + from: recovery.boundaryTimestamp, + seriesKey, + }); + if (recovery.historySource === 'fallback') { + pagination.hasMore = false; + if (!olderPoints.length) { + return; + } + hasMoreHistory = false; + } else { + olderPoints = mergeKLinePoints(recovery.points, olderPoints); + paginationCursorTimestamp = recovery.cursorTimestamp; + hasMoreHistory = recovery.hasMoreBefore; + applyRecoveryProgress(recovery); } - await waitForHistoryRetry(retryDelay, abortController.signal); - if (abortController.signal.aborted) { + if (!olderPoints.length) { + pagination.earliestTimestamp = recovery.cursorTimestamp; + pagination.hasMore = recovery.hasMoreBefore; return; } } } + addHistoryCoverageRange({ + coverageState: historyCoverageRef.current, + from: olderPoints[0]?.t, + interval: activeInterval, + intervalSeconds: interval.seconds, + seriesKey, + to: olderPoints[olderPoints.length - 1]?.t, + }); + pagination.earliestTimestamp = + paginationCursorTimestamp ?? olderPoints[0].t; + pagination.hasMore = hasMoreHistory; + publishOlderPoints(olderPoints); + } catch (error) { + if (abortController.signal.aborted || isAbortError(error)) { + return; + } logTradingViewNativeDataError( 'Failed to fetch older native TradingView candle history', - lastError ?? - new OneKeyLocalError( - 'No older candle history response is available', - ), + error, ); } finally { if ( @@ -3085,7 +3342,7 @@ export function useTradingViewNativeKLine({ }; void loadOlderHistory(); }, - [activeInterval, historyProvider, seriesKey], + [activeInterval, historyProvider, seriesKey, sourceKind], ); const handleRealtimePoint = useCallback( @@ -3526,6 +3783,8 @@ export function useTradingViewNativeKLine({ const fetchHistory = async () => { let lastError: unknown; + let initialData: ITradingViewNativeHistoryResponse | undefined; + let initialPoints: IMarketTokenKLineDataPoint[] | undefined; for ( let attempt = 0; attempt <= HISTORY_RETRY_DELAYS.length; @@ -3541,75 +3800,16 @@ export function useTradingViewNativeKLine({ if (isCancelled || latestRequestIdRef.current !== requestId) { return; } - let points = normalizeKLinePoints(data?.points ?? []); - if (!points.length) { + if (!data) { throw new OneKeyLocalError('No candle data is available'); } - const receivedHistoryPointCount = points.length; - addHistoryCoverageRange({ - coverageState: historyCoverageRef.current, - from: points[0]?.t, - interval: requestedInterval.value, - intervalSeconds: requestedInterval.seconds, - seriesKey, - to: points[points.length - 1]?.t, - }); - const realtimeScope = realtimeScopeRef.current; - if ( - realtimeScope.seriesKey === seriesKey && - realtimeScope.interval === requestedInterval.value && - realtimePointBufferRef.current.size > 0 - ) { - points = mergeRealtimePointBuffer( - points, - realtimePointBufferRef.current.values(), - ); - realtimePointBufferRef.current.clear(); - } - const updatedAt = Date.now(); - const currentChartData = chartDataRef.current; - const nextPoints = - currentChartData?.seriesKey === seriesKey && - currentChartData.interval === requestedInterval.value - ? mergeKLinePoints(currentChartData.points, points) - : points; - const pagination = historyPaginationRef.current; - if ( - pagination.seriesKey === seriesKey && - pagination.interval === requestedInterval.value - ) { - if (pagination.earliestTimestamp === undefined) { - pagination.hasMore = historyProvider.hasMoreHistory({ - historySource: data?.historySource, - interval: requestedInterval, - receivedPointCount: receivedHistoryPointCount, - }); - } - pagination.earliestTimestamp = nextPoints[0]?.t; - pagination.hasMoreAfter = false; - pagination.newerCursorTimestamp = timeTo; + const points = normalizeKLinePoints(data.points); + if (!points.length) { + throw new OneKeyLocalError('No candle data is available'); } - setChartData((currentData) => ({ - chartPictureVersion: - currentData?.seriesKey === seriesKey && - currentData.interval === requestedInterval.value - ? currentData.chartPictureVersion + 1 - : 0, - interval: requestedInterval.value, - seriesKey, - points: - currentData?.seriesKey === seriesKey && - currentData.interval === requestedInterval.value - ? mergeKLinePoints(currentData.points, points) - : points, - })); - setHistoryState({ - interval: requestedInterval.value, - lastUpdatedAt: updatedAt, - seriesKey, - status: 'ready', - }); - return; + initialData = data; + initialPoints = points; + break; } catch (error) { if (isCancelled || isAbortError(error)) { return; @@ -3626,13 +3826,194 @@ export function useTradingViewNativeKLine({ } } - const error = - lastError ?? new OneKeyLocalError('No candle data is available'); - logTradingViewNativeDataError( - 'Failed to fetch native TradingView candle history', - error, - ); - rollbackInterval(error); + if (!initialData || !initialPoints) { + const error = + lastError ?? new OneKeyLocalError('No candle data is available'); + logTradingViewNativeDataError( + 'Failed to fetch native TradingView candle history', + error, + ); + rollbackInterval(error); + return; + } + + let points = initialPoints; + const receivedHistoryPointCount = points.length; + const initialEarliestTimestamp = points[0]?.t; + const pageHasMoreHistory = historyProvider.hasMoreHistory({ + historySource: initialData.historySource, + interval: requestedInterval, + receivedPointCount: receivedHistoryPointCount, + }); + const hasMoreHistory = getHasPotentialEarlierHistory({ + earliestTimestamp: initialEarliestTimestamp, + historyBoundaryTimestamp: getCachedHistoryBoundaryTimestamp( + getHistoryBoundaryPrefetchCacheKey(seriesKey), + ), + historySource: initialData.historySource, + pageHasMoreHistory, + sourceKind, + }); + const pagination = historyPaginationRef.current; + const shouldRecoverSparseHistory = + sourceKind === 'market' && + (requestedInterval.value === '1' || requestedInterval.value === '5') && + initialData.historySource !== 'fallback' && + !pageHasMoreHistory && + pagination.seriesKey === seriesKey && + pagination.interval === requestedInterval.value && + pagination.earliestTimestamp === undefined && + initialEarliestTimestamp !== undefined; + + addHistoryCoverageRange({ + coverageState: historyCoverageRef.current, + from: initialEarliestTimestamp, + interval: requestedInterval.value, + intervalSeconds: requestedInterval.seconds, + seriesKey, + to: points[points.length - 1]?.t, + }); + const realtimeScope = realtimeScopeRef.current; + if ( + realtimeScope.seriesKey === seriesKey && + realtimeScope.interval === requestedInterval.value && + realtimePointBufferRef.current.size > 0 + ) { + points = mergeRealtimePointBuffer( + points, + realtimePointBufferRef.current.values(), + ); + realtimePointBufferRef.current.clear(); + } + const currentChartData = chartDataRef.current; + const nextPoints = + currentChartData?.seriesKey === seriesKey && + currentChartData.interval === requestedInterval.value + ? mergeKLinePoints(currentChartData.points, points) + : points; + if ( + pagination.seriesKey === seriesKey && + pagination.interval === requestedInterval.value + ) { + if (pagination.earliestTimestamp === undefined) { + pagination.hasMore = hasMoreHistory; + } + pagination.earliestTimestamp = nextPoints[0]?.t; + pagination.hasMoreAfter = false; + pagination.newerCursorTimestamp = timeTo; + } + setChartData((currentData) => ({ + chartPictureVersion: + currentData?.seriesKey === seriesKey && + currentData.interval === requestedInterval.value + ? currentData.chartPictureVersion + 1 + : 0, + interval: requestedInterval.value, + seriesKey, + points: + currentData?.seriesKey === seriesKey && + currentData.interval === requestedInterval.value + ? mergeKLinePoints(currentData.points, points) + : points, + })); + setHistoryState({ + interval: requestedInterval.value, + lastUpdatedAt: Date.now(), + seriesKey, + status: 'ready', + }); + + if ( + !shouldRecoverSparseHistory || + initialEarliestTimestamp === undefined + ) { + return; + } + + pagination.isLoading = true; + let appliedRecoveryCursorTimestamp: number | undefined; + const applyRecoveryProgress = (recovery: IHistoryGapRecoveryResult) => { + if ( + isCancelled || + latestRequestIdRef.current !== requestId || + historyPaginationRef.current !== pagination || + recovery.historySource === 'fallback' || + appliedRecoveryCursorTimestamp === recovery.cursorTimestamp + ) { + return; + } + appliedRecoveryCursorTimestamp = recovery.cursorTimestamp; + pagination.earliestTimestamp = recovery.cursorTimestamp; + pagination.hasMore = recovery.hasMoreBefore; + addHistoryCoverageRange({ + coverageState: historyCoverageRef.current, + from: recovery.cursorTimestamp, + interval: requestedInterval.value, + intervalSeconds: requestedInterval.seconds, + seriesKey, + to: initialEarliestTimestamp - 1, + }); + if (!recovery.points.length) { + return; + } + setChartData((currentData) => { + if ( + currentData?.seriesKey !== seriesKey || + currentData.interval !== requestedInterval.value + ) { + return currentData; + } + return { + ...currentData, + chartPictureVersion: currentData.chartPictureVersion + 1, + points: mergeKLinePoints(currentData.points, recovery.points), + }; + }); + }; + try { + const recovery = await recoverOlderHistoryFromBoundary({ + historyProvider, + interval: requestedInterval, + onProgress: applyRecoveryProgress, + seriesKey, + signal: abortController.signal, + targetPointCount: Math.max( + TRADING_VIEW_NATIVE_TIME_RANGE_MAX_CANDLE_COUNT - + receivedHistoryPointCount, + 1, + ), + timeTo: Math.max(initialEarliestTimestamp - 1, 0), + }); + if ( + isCancelled || + latestRequestIdRef.current !== requestId || + historyPaginationRef.current !== pagination || + !recovery + ) { + return; + } + + setHistoryBoundaryAvailableTimeRange({ + from: recovery.boundaryTimestamp, + seriesKey, + }); + if (recovery.historySource === 'fallback') { + pagination.hasMore = false; + return; + } + applyRecoveryProgress(recovery); + } catch (error) { + if (!isCancelled && !isAbortError(error)) { + logTradingViewNativeDataError( + 'Failed to recover sparse native TradingView candle history', + error, + ); + } + } finally { + if (historyPaginationRef.current === pagination) { + pagination.isLoading = false; + } + } }; void fetchHistory().finally(() => { if (initialHistoryAbortControllerRef.current === abortController) { @@ -3654,6 +4035,7 @@ export function useTradingViewNativeKLine({ providerIsReady, seriesKey, setActiveInterval, + sourceKind, ]); const dataState = useMemo( From 00adb594c45fcf00d5b78dad4540810180756d6a Mon Sep 17 00:00:00 2001 From: limichange Date: Mon, 17 Aug 2026 22:16:03 +0800 Subject: [PATCH 02/15] fix: address sparse kline review feedback OK-60116 --- .../data/useTradingViewNativeKLine.test.ts | 263 +++++++++++++++- .../data/useTradingViewNativeKLine.ts | 286 ++++++++++++------ 2 files changed, 448 insertions(+), 101 deletions(-) diff --git a/packages/kit/src/components/TradingView/TradingViewNative/data/useTradingViewNativeKLine.test.ts b/packages/kit/src/components/TradingView/TradingViewNative/data/useTradingViewNativeKLine.test.ts index 294047957b86..67e9befb5789 100644 --- a/packages/kit/src/components/TradingView/TradingViewNative/data/useTradingViewNativeKLine.test.ts +++ b/packages/kit/src/components/TradingView/TradingViewNative/data/useTradingViewNativeKLine.test.ts @@ -727,6 +727,7 @@ describe('TradingViewNative K-line data state machine', () => { mockHistoryRequestCandleCount = 2000; mockReadTradingViewNativeActiveInterval.mockReturnValue(activeInterval); let activeIntervalRequestCount = 0; + let dailyRequestCount = 0; const firstScan = createDeferred(); mockFetchHistory.mockImplementation( @@ -735,7 +736,13 @@ describe('TradingViewNative K-line data state machine', () => { return buildResponse(50, 100_000); } if (interval.value === '1D') { - return buildResponse(60, 110_000); + dailyRequestCount += 1; + return dailyRequestCount === 1 + ? buildResponse(60, 110_000) + : buildMultiPointResponse([ + { close: 70, timestamp: 740_000 }, + { close: 80, timestamp: 830_000 }, + ]); } activeIntervalRequestCount += 1; @@ -747,19 +754,19 @@ describe('TradingViewNative K-line data state machine', () => { } if (activeIntervalRequestCount === 2) { expect({ timeFrom, timeTo }).toEqual({ - timeFrom: 913_599, - timeTo: 999_999, + timeFrom: 830_000, + timeTo: 916_399, }); return firstScan.promise; } expect({ timeFrom, timeTo }).toEqual({ - timeFrom: 827_198, - timeTo: 913_598, + timeFrom: 740_000, + timeTo: 826_399, }); return buildMultiPointResponse( Array.from({ length: 196 }, (_, index) => ({ close: 90 + index, - timestamp: 830_000 + index * intervalSeconds, + timestamp: 740_000 + index * intervalSeconds, })), ); }, @@ -771,8 +778,72 @@ describe('TradingViewNative K-line data state machine', () => { await waitFor(() => expect(result.current.points).toHaveLength(2)); act(() => firstScan.resolve({ points: [], total: 0 })); await waitFor(() => expect(result.current.points).toHaveLength(198)); + expect(mockFetchHistory).toHaveBeenCalledTimes(6); + expect(result.current.points[0]?.t).toBe(740_000); + }, + ); + + it.each([ + ['1', 60], + ['5', 5 * 60], + ] as const)( + 'locates %s-minute Market history across a gap longer than 60 days', + async (activeInterval, intervalSeconds) => { + const currentTimestamp = 20_000_000; + const recentTimestamp = currentTimestamp - intervalSeconds; + const oldActiveDay = recentTimestamp - 100 * 24 * 60 * 60; + const boundaryTimestamp = oldActiveDay - 24 * 60 * 60; + mockHistoryBatchSize = 299; + mockHistoryRequestCandleCount = 2000; + mockReadTradingViewNativeActiveInterval.mockReturnValue(activeInterval); + jest.spyOn(Date, 'now').mockReturnValue(currentTimestamp * 1000); + let activeIntervalRequestCount = 0; + let dailyRequestCount = 0; + mockFetchHistory.mockImplementation(async ({ interval }) => { + if (interval.value === '1W') { + return buildResponse(50, boundaryTimestamp - 3600); + } + if (interval.value === '1D') { + dailyRequestCount += 1; + return dailyRequestCount === 1 + ? buildResponse(60, boundaryTimestamp) + : buildResponse(70, oldActiveDay); + } + + activeIntervalRequestCount += 1; + if (activeIntervalRequestCount === 1) { + return buildMultiPointResponse([ + { close: 100, timestamp: recentTimestamp }, + { close: 110, timestamp: currentTimestamp }, + ]); + } + return buildMultiPointResponse( + Array.from({ length: 196 }, (_, index) => ({ + close: 70 + index, + timestamp: oldActiveDay + index * intervalSeconds, + })), + ); + }); + const { result } = renderHook(() => + useTradingViewNativeKLine({ source: buildMarketSource() }), + ); + + await waitFor(() => expect(result.current.points).toHaveLength(198)); + expect(recentTimestamp - oldActiveDay).toBeGreaterThan(60 * 24 * 60 * 60); expect(mockFetchHistory).toHaveBeenCalledTimes(5); - expect(result.current.points[0]?.t).toBe(830_000); + expect( + mockFetchHistory.mock.calls.find( + ([request]) => + request.interval.value === activeInterval && + request.timeFrom === oldActiveDay, + )?.[0], + ).toEqual( + expect.objectContaining({ + timeFrom: oldActiveDay, + timeTo: oldActiveDay + 24 * 60 * 60 - 1, + }), + ); + expect(result.current.points[0]?.t).toBe(oldActiveDay); }, ); @@ -787,12 +858,16 @@ describe('TradingViewNative K-line data state machine', () => { mockReadTradingViewNativeActiveInterval.mockReturnValue(activeInterval); jest.spyOn(Date, 'now').mockReturnValue(2_000_000_000); let activeIntervalRequestCount = 0; + let dailyRequestCount = 0; mockFetchHistory.mockImplementation(async ({ interval }) => { if (interval.value === '1W') { return buildResponse(50, 100_000); } if (interval.value === '1D') { - return buildResponse(60, 110_000); + dailyRequestCount += 1; + return dailyRequestCount === 1 + ? buildResponse(60, 110_000) + : buildResponse(70, 920_000); } activeIntervalRequestCount += 1; @@ -830,7 +905,7 @@ describe('TradingViewNative K-line data state machine', () => { )[2]?.[0], ).toEqual( expect.objectContaining({ - timeFrom: recoveryTimeTo - 86_400, + timeFrom: 920_000, timeTo: recoveryTimeTo, }), ); @@ -846,7 +921,7 @@ describe('TradingViewNative K-line data state machine', () => { mockFetchHistory.mock.calls.filter( ([request]) => request.interval.value === '1D', ), - ).toHaveLength(1); + ).toHaveLength(2); }, ); @@ -860,12 +935,16 @@ describe('TradingViewNative K-line data state machine', () => { mockHistoryRequestCandleCount = 2; mockReadTradingViewNativeActiveInterval.mockReturnValue(activeInterval); let activeIntervalRequestCount = 0; + let dailyRequestCount = 0; mockFetchHistory.mockImplementation(async ({ interval }) => { if (interval.value === '1W') { return buildResponse(50, 100_000); } if (interval.value === '1D') { - return buildResponse(60, 110_000); + dailyRequestCount += 1; + return dailyRequestCount === 1 + ? buildResponse(60, 110_000) + : buildResponse(70, 920_000); } activeIntervalRequestCount += 1; @@ -903,14 +982,14 @@ describe('TradingViewNative K-line data state machine', () => { )[2]?.[0], ).toEqual( expect.objectContaining({ - timeFrom: recoveryTimeTo - 86_400, + timeFrom: 920_000, timeTo: recoveryTimeTo, }), ); expect(result.current.points.some((point) => point.t === 995_000)).toBe( true, ); - expect(mockFetchHistory).toHaveBeenCalledTimes(5); + expect(mockFetchHistory).toHaveBeenCalledTimes(6); }, ); @@ -955,6 +1034,142 @@ describe('TradingViewNative K-line data state machine', () => { expect(mockFetchHistory).toHaveBeenCalledTimes(4); }); + it('keeps sparse pagination retryable after boundary prefetch fails', async () => { + jest.useFakeTimers(); + mockHistoryBatchSize = 2; + mockHistoryRequestCandleCount = 2; + mockReadTradingViewNativeActiveInterval.mockReturnValue('1'); + let activeIntervalRequestCount = 0; + let dailyRequestCount = 0; + let weeklyRequestCount = 0; + mockFetchHistory.mockImplementation(async ({ interval }) => { + if (interval.value === '1W') { + weeklyRequestCount += 1; + return weeklyRequestCount <= 3 ? null : buildResponse(50, 100_000); + } + if (interval.value === '1D') { + dailyRequestCount += 1; + return dailyRequestCount === 1 + ? buildResponse(60, 110_000) + : buildResponse(70, 920_000); + } + + activeIntervalRequestCount += 1; + if (activeIntervalRequestCount === 1) { + return buildMultiPointResponse([ + { close: 100, timestamp: 1_000_000 }, + { close: 110, timestamp: 1_000_060 }, + ]); + } + if (activeIntervalRequestCount <= 3) { + return { points: [], total: 0 }; + } + return buildMultiPointResponse( + Array.from({ length: 99 }, (_, index) => ({ + close: 70 + index, + timestamp: 920_100 + index * 60, + })), + ); + }); + const { result } = renderHook(() => + useTradingViewNativeKLine({ source: buildMarketSource() }), + ); + + await waitFor(() => expect(result.current.points).toHaveLength(2)); + act(() => result.current.handleVisiblePointRangeChange({ startIndex: 0 })); + await act(async () => { + await jest.advanceTimersByTimeAsync(4001); + }); + expect(weeklyRequestCount).toBe(3); + expect(activeIntervalRequestCount).toBe(2); + + act(() => result.current.handleVisiblePointRangeChange({ startIndex: 0 })); + await waitFor(() => expect(result.current.points).toHaveLength(101)); + expect(weeklyRequestCount).toBe(4); + expect(activeIntervalRequestCount).toBe(4); + }); + + it('keeps load-more ownership when aborted initial recovery settles', async () => { + mockHistoryBatchSize = 3; + mockHistoryRequestCandleCount = 2; + mockReadTradingViewNativeActiveInterval.mockReturnValue('1'); + jest.spyOn(Date, 'now').mockReturnValue(2_000_000_000); + const boundaryRequest = + createDeferred(); + const loadMoreRequest = + createDeferred(); + let activeIntervalRequestCount = 0; + mockFetchHistory.mockImplementation(async ({ interval }) => { + if (interval.value === '1W') { + return boundaryRequest.promise; + } + if (interval.value === '1D') { + return buildResponse(60, 110_000); + } + + activeIntervalRequestCount += 1; + if (activeIntervalRequestCount === 1) { + return buildMultiPointResponse([ + { close: 100, timestamp: 1_000_000 }, + { close: 110, timestamp: 1_000_060 }, + ]); + } + if (activeIntervalRequestCount === 2) { + return buildMultiPointResponse([ + { close: 80, timestamp: 900_000 }, + { close: 90, timestamp: 900_060 }, + ]); + } + return loadMoreRequest.promise; + }); + const { result } = renderHook(() => + useTradingViewNativeKLine({ source: buildMarketSource() }), + ); + + await waitFor(() => + expect( + mockFetchHistory.mock.calls.some( + ([request]) => request.interval.value === '1W', + ), + ).toBe(true), + ); + await act(async () => { + await result.current.handleViewportTargetChange({ + kind: 'timeRange', + from: 900_000, + to: 900_060, + }); + }); + act(() => + result.current.handleViewportRequestApplied( + result.current.viewportRequest?.requestId ?? 0, + ), + ); + act(() => result.current.handleVisiblePointRangeChange({ startIndex: 0 })); + await waitFor(() => expect(activeIntervalRequestCount).toBe(3)); + + await act(async () => { + boundaryRequest.resolve(buildResponse(50, 100_000)); + await boundaryRequest.promise; + await Promise.resolve(); + }); + act(() => result.current.handleVisiblePointRangeChange({ startIndex: 0 })); + await act(async () => Promise.resolve()); + expect(activeIntervalRequestCount).toBe(3); + + await act(async () => { + loadMoreRequest.resolve( + buildMultiPointResponse([ + { close: 60, timestamp: 899_000 }, + { close: 70, timestamp: 899_060 }, + { close: 80, timestamp: 899_120 }, + ]), + ); + await loadMoreRequest.promise; + }); + await waitFor(() => expect(result.current.points[0]?.t).toBe(899_000)); + }); + it('does not paginate CoinGecko fallback data as Market history', async () => { mockHistoryBatchSize = 1; mockHistoryRequestCandleCount = 2000; @@ -3360,6 +3575,28 @@ describe('TradingViewNative K-line data state machine', () => { ); }); + it('retries a transient older-history failure automatically', async () => { + jest.useFakeTimers(); + mockFetchHistory + .mockResolvedValueOnce(buildResponse(110, 1_003_600)) + .mockRejectedValueOnce(new Error('temporary older-history failure')) + .mockResolvedValueOnce(buildResponse(90, 996_400)); + const { result } = renderHook(() => + useTradingViewNativeKLine({ source: buildMarketSource() }), + ); + + await waitFor(() => expect(result.current.points).toHaveLength(1)); + act(() => result.current.handleVisiblePointRangeChange({ startIndex: 0 })); + await act(async () => { + await jest.advanceTimersByTimeAsync(1001); + }); + + expect(mockFetchHistory).toHaveBeenCalledTimes(3); + expect(result.current.points.map((point) => point.t)).toEqual([ + 996_400, 1_003_600, + ]); + }); + it('keeps an OHLC chart when a single-value older page is empty', async () => { mockHistoryBatchSize = 2; mockHistoryRequestCandleCount = 2; diff --git a/packages/kit/src/components/TradingView/TradingViewNative/data/useTradingViewNativeKLine.ts b/packages/kit/src/components/TradingView/TradingViewNative/data/useTradingViewNativeKLine.ts index a46936bb97c2..030893d827f7 100644 --- a/packages/kit/src/components/TradingView/TradingViewNative/data/useTradingViewNativeKLine.ts +++ b/packages/kit/src/components/TradingView/TradingViewNative/data/useTradingViewNativeKLine.ts @@ -57,8 +57,9 @@ const HISTORY_BOUNDARY_PREFETCH_CACHE_MAX_SIZE = 100; const HISTORY_BOUNDARY_PREFETCH_CACHE_TTL = 24 * 60 * 60 * 1000; const HISTORY_BOUNDARY_SEARCH_INTERVAL_VALUE: ITradingViewNativeChartInterval = '1W'; -const SPARSE_MARKET_HISTORY_SCAN_WINDOW_SECONDS = 24 * 60 * 60; -const MAX_SPARSE_MARKET_HISTORY_SCAN_PAGE_COUNT = 60; +const SPARSE_MARKET_HISTORY_LOCATOR_INTERVAL_VALUE: ITradingViewNativeChartInterval = + '1D'; +const MAX_SPARSE_MARKET_HISTORY_ACTIVE_DAY_REQUEST_COUNT = 8; const MARKET_MINUTE_HISTORY_LOAD_MORE_TARGET_POINT_COUNT = Math.ceil( TRADING_VIEW_NATIVE_TIME_RANGE_MAX_CANDLE_COUNT / 2, ); @@ -199,6 +200,17 @@ interface IHistoryGapRecoveryResult { points: IMarketTokenKLineDataPoint[]; } +interface IHistoryGapRecoveryProgressOptions { + coverageState: IHistoryCoverageState; + interval: ITradingViewNativeChartInterval; + intervalSeconds: number; + isActive: () => boolean; + onPoints: (points: IMarketTokenKLineDataPoint[]) => void; + pagination: IHistoryPaginationState; + seriesKey: string; + timeTo: number; +} + interface IScopedVisiblePointRange { endIndex: number; interval: ITradingViewNativeChartInterval; @@ -273,6 +285,31 @@ function mergeKLinePoints( return normalizeKLinePoints([...pointsByTimestamp.values()]); } +function mergeScopedChartDataPoints({ + currentData, + interval, + points, + seriesKey, +}: { + currentData: IChartData | null; + interval: ITradingViewNativeChartInterval; + points: IMarketTokenKLineDataPoint[]; + seriesKey: string; +}) { + if ( + !points.length || + currentData?.seriesKey !== seriesKey || + currentData.interval !== interval + ) { + return currentData; + } + return { + ...currentData, + chartPictureVersion: currentData.chartPictureVersion + 1, + points: mergeKLinePoints(currentData.points, points), + }; +} + function areKLinePointsEqual( first: IMarketTokenKLineDataPoint, second: IMarketTokenKLineDataPoint, @@ -670,7 +707,6 @@ function prefetchHistoryBoundaryPage({ async function recoverOlderHistoryFromBoundary({ historyProvider, interval, - maxPageCount = MAX_SPARSE_MARKET_HISTORY_SCAN_PAGE_COUNT, onProgress, seriesKey, signal, @@ -707,10 +743,42 @@ async function recoverOlderHistoryFromBoundary({ }; } + const locatorInterval = TRADING_VIEW_NATIVE_KLINE_INTERVALS.find( + (candidate) => + candidate.value === SPARSE_MARKET_HISTORY_LOCATOR_INTERVAL_VALUE, + ); + if (!locatorInterval) { + return null; + } + // A daily lookup skips empty calendar spans before minute requests are made. + const locatorData = await fetchRequiredHistoryPage({ + historyProvider, + request: { + interval: locatorInterval, + signal, + timeFrom: boundaryTimestamp, + timeTo, + }, + unavailableMessage: + 'No daily candle history response is available for sparse history recovery', + }); + if (signal.aborted) { + return null; + } + const activeDays = normalizeKLinePointsInRange({ + from: boundaryTimestamp, + points: locatorData.points, + to: timeTo, + }); + if (!activeDays.length) { + return null; + } + const normalizedTargetPointCount = Math.max(Math.floor(targetPointCount), 1); let cursorTimeTo = Math.floor(timeTo); let historySource: 'fallback' | undefined; let points: IMarketTokenKLineDataPoint[] = []; + let activeDayIndex = activeDays.length - 1; const buildResult = (): IHistoryGapRecoveryResult => { const hasMoreBefore = cursorTimeTo >= boundaryTimestamp; return { @@ -722,15 +790,29 @@ async function recoverOlderHistoryFromBoundary({ }; }; for ( - let pageIndex = 0; - pageIndex < maxPageCount && + let requestIndex = 0; + requestIndex < MAX_SPARSE_MARKET_HISTORY_ACTIVE_DAY_REQUEST_COUNT && + activeDayIndex >= 0 && cursorTimeTo >= boundaryTimestamp && points.length < normalizedTargetPointCount; - pageIndex += 1 + requestIndex += 1 ) { - const pageTimeFrom = Math.max( - cursorTimeTo - SPARSE_MARKET_HISTORY_SCAN_WINDOW_SECONDS, - boundaryTimestamp, + while (activeDayIndex >= 0) { + const candidateActiveDay = activeDays[activeDayIndex]; + if (!candidateActiveDay || candidateActiveDay.t <= cursorTimeTo) { + break; + } + activeDayIndex -= 1; + } + const activeDay = activeDays[activeDayIndex]; + if (!activeDay) { + break; + } + activeDayIndex -= 1; + const pageTimeFrom = Math.max(activeDay.t, boundaryTimestamp); + const pageTimeTo = Math.min( + cursorTimeTo, + activeDay.t + locatorInterval.seconds - 1, ); const data = await fetchRequiredHistoryPage({ historyProvider, @@ -738,7 +820,7 @@ async function recoverOlderHistoryFromBoundary({ interval, signal, timeFrom: pageTimeFrom, - timeTo: cursorTimeTo, + timeTo: pageTimeTo, }, unavailableMessage: 'No candle history response is available for sparse history recovery', @@ -762,7 +844,7 @@ async function recoverOlderHistoryFromBoundary({ normalizeKLinePointsInRange({ from: pageTimeFrom, points: data.points, - to: cursorTimeTo, + to: pageTimeTo, }), ); if (mergedPoints.length >= normalizedTargetPointCount) { @@ -1115,6 +1197,68 @@ function addHistoryCoverageRange({ }); } +function applyHistoryGapRecoveryToPagination({ + coverageState, + interval, + intervalSeconds, + pagination, + recovery, + seriesKey, + timeTo, +}: { + coverageState: IHistoryCoverageState; + interval: ITradingViewNativeChartInterval; + intervalSeconds: number; + pagination: IHistoryPaginationState; + recovery: IHistoryGapRecoveryResult; + seriesKey: string; + timeTo: number; +}) { + pagination.earliestTimestamp = recovery.cursorTimestamp; + pagination.hasMore = recovery.hasMoreBefore; + addHistoryCoverageRange({ + coverageState, + from: recovery.cursorTimestamp, + interval, + intervalSeconds, + seriesKey, + to: timeTo, + }); +} + +function createHistoryGapRecoveryProgressHandler({ + coverageState, + interval, + intervalSeconds, + isActive, + onPoints, + pagination, + seriesKey, + timeTo, +}: IHistoryGapRecoveryProgressOptions) { + let appliedCursorTimestamp: number | undefined; + return (recovery: IHistoryGapRecoveryResult) => { + if ( + !isActive() || + recovery.historySource === 'fallback' || + appliedCursorTimestamp === recovery.cursorTimestamp + ) { + return; + } + appliedCursorTimestamp = recovery.cursorTimestamp; + applyHistoryGapRecoveryToPagination({ + coverageState, + interval, + intervalSeconds, + pagination, + recovery, + seriesKey, + timeTo, + }); + onPoints(recovery.points); + }; +} + function getVisibleHistoryGap({ coverageRanges, endIndex, @@ -3200,22 +3344,14 @@ export function useTradingViewNativeKLine({ const publishOlderPoints = ( pointsToPublish: IMarketTokenKLineDataPoint[], ) => { - if (!pointsToPublish.length) { - return; - } - setChartData((currentData) => { - if ( - currentData?.seriesKey !== seriesKey || - currentData.interval !== activeInterval - ) { - return currentData; - } - return { - ...currentData, - chartPictureVersion: currentData.chartPictureVersion + 1, - points: mergeKLinePoints(currentData.points, pointsToPublish), - }; - }); + setChartData((currentData) => + mergeScopedChartDataPoints({ + currentData, + interval: activeInterval, + points: pointsToPublish, + seriesKey, + }), + ); }; if (!olderPoints.length || shouldRecoverSparseHistory) { @@ -3236,31 +3372,19 @@ export function useTradingViewNativeKLine({ if (shouldRecoverSparseHistory) { publishOlderPoints(olderPoints); const recoveryTimeTo = Math.max(timeFrom - 1, 0); - let appliedRecoveryCursorTimestamp: number | undefined; - const applyRecoveryProgress = ( - recovery: IHistoryGapRecoveryResult, - ) => { - if ( - abortController.signal.aborted || - historyPaginationRef.current !== pagination || - recovery.historySource === 'fallback' || - appliedRecoveryCursorTimestamp === recovery.cursorTimestamp - ) { - return; - } - appliedRecoveryCursorTimestamp = recovery.cursorTimestamp; - pagination.earliestTimestamp = recovery.cursorTimestamp; - pagination.hasMore = recovery.hasMoreBefore; - addHistoryCoverageRange({ + const applyRecoveryProgress = + createHistoryGapRecoveryProgressHandler({ coverageState: historyCoverageRef.current, - from: recovery.cursorTimestamp, interval: activeInterval, intervalSeconds: interval.seconds, + isActive: () => + !abortController.signal.aborted && + historyPaginationRef.current === pagination, + onPoints: publishOlderPoints, + pagination, seriesKey, - to: timeTo, + timeTo, }); - publishOlderPoints(recovery.points); - }; const recovery = await recoverOlderHistoryFromBoundary({ historyProvider, interval, @@ -3282,7 +3406,6 @@ export function useTradingViewNativeKLine({ } if (!recovery) { if (!olderPoints.length) { - pagination.hasMore = false; return; } } else { @@ -3930,46 +4053,29 @@ export function useTradingViewNativeKLine({ return; } + pagination.abortController = abortController; pagination.isLoading = true; - let appliedRecoveryCursorTimestamp: number | undefined; - const applyRecoveryProgress = (recovery: IHistoryGapRecoveryResult) => { - if ( - isCancelled || - latestRequestIdRef.current !== requestId || - historyPaginationRef.current !== pagination || - recovery.historySource === 'fallback' || - appliedRecoveryCursorTimestamp === recovery.cursorTimestamp - ) { - return; - } - appliedRecoveryCursorTimestamp = recovery.cursorTimestamp; - pagination.earliestTimestamp = recovery.cursorTimestamp; - pagination.hasMore = recovery.hasMoreBefore; - addHistoryCoverageRange({ - coverageState: historyCoverageRef.current, - from: recovery.cursorTimestamp, - interval: requestedInterval.value, - intervalSeconds: requestedInterval.seconds, - seriesKey, - to: initialEarliestTimestamp - 1, - }); - if (!recovery.points.length) { - return; - } - setChartData((currentData) => { - if ( - currentData?.seriesKey !== seriesKey || - currentData.interval !== requestedInterval.value - ) { - return currentData; - } - return { - ...currentData, - chartPictureVersion: currentData.chartPictureVersion + 1, - points: mergeKLinePoints(currentData.points, recovery.points), - }; - }); - }; + const applyRecoveryProgress = createHistoryGapRecoveryProgressHandler({ + coverageState: historyCoverageRef.current, + interval: requestedInterval.value, + intervalSeconds: requestedInterval.seconds, + isActive: () => + !isCancelled && + latestRequestIdRef.current === requestId && + historyPaginationRef.current === pagination, + onPoints: (recoveryPoints) => + setChartData((currentData) => + mergeScopedChartDataPoints({ + currentData, + interval: requestedInterval.value, + points: recoveryPoints, + seriesKey, + }), + ), + pagination, + seriesKey, + timeTo: initialEarliestTimestamp - 1, + }); try { const recovery = await recoverOlderHistoryFromBoundary({ historyProvider, @@ -4010,7 +4116,11 @@ export function useTradingViewNativeKLine({ ); } } finally { - if (historyPaginationRef.current === pagination) { + if ( + historyPaginationRef.current === pagination && + pagination.abortController === abortController + ) { + pagination.abortController = undefined; pagination.isLoading = false; } } From c93ecf16543effc3890ed67221c1a978555ab5db Mon Sep 17 00:00:00 2001 From: limichange Date: Tue, 18 Aug 2026 10:21:31 +0800 Subject: [PATCH 03/15] fix: preload native kline history by viewport OK-60116 --- .../data/useTradingViewNativeKLine.test.ts | 181 +++++++++++++++--- .../data/useTradingViewNativeKLine.ts | 83 ++++++-- 2 files changed, 217 insertions(+), 47 deletions(-) diff --git a/packages/kit/src/components/TradingView/TradingViewNative/data/useTradingViewNativeKLine.test.ts b/packages/kit/src/components/TradingView/TradingViewNative/data/useTradingViewNativeKLine.test.ts index 67e9befb5789..0c3b9f1c05e6 100644 --- a/packages/kit/src/components/TradingView/TradingViewNative/data/useTradingViewNativeKLine.test.ts +++ b/packages/kit/src/components/TradingView/TradingViewNative/data/useTradingViewNativeKLine.test.ts @@ -993,6 +993,122 @@ describe('TradingViewNative K-line data state machine', () => { }, ); + it.each([ + ['1', 60], + ['5', 5 * 60], + ] as const)( + 'preloads %s-minute Market history from half a visible screen to one screen', + async (activeInterval, intervalSeconds) => { + const currentTimestamp = 2_000_000; + const initialFirstTimestamp = currentTimestamp - 298 * intervalSeconds; + const olderFirstTimestamp = initialFirstTimestamp - 30 * intervalSeconds; + mockHistoryBatchSize = 299; + mockHistoryRequestCandleCount = 2000; + mockReadTradingViewNativeActiveInterval.mockReturnValue(activeInterval); + jest.spyOn(Date, 'now').mockReturnValue(currentTimestamp * 1000); + mockFetchHistory + .mockResolvedValueOnce( + buildMultiPointResponse( + Array.from({ length: 299 }, (_, index) => ({ + close: 100 + index, + timestamp: initialFirstTimestamp + index * intervalSeconds, + })), + ), + ) + .mockResolvedValueOnce( + buildMultiPointResponse( + Array.from({ length: 30 }, (_, index) => ({ + close: 70 + index, + timestamp: olderFirstTimestamp + index * intervalSeconds, + })), + ), + ); + const { result } = renderHook(() => + useTradingViewNativeKLine({ source: buildMarketSource() }), + ); + + await waitFor(() => expect(result.current.points).toHaveLength(299)); + act(() => + result.current.handleVisiblePointRangeChange({ + endIndex: 91, + startIndex: 31, + }), + ); + expect(mockFetchHistory).toHaveBeenCalledTimes(1); + + act(() => + result.current.handleVisiblePointRangeChange({ + endIndex: 90, + startIndex: 30, + }), + ); + await waitFor(() => expect(mockFetchHistory).toHaveBeenCalledTimes(2)); + expect(mockFetchHistory.mock.calls[1]?.[0]).toEqual( + expect.objectContaining({ + timeFrom: initialFirstTimestamp - 30 * intervalSeconds - 1, + timeTo: initialFirstTimestamp - 1, + }), + ); + await waitFor(() => expect(result.current.points).toHaveLength(329)); + expect(result.current.points[0]?.t).toBe(olderFirstTimestamp); + }, + ); + + it('continues sparse minute preloading until the full-screen buffer target', async () => { + const currentTimestamp = 2_100_000; + const initialFirstTimestamp = 2_000_000; + const activeDays = Array.from({ length: 10 }, (_, index) => ({ + close: 70 + index, + timestamp: 700_000 + index * 24 * 60 * 60, + })); + mockHistoryBatchSize = 299; + mockHistoryRequestCandleCount = 2000; + mockReadTradingViewNativeActiveInterval.mockReturnValue('1'); + jest.spyOn(Date, 'now').mockReturnValue(currentTimestamp * 1000); + let activeIntervalRequestCount = 0; + let dailyRequestCount = 0; + mockFetchHistory.mockImplementation(async ({ interval, timeFrom }) => { + if (interval.value === '1W') { + return buildResponse(50, 500_000); + } + if (interval.value === '1D') { + dailyRequestCount += 1; + return dailyRequestCount === 1 + ? buildResponse(60, 600_000) + : buildMultiPointResponse(activeDays); + } + + activeIntervalRequestCount += 1; + if (activeIntervalRequestCount === 1) { + return buildMultiPointResponse( + Array.from({ length: 299 }, (_, index) => ({ + close: 100 + index, + timestamp: initialFirstTimestamp + index * 60, + })), + ); + } + if (activeIntervalRequestCount === 2) { + return { points: [], total: 0 }; + } + return buildResponse(60 + activeIntervalRequestCount, timeFrom); + }); + const { result } = renderHook(() => + useTradingViewNativeKLine({ source: buildMarketSource() }), + ); + + await waitFor(() => expect(result.current.points).toHaveLength(299)); + act(() => + result.current.handleVisiblePointRangeChange({ + endIndex: 10, + startIndex: 0, + }), + ); + + await waitFor(() => expect(result.current.points).toHaveLength(309)); + expect(activeIntervalRequestCount).toBe(12); + expect(dailyRequestCount).toBe(2); + }); + it('stops sparse Market pagination at the refined daily boundary', async () => { mockHistoryBatchSize = 2; mockHistoryRequestCandleCount = 2; @@ -3510,69 +3626,78 @@ describe('TradingViewNative K-line data state machine', () => { expect(handleRealtimePoint).toHaveBeenCalledTimes(1); }); - it('loads and prepends one older page near the left boundary', async () => { - mockHistoryBatchSize = 2; - mockHistoryRequestCandleCount = 2; + it('preloads older history from a half-screen buffer to a full screen', async () => { + mockHistoryBatchSize = 150; + mockHistoryRequestCandleCount = 45; const olderHistoryRequest = createDeferred(); mockFetchHistory .mockResolvedValueOnce( - buildMultiPointResponse([ - { close: 100, timestamp: 1_000_000 }, - { close: 110, timestamp: 1_003_600 }, - ]), + buildSequentialResponse({ + count: 150, + firstTimestamp: 1_000_000, + startingClose: 100, + }), ) .mockReturnValueOnce(olderHistoryRequest.promise) .mockResolvedValueOnce( buildMultiPointResponse([ - { close: 110, timestamp: 1_003_600 }, - { close: 120, timestamp: 1_007_200 }, + { close: 249, timestamp: 1_536_400 }, + { close: 250, timestamp: 1_540_000 }, ]), ); const { result } = renderHook(() => useTradingViewNativeKLine({ source: buildMarketSource() }), ); - await waitFor(() => expect(result.current.points).toHaveLength(2)); - act(() => result.current.handleVisiblePointRangeChange({ startIndex: 21 })); + await waitFor(() => expect(result.current.points).toHaveLength(150)); + act(() => + result.current.handleVisiblePointRangeChange({ + endIndex: 136, + startIndex: 46, + }), + ); expect(mockFetchHistory).toHaveBeenCalledTimes(1); act(() => { - result.current.handleVisiblePointRangeChange({ startIndex: 20 }); - result.current.handleVisiblePointRangeChange({ startIndex: 0 }); + result.current.handleVisiblePointRangeChange({ + endIndex: 135, + startIndex: 45, + }); + result.current.handleVisiblePointRangeChange({ + endIndex: 90, + startIndex: 0, + }); }); await waitFor(() => expect(mockFetchHistory).toHaveBeenCalledTimes(2)); expect(mockFetchHistory.mock.calls[1]?.[0]).toEqual( expect.objectContaining({ interval: expect.objectContaining({ value: '60' }), - timeFrom: 992_799, + timeFrom: 837_999, timeTo: 999_999, }), ); await act(async () => { olderHistoryRequest.resolve( - buildMultiPointResponse([ - { close: 80, timestamp: 992_800 }, - { close: 90, timestamp: 996_400 }, - ]), + buildSequentialResponse({ + count: 45, + firstTimestamp: 838_000, + startingClose: 55, + }), ); await olderHistoryRequest.promise; }); - await waitFor(() => - expect(result.current.points.map((point) => point.c)).toEqual([ - 80, 90, 100, 110, - ]), - ); + await waitFor(() => expect(result.current.points).toHaveLength(195)); + expect(result.current.points.slice(0, 3).map((point) => point.c)).toEqual([ + 55, 56, 57, + ]); updateVisibility(false); updateVisibility(true); await waitFor(() => expect(mockFetchHistory).toHaveBeenCalledTimes(3)); - await waitFor(() => - expect(result.current.points.map((point) => point.c)).toEqual([ - 80, 90, 100, 110, 120, - ]), - ); + await waitFor(() => expect(result.current.points).toHaveLength(196)); + expect(result.current.points.at(-1)?.c).toBe(250); }); it('retries a transient older-history failure automatically', async () => { diff --git a/packages/kit/src/components/TradingView/TradingViewNative/data/useTradingViewNativeKLine.ts b/packages/kit/src/components/TradingView/TradingViewNative/data/useTradingViewNativeKLine.ts index 030893d827f7..9ccf11f0f518 100644 --- a/packages/kit/src/components/TradingView/TradingViewNative/data/useTradingViewNativeKLine.ts +++ b/packages/kit/src/components/TradingView/TradingViewNative/data/useTradingViewNativeKLine.ts @@ -50,7 +50,12 @@ import type { ITradingViewNativeViewportTarget, } from '../utils/chartViewport'; -const HISTORY_LOAD_MORE_THRESHOLD = 20; +const HISTORY_GAP_SEARCH_PADDING_POINT_COUNT = 20; +const HISTORY_NEWER_LOAD_MORE_THRESHOLD = 20; +const HISTORY_OLDER_LOAD_MORE_FALLBACK_THRESHOLD = 20; +const HISTORY_OLDER_LOAD_MORE_FALLBACK_TARGET_POINT_COUNT = Math.ceil( + TRADING_VIEW_NATIVE_TIME_RANGE_MAX_CANDLE_COUNT / 2, +); const HISTORY_GAP_REQUEST_CANDLE_COUNT = 100; const HISTORY_GAP_EMPTY_SCAN_PAGE_COUNT = 4; const HISTORY_BOUNDARY_PREFETCH_CACHE_MAX_SIZE = 100; @@ -60,9 +65,6 @@ const HISTORY_BOUNDARY_SEARCH_INTERVAL_VALUE: ITradingViewNativeChartInterval = const SPARSE_MARKET_HISTORY_LOCATOR_INTERVAL_VALUE: ITradingViewNativeChartInterval = '1D'; const MAX_SPARSE_MARKET_HISTORY_ACTIVE_DAY_REQUEST_COUNT = 8; -const MARKET_MINUTE_HISTORY_LOAD_MORE_TARGET_POINT_COUNT = Math.ceil( - TRADING_VIEW_NATIVE_TIME_RANGE_MAX_CANDLE_COUNT / 2, -); const HISTORY_RETRY_DELAYS = [1000, 3000] as const; const MAX_VIEWPORT_HISTORY_PAGE_COUNT = 20; const MAX_VIEWPORT_HISTORY_BOUNDARY_SEARCH_COUNT = 32; @@ -402,6 +404,39 @@ function getHasPotentialEarlierHistory({ return pageHasMoreHistory; } +function getOlderHistoryPreloadPointCount({ + endIndex, + startIndex, +}: { + endIndex?: number; + startIndex: number; +}) { + if (!Number.isFinite(startIndex)) { + return 0; + } + + const loadedPointCountBeforeViewport = Math.max(Math.floor(startIndex), 0); + if (endIndex === undefined) { + return loadedPointCountBeforeViewport <= + HISTORY_OLDER_LOAD_MORE_FALLBACK_THRESHOLD + ? HISTORY_OLDER_LOAD_MORE_FALLBACK_TARGET_POINT_COUNT + : 0; + } + if (!Number.isFinite(endIndex)) { + return 0; + } + const visiblePointCount = Math.max( + Math.ceil(endIndex) - Math.floor(startIndex), + 1, + ); + const preloadTriggerPointCount = Math.ceil(visiblePointCount / 2); + if (loadedPointCountBeforeViewport > preloadTriggerPointCount) { + return 0; + } + + return Math.max(visiblePointCount - loadedPointCountBeforeViewport, 0); +} + function getHistoryBoundaryPrefetchCacheKey(seriesKey: string) { return `${seriesKey}:${HISTORY_BOUNDARY_SEARCH_INTERVAL_VALUE}`; } @@ -707,6 +742,7 @@ function prefetchHistoryBoundaryPage({ async function recoverOlderHistoryFromBoundary({ historyProvider, interval, + maxPageCount = MAX_SPARSE_MARKET_HISTORY_ACTIVE_DAY_REQUEST_COUNT, onProgress, seriesKey, signal, @@ -774,6 +810,7 @@ async function recoverOlderHistoryFromBoundary({ return null; } + const normalizedMaxPageCount = Math.max(Math.floor(maxPageCount), 1); const normalizedTargetPointCount = Math.max(Math.floor(targetPointCount), 1); let cursorTimeTo = Math.floor(timeTo); let historySource: 'fallback' | undefined; @@ -791,7 +828,7 @@ async function recoverOlderHistoryFromBoundary({ }; for ( let requestIndex = 0; - requestIndex < MAX_SPARSE_MARKET_HISTORY_ACTIVE_DAY_REQUEST_COUNT && + requestIndex < normalizedMaxPageCount && activeDayIndex >= 0 && cursorTimeTo >= boundaryTimestamp && points.length < normalizedTargetPointCount; @@ -1264,7 +1301,7 @@ function getVisibleHistoryGap({ endIndex, intervalSeconds, points, - searchPaddingPointCount = HISTORY_LOAD_MORE_THRESHOLD, + searchPaddingPointCount = HISTORY_GAP_SEARCH_PADDING_POINT_COUNT, startIndex, }: { coverageRanges: IHistoryCoverageRange[]; @@ -3043,7 +3080,7 @@ export function useTradingViewNativeKLine({ endIndex !== undefined && Number.isFinite(endIndex) && endIndex >= - currentChartData.points.length - HISTORY_LOAD_MORE_THRESHOLD; + currentChartData.points.length - HISTORY_NEWER_LOAD_MORE_THRESHOLD; if (isNearNewerBoundary && pagination.hasMoreAfter) { const currentTimestamp = Math.floor(Date.now() / 1000); const newerCursorTimestamp = @@ -3256,7 +3293,14 @@ export function useTradingViewNativeKLine({ return; } - if (startIndex > HISTORY_LOAD_MORE_THRESHOLD || !pagination.hasMore) { + if (!pagination.hasMore) { + return; + } + const olderHistoryPreloadPointCount = getOlderHistoryPreloadPointCount({ + endIndex, + startIndex, + }); + if (!olderHistoryPreloadPointCount) { return; } @@ -3277,7 +3321,7 @@ export function useTradingViewNativeKLine({ (interval.value === '1' || interval.value === '5'); const timeFrom = getHistoryTimeFrom({ candleCount: isMarketMinuteHistory - ? MARKET_MINUTE_HISTORY_LOAD_MORE_TARGET_POINT_COUNT + ? olderHistoryPreloadPointCount : historyProvider.getHistoryRequestCandleCount(interval), intervalSeconds: interval.seconds, timeTo, @@ -3315,9 +3359,7 @@ export function useTradingViewNativeKLine({ (point) => point.t < earliestTimestamp, ); let olderPoints = isMarketMinuteHistory - ? receivedOlderPoints.slice( - -MARKET_MINUTE_HISTORY_LOAD_MORE_TARGET_POINT_COUNT, - ) + ? receivedOlderPoints.slice(-olderHistoryPreloadPointCount) : receivedOlderPoints; let paginationCursorTimestamp = olderPoints[0]?.t; const pageHasMoreHistory = historyProvider.hasMoreHistory({ @@ -3339,8 +3381,7 @@ export function useTradingViewNativeKLine({ historySource !== 'fallback' && (interval.value === '1' || interval.value === '5') && !pageHasMoreHistory && - olderPoints.length < - MARKET_MINUTE_HISTORY_LOAD_MORE_TARGET_POINT_COUNT; + olderPoints.length < olderHistoryPreloadPointCount; const publishOlderPoints = ( pointsToPublish: IMarketTokenKLineDataPoint[], ) => { @@ -3372,6 +3413,10 @@ export function useTradingViewNativeKLine({ if (shouldRecoverSparseHistory) { publishOlderPoints(olderPoints); const recoveryTimeTo = Math.max(timeFrom - 1, 0); + const recoveryTargetPointCount = Math.max( + olderHistoryPreloadPointCount - olderPoints.length, + 1, + ); const applyRecoveryProgress = createHistoryGapRecoveryProgressHandler({ coverageState: historyCoverageRef.current, @@ -3388,14 +3433,14 @@ export function useTradingViewNativeKLine({ const recovery = await recoverOlderHistoryFromBoundary({ historyProvider, interval, + maxPageCount: Math.max( + MAX_SPARSE_MARKET_HISTORY_ACTIVE_DAY_REQUEST_COUNT, + recoveryTargetPointCount, + ), onProgress: applyRecoveryProgress, seriesKey, signal: abortController.signal, - targetPointCount: Math.max( - MARKET_MINUTE_HISTORY_LOAD_MORE_TARGET_POINT_COUNT - - olderPoints.length, - 1, - ), + targetPointCount: recoveryTargetPointCount, timeTo: recoveryTimeTo, }); if ( From b57cf1bd6ea1a8d6df497ba4ea113ba535fc05e2 Mon Sep 17 00:00:00 2001 From: limichange Date: Tue, 18 Aug 2026 11:35:07 +0800 Subject: [PATCH 04/15] fix: batch sparse kline recovery requests OK-60116 --- .../data/useTradingViewNativeKLine.test.ts | 118 +++++++++++++----- .../data/useTradingViewNativeKLine.ts | 83 +++++++----- 2 files changed, 140 insertions(+), 61 deletions(-) diff --git a/packages/kit/src/components/TradingView/TradingViewNative/data/useTradingViewNativeKLine.test.ts b/packages/kit/src/components/TradingView/TradingViewNative/data/useTradingViewNativeKLine.test.ts index 0c3b9f1c05e6..9f7cdcd85483 100644 --- a/packages/kit/src/components/TradingView/TradingViewNative/data/useTradingViewNativeKLine.test.ts +++ b/packages/kit/src/components/TradingView/TradingViewNative/data/useTradingViewNativeKLine.test.ts @@ -783,6 +783,58 @@ describe('TradingViewNative K-line data state machine', () => { }, ); + it('crosses more than eight empty active days during initial sparse recovery', async () => { + const currentTimestamp = 30_000_000; + const oldestActiveDayTimestamp = 10_000_000; + const boundaryTimestamp = oldestActiveDayTimestamp - 24 * 60 * 60; + const activeDays = Array.from({ length: 10 }, (_, index) => ({ + close: 70 + index, + timestamp: oldestActiveDayTimestamp + index * 24 * 60 * 60, + })); + mockHistoryBatchSize = 299; + mockHistoryRequestCandleCount = 2000; + mockReadTradingViewNativeActiveInterval.mockReturnValue('1'); + jest.spyOn(Date, 'now').mockReturnValue(currentTimestamp * 1000); + let activeIntervalRequestCount = 0; + let dailyRequestCount = 0; + mockFetchHistory.mockImplementation( + async ({ interval, timeFrom, timeTo }) => { + if (interval.value === '1W') { + return buildResponse(50, boundaryTimestamp - 3600); + } + if (interval.value === '1D') { + dailyRequestCount += 1; + return dailyRequestCount === 1 + ? buildResponse(60, boundaryTimestamp) + : buildMultiPointResponse(activeDays); + } + + activeIntervalRequestCount += 1; + if (activeIntervalRequestCount === 1) { + return buildMultiPointResponse([ + { close: 100, timestamp: currentTimestamp - 60 }, + { close: 110, timestamp: currentTimestamp }, + ]); + } + if ( + timeFrom <= oldestActiveDayTimestamp && + timeTo >= oldestActiveDayTimestamp + ) { + return buildResponse(60, oldestActiveDayTimestamp); + } + return { points: [], total: 0 }; + }, + ); + const { result } = renderHook(() => + useTradingViewNativeKLine({ source: buildMarketSource() }), + ); + + await waitFor(() => expect(result.current.points).toHaveLength(3)); + expect(result.current.points[0]?.t).toBe(oldestActiveDayTimestamp); + expect(activeIntervalRequestCount).toBe(6); + expect(activeIntervalRequestCount - 1).toBeLessThanOrEqual(8); + }); + it.each([ ['1', 60], ['5', 5 * 60], @@ -1055,11 +1107,11 @@ describe('TradingViewNative K-line data state machine', () => { ); it('continues sparse minute preloading until the full-screen buffer target', async () => { - const currentTimestamp = 2_100_000; - const initialFirstTimestamp = 2_000_000; - const activeDays = Array.from({ length: 10 }, (_, index) => ({ + const currentTimestamp = 100_000_000; + const initialFirstTimestamp = 90_000_000; + const activeDays = Array.from({ length: 100 }, (_, index) => ({ close: 70 + index, - timestamp: 700_000 + index * 24 * 60 * 60, + timestamp: 70_000_000 + index * 24 * 60 * 60, })); mockHistoryBatchSize = 299; mockHistoryRequestCandleCount = 2000; @@ -1067,31 +1119,38 @@ describe('TradingViewNative K-line data state machine', () => { jest.spyOn(Date, 'now').mockReturnValue(currentTimestamp * 1000); let activeIntervalRequestCount = 0; let dailyRequestCount = 0; - mockFetchHistory.mockImplementation(async ({ interval, timeFrom }) => { - if (interval.value === '1W') { - return buildResponse(50, 500_000); - } - if (interval.value === '1D') { - dailyRequestCount += 1; - return dailyRequestCount === 1 - ? buildResponse(60, 600_000) - : buildMultiPointResponse(activeDays); - } + mockFetchHistory.mockImplementation( + async ({ interval, timeFrom, timeTo }) => { + if (interval.value === '1W') { + return buildResponse(50, 59_900_000); + } + if (interval.value === '1D') { + dailyRequestCount += 1; + return dailyRequestCount === 1 + ? buildResponse(60, 60_000_000) + : buildMultiPointResponse(activeDays); + } - activeIntervalRequestCount += 1; - if (activeIntervalRequestCount === 1) { + activeIntervalRequestCount += 1; + if (activeIntervalRequestCount === 1) { + return buildMultiPointResponse( + Array.from({ length: 299 }, (_, index) => ({ + close: 100 + index, + timestamp: initialFirstTimestamp + index * 60, + })), + ); + } + if (activeIntervalRequestCount === 2) { + return { points: [], total: 0 }; + } return buildMultiPointResponse( - Array.from({ length: 299 }, (_, index) => ({ - close: 100 + index, - timestamp: initialFirstTimestamp + index * 60, - })), + activeDays.filter( + (activeDay) => + activeDay.timestamp >= timeFrom && activeDay.timestamp <= timeTo, + ), ); - } - if (activeIntervalRequestCount === 2) { - return { points: [], total: 0 }; - } - return buildResponse(60 + activeIntervalRequestCount, timeFrom); - }); + }, + ); const { result } = renderHook(() => useTradingViewNativeKLine({ source: buildMarketSource() }), ); @@ -1099,13 +1158,14 @@ describe('TradingViewNative K-line data state machine', () => { await waitFor(() => expect(result.current.points).toHaveLength(299)); act(() => result.current.handleVisiblePointRangeChange({ - endIndex: 10, + endIndex: 100, startIndex: 0, }), ); - await waitFor(() => expect(result.current.points).toHaveLength(309)); - expect(activeIntervalRequestCount).toBe(12); + await waitFor(() => expect(activeIntervalRequestCount).toBe(10)); + expect(result.current.points).toHaveLength(399); + expect(activeIntervalRequestCount - 2).toBe(8); expect(dailyRequestCount).toBe(2); }); diff --git a/packages/kit/src/components/TradingView/TradingViewNative/data/useTradingViewNativeKLine.ts b/packages/kit/src/components/TradingView/TradingViewNative/data/useTradingViewNativeKLine.ts index 9ccf11f0f518..fee6969f8652 100644 --- a/packages/kit/src/components/TradingView/TradingViewNative/data/useTradingViewNativeKLine.ts +++ b/packages/kit/src/components/TradingView/TradingViewNative/data/useTradingViewNativeKLine.ts @@ -64,7 +64,7 @@ const HISTORY_BOUNDARY_SEARCH_INTERVAL_VALUE: ITradingViewNativeChartInterval = '1W'; const SPARSE_MARKET_HISTORY_LOCATOR_INTERVAL_VALUE: ITradingViewNativeChartInterval = '1D'; -const MAX_SPARSE_MARKET_HISTORY_ACTIVE_DAY_REQUEST_COUNT = 8; +const MAX_SPARSE_MARKET_HISTORY_REQUEST_COUNT = 8; const HISTORY_RETRY_DELAYS = [1000, 3000] as const; const MAX_VIEWPORT_HISTORY_PAGE_COUNT = 20; const MAX_VIEWPORT_HISTORY_BOUNDARY_SEARCH_COUNT = 32; @@ -739,10 +739,46 @@ function prefetchHistoryBoundaryPage({ return promise; } +function getSparseHistoryRequestRanges({ + activeDays, + boundaryTimestamp, + locatorIntervalSeconds, + timeTo, +}: { + activeDays: IMarketTokenKLineDataPoint[]; + boundaryTimestamp: number; + locatorIntervalSeconds: number; + timeTo: number; +}) { + // Spread every locator candidate across the fixed request budget instead of + // consuming one minute-history request for each active day. + const activeDayBatchSize = Math.max( + Math.ceil(activeDays.length / MAX_SPARSE_MARKET_HISTORY_REQUEST_COUNT), + 1, + ); + const ranges: { timeFrom: number; timeTo: number }[] = []; + for ( + let batchEndIndex = activeDays.length; + batchEndIndex > 0; + batchEndIndex -= activeDayBatchSize + ) { + const batchStartIndex = Math.max(batchEndIndex - activeDayBatchSize, 0); + const oldestActiveDay = activeDays[batchStartIndex]; + const newestActiveDay = activeDays[batchEndIndex - 1]; + if (!oldestActiveDay || !newestActiveDay) { + break; + } + ranges.push({ + timeFrom: Math.max(oldestActiveDay.t, boundaryTimestamp), + timeTo: Math.min(timeTo, newestActiveDay.t + locatorIntervalSeconds - 1), + }); + } + return ranges; +} + async function recoverOlderHistoryFromBoundary({ historyProvider, interval, - maxPageCount = MAX_SPARSE_MARKET_HISTORY_ACTIVE_DAY_REQUEST_COUNT, onProgress, seriesKey, signal, @@ -751,7 +787,6 @@ async function recoverOlderHistoryFromBoundary({ }: { historyProvider: ITradingViewNativeDataProvider; interval: ITradingViewNativeKLineInterval; - maxPageCount?: number; onProgress?: (result: IHistoryGapRecoveryResult) => void; seriesKey: string; signal: AbortSignal; @@ -810,12 +845,16 @@ async function recoverOlderHistoryFromBoundary({ return null; } - const normalizedMaxPageCount = Math.max(Math.floor(maxPageCount), 1); const normalizedTargetPointCount = Math.max(Math.floor(targetPointCount), 1); + const requestRanges = getSparseHistoryRequestRanges({ + activeDays, + boundaryTimestamp, + locatorIntervalSeconds: locatorInterval.seconds, + timeTo, + }); let cursorTimeTo = Math.floor(timeTo); let historySource: 'fallback' | undefined; let points: IMarketTokenKLineDataPoint[] = []; - let activeDayIndex = activeDays.length - 1; const buildResult = (): IHistoryGapRecoveryResult => { const hasMoreBefore = cursorTimeTo >= boundaryTimestamp; return { @@ -826,31 +865,15 @@ async function recoverOlderHistoryFromBoundary({ points, }; }; - for ( - let requestIndex = 0; - requestIndex < normalizedMaxPageCount && - activeDayIndex >= 0 && - cursorTimeTo >= boundaryTimestamp && - points.length < normalizedTargetPointCount; - requestIndex += 1 - ) { - while (activeDayIndex >= 0) { - const candidateActiveDay = activeDays[activeDayIndex]; - if (!candidateActiveDay || candidateActiveDay.t <= cursorTimeTo) { - break; - } - activeDayIndex -= 1; - } - const activeDay = activeDays[activeDayIndex]; - if (!activeDay) { + for (const requestRange of requestRanges) { + if ( + cursorTimeTo < boundaryTimestamp || + points.length >= normalizedTargetPointCount + ) { break; } - activeDayIndex -= 1; - const pageTimeFrom = Math.max(activeDay.t, boundaryTimestamp); - const pageTimeTo = Math.min( - cursorTimeTo, - activeDay.t + locatorInterval.seconds - 1, - ); + const pageTimeFrom = requestRange.timeFrom; + const pageTimeTo = Math.min(cursorTimeTo, requestRange.timeTo); const data = await fetchRequiredHistoryPage({ historyProvider, request: { @@ -3433,10 +3456,6 @@ export function useTradingViewNativeKLine({ const recovery = await recoverOlderHistoryFromBoundary({ historyProvider, interval, - maxPageCount: Math.max( - MAX_SPARSE_MARKET_HISTORY_ACTIVE_DAY_REQUEST_COUNT, - recoveryTargetPointCount, - ), onProgress: applyRecoveryProgress, seriesKey, signal: abortController.signal, From bcf21516c75220157827e8e95516ccd987ea6a13 Mon Sep 17 00:00:00 2001 From: limichange Date: Tue, 18 Aug 2026 12:36:57 +0800 Subject: [PATCH 05/15] fix: paginate capped sparse kline batches OK-60116 --- .../data/useTradingViewNativeKLine.test.ts | 98 ++++++++++++++++ .../data/useTradingViewNativeKLine.ts | 109 ++++++++++++------ 2 files changed, 170 insertions(+), 37 deletions(-) diff --git a/packages/kit/src/components/TradingView/TradingViewNative/data/useTradingViewNativeKLine.test.ts b/packages/kit/src/components/TradingView/TradingViewNative/data/useTradingViewNativeKLine.test.ts index 9f7cdcd85483..b07e56bfed5a 100644 --- a/packages/kit/src/components/TradingView/TradingViewNative/data/useTradingViewNativeKLine.test.ts +++ b/packages/kit/src/components/TradingView/TradingViewNative/data/useTradingViewNativeKLine.test.ts @@ -1169,6 +1169,104 @@ describe('TradingViewNative K-line data state machine', () => { expect(dailyRequestCount).toBe(2); }); + it('continues paging inside a sparse recovery batch after a full Market page', async () => { + const intervalSeconds = 60; + const currentTimestamp = 1_000_120; + const boundaryTimestamp = 100_000; + const olderActiveDayTimestamp = 200_000; + const newerActiveDayTimestamp = 300_000; + const olderBatchPoints = Array.from({ length: 6 }, (_, index) => ({ + close: 70 + index, + timestamp: olderActiveDayTimestamp + 100 + index * intervalSeconds, + })); + const newerBatchPoints = Array.from({ length: 3 }, (_, index) => ({ + close: 80 + index, + timestamp: newerActiveDayTimestamp + 100 + index * intervalSeconds, + })); + const historicalPoints = [...olderBatchPoints, ...newerBatchPoints]; + mockHistoryBatchSize = 3; + mockHistoryRequestCandleCount = 2000; + mockReadTradingViewNativeActiveInterval.mockReturnValue('1'); + jest.spyOn(Date, 'now').mockReturnValue(currentTimestamp * 1000); + let activeIntervalRequestCount = 0; + let dailyRequestCount = 0; + mockFetchHistory.mockImplementation( + async ({ interval, timeFrom, timeTo }) => { + if (interval.value === '1W') { + return buildResponse(50, boundaryTimestamp - 3600); + } + if (interval.value === '1D') { + dailyRequestCount += 1; + return dailyRequestCount === 1 + ? buildResponse(60, boundaryTimestamp) + : buildMultiPointResponse([ + { close: 70, timestamp: olderActiveDayTimestamp }, + { close: 80, timestamp: newerActiveDayTimestamp }, + ]); + } + + activeIntervalRequestCount += 1; + if (activeIntervalRequestCount === 1) { + return buildMultiPointResponse( + Array.from({ length: 3 }, (_, index) => ({ + close: 100 + index, + timestamp: + currentTimestamp - + 2 * intervalSeconds + + index * intervalSeconds, + })), + ); + } + return buildMultiPointResponse( + historicalPoints + .filter( + (point) => + point.timestamp >= timeFrom && point.timestamp <= timeTo, + ) + .slice(-mockHistoryBatchSize), + ); + }, + ); + const { result } = renderHook(() => + useTradingViewNativeKLine({ source: buildMarketSource() }), + ); + + await waitFor(() => expect(result.current.points).toHaveLength(3)); + act(() => + result.current.handleVisiblePointRangeChange({ + endIndex: 3, + startIndex: 0, + }), + ); + await waitFor(() => expect(result.current.points).toHaveLength(6)); + + act(() => + result.current.handleVisiblePointRangeChange({ + endIndex: 5, + startIndex: 0, + }), + ); + await waitFor(() => expect(result.current.points).toHaveLength(11)); + + const activeIntervalRequests = mockFetchHistory.mock.calls + .map(([request]) => request) + .filter((request) => request.interval.value === '1'); + expect(activeIntervalRequests).toHaveLength(6); + expect(activeIntervalRequests[4]).toEqual( + expect.objectContaining({ + timeFrom: olderActiveDayTimestamp, + timeTo: olderActiveDayTimestamp + 24 * 60 * 60 - 1, + }), + ); + expect(activeIntervalRequests[5]).toEqual( + expect.objectContaining({ + timeFrom: olderActiveDayTimestamp, + timeTo: olderBatchPoints[3].timestamp - 1, + }), + ); + expect(result.current.points[0]?.t).toBe(olderBatchPoints[1].timestamp); + }); + it('stops sparse Market pagination at the refined daily boundary', async () => { mockHistoryBatchSize = 2; mockHistoryRequestCandleCount = 2; diff --git a/packages/kit/src/components/TradingView/TradingViewNative/data/useTradingViewNativeKLine.ts b/packages/kit/src/components/TradingView/TradingViewNative/data/useTradingViewNativeKLine.ts index fee6969f8652..fa00b37f6b84 100644 --- a/packages/kit/src/components/TradingView/TradingViewNative/data/useTradingViewNativeKLine.ts +++ b/packages/kit/src/components/TradingView/TradingViewNative/data/useTradingViewNativeKLine.ts @@ -855,6 +855,7 @@ async function recoverOlderHistoryFromBoundary({ let cursorTimeTo = Math.floor(timeTo); let historySource: 'fallback' | undefined; let points: IMarketTokenKLineDataPoint[] = []; + let requestCount = 0; const buildResult = (): IHistoryGapRecoveryResult => { const hasMoreBefore = cursorTimeTo >= boundaryTimestamp; return { @@ -868,53 +869,87 @@ async function recoverOlderHistoryFromBoundary({ for (const requestRange of requestRanges) { if ( cursorTimeTo < boundaryTimestamp || - points.length >= normalizedTargetPointCount + points.length >= normalizedTargetPointCount || + requestCount >= MAX_SPARSE_MARKET_HISTORY_REQUEST_COUNT ) { break; } const pageTimeFrom = requestRange.timeFrom; - const pageTimeTo = Math.min(cursorTimeTo, requestRange.timeTo); - const data = await fetchRequiredHistoryPage({ - historyProvider, - request: { - interval, - signal, - timeFrom: pageTimeFrom, - timeTo: pageTimeTo, - }, - unavailableMessage: - 'No candle history response is available for sparse history recovery', - }); - if (signal.aborted) { - return null; - } + let pageTimeTo = Math.min(cursorTimeTo, requestRange.timeTo); + while ( + pageTimeTo >= pageTimeFrom && + points.length < normalizedTargetPointCount && + requestCount < MAX_SPARSE_MARKET_HISTORY_REQUEST_COUNT + ) { + const data = await fetchRequiredHistoryPage({ + historyProvider, + request: { + interval, + signal, + timeFrom: pageTimeFrom, + timeTo: pageTimeTo, + }, + unavailableMessage: + 'No candle history response is available for sparse history recovery', + }); + requestCount += 1; + if (signal.aborted) { + return null; + } - historySource = data.historySource; - if (historySource === 'fallback') { - return { - boundaryTimestamp, - cursorTimestamp: pageTimeFrom, - hasMoreBefore: false, - historySource, - points: [], - }; - } - const mergedPoints = mergeKLinePoints( - points, - normalizeKLinePointsInRange({ + historySource = data.historySource; + if (historySource === 'fallback') { + return { + boundaryTimestamp, + cursorTimestamp: pageTimeFrom, + hasMoreBefore: false, + historySource, + points: [], + }; + } + const pagePoints = normalizeKLinePointsInRange({ from: pageTimeFrom, points: data.points, to: pageTimeTo, - }), - ); - if (mergedPoints.length >= normalizedTargetPointCount) { - points = mergedPoints.slice(-normalizedTargetPointCount); - cursorTimeTo = (points[0]?.t ?? pageTimeFrom) - 1; - } else { + }); + const mergedPoints = mergeKLinePoints(points, pagePoints); + if (mergedPoints.length >= normalizedTargetPointCount) { + points = mergedPoints.slice(-normalizedTargetPointCount); + cursorTimeTo = (points[0]?.t ?? pageTimeFrom) - 1; + onProgress?.(buildResult()); + break; + } + points = mergedPoints; - cursorTimeTo = pageTimeFrom - 1; + const pageHasMoreHistory = historyProvider.hasMoreHistory({ + historySource: data.historySource, + interval, + receivedPointCount: data.points.length, + }); + if (!pageHasMoreHistory) { + cursorTimeTo = pageTimeFrom - 1; + onProgress?.(buildResult()); + break; + } + + const earliestPageTimestamp = pagePoints[0]?.t; + if (earliestPageTimestamp === undefined) { + return buildResult(); + } + const nextPageTimeTo = earliestPageTimestamp - 1; + if (nextPageTimeTo < pageTimeFrom) { + cursorTimeTo = pageTimeFrom - 1; + onProgress?.(buildResult()); + break; + } + if (nextPageTimeTo >= pageTimeTo) { + return buildResult(); + } + + cursorTimeTo = nextPageTimeTo; + pageTimeTo = nextPageTimeTo; + onProgress?.(buildResult()); } - onProgress?.(buildResult()); } return buildResult(); From d1ddd6b866fb694560ca34583601edb27121639d Mon Sep 17 00:00:00 2001 From: limichange Date: Tue, 18 Aug 2026 13:28:16 +0800 Subject: [PATCH 06/15] fix: preserve sparse kline preload page OK-60116 --- .../data/useTradingViewNativeKLine.test.ts | 66 ++++++++++++++++++- .../data/useTradingViewNativeKLine.ts | 4 +- 2 files changed, 67 insertions(+), 3 deletions(-) diff --git a/packages/kit/src/components/TradingView/TradingViewNative/data/useTradingViewNativeKLine.test.ts b/packages/kit/src/components/TradingView/TradingViewNative/data/useTradingViewNativeKLine.test.ts index b07e56bfed5a..1d394e137fec 100644 --- a/packages/kit/src/components/TradingView/TradingViewNative/data/useTradingViewNativeKLine.test.ts +++ b/packages/kit/src/components/TradingView/TradingViewNative/data/useTradingViewNativeKLine.test.ts @@ -783,6 +783,68 @@ describe('TradingViewNative K-line data state machine', () => { }, ); + it('keeps a capped sparse recovery page as viewport preload data', async () => { + const intervalSeconds = 60; + mockHistoryBatchSize = 299; + mockHistoryRequestCandleCount = 2000; + mockReadTradingViewNativeActiveInterval.mockReturnValue('1'); + jest.spyOn(Date, 'now').mockReturnValue(2_000_000_000); + let activeIntervalRequestCount = 0; + let dailyRequestCount = 0; + mockFetchHistory.mockImplementation(async ({ interval }) => { + if (interval.value === '1W') { + return buildResponse(50, 100_000); + } + if (interval.value === '1D') { + dailyRequestCount += 1; + return dailyRequestCount === 1 + ? buildResponse(60, 110_000) + : buildMultiPointResponse([ + { close: 70, timestamp: 740_000 }, + { close: 80, timestamp: 830_000 }, + ]); + } + + activeIntervalRequestCount += 1; + if (activeIntervalRequestCount === 1) { + return buildMultiPointResponse( + Array.from({ length: 7 }, (_, index) => ({ + close: 100 + index, + timestamp: 1_000_000 + index * intervalSeconds, + })), + ); + } + if (activeIntervalRequestCount === 2) { + return buildMultiPointResponse([ + { close: 90, timestamp: 830_100 }, + { close: 91, timestamp: 830_160 }, + ]); + } + return buildMultiPointResponse( + Array.from({ length: 299 }, (_, index) => ({ + close: 200 + index, + timestamp: 740_100 + index * intervalSeconds, + })), + ); + }); + const { result } = renderHook(() => + useTradingViewNativeKLine({ source: buildMarketSource() }), + ); + + await waitFor(() => expect(result.current.points).toHaveLength(308)); + expect(activeIntervalRequestCount).toBe(3); + + const visiblePointCount = 164; + act(() => + result.current.handleVisiblePointRangeChange({ + endIndex: result.current.points.length, + startIndex: result.current.points.length - visiblePointCount, + }), + ); + + expect(activeIntervalRequestCount).toBe(3); + }); + it('crosses more than eight empty active days during initial sparse recovery', async () => { const currentTimestamp = 30_000_000; const oldestActiveDayTimestamp = 10_000_000; @@ -1246,7 +1308,7 @@ describe('TradingViewNative K-line data state machine', () => { startIndex: 0, }), ); - await waitFor(() => expect(result.current.points).toHaveLength(11)); + await waitFor(() => expect(result.current.points).toHaveLength(12)); const activeIntervalRequests = mockFetchHistory.mock.calls .map(([request]) => request) @@ -1264,7 +1326,7 @@ describe('TradingViewNative K-line data state machine', () => { timeTo: olderBatchPoints[3].timestamp - 1, }), ); - expect(result.current.points[0]?.t).toBe(olderBatchPoints[1].timestamp); + expect(result.current.points[0]?.t).toBe(olderBatchPoints[0].timestamp); }); it('stops sparse Market pagination at the refined daily boundary', async () => { diff --git a/packages/kit/src/components/TradingView/TradingViewNative/data/useTradingViewNativeKLine.ts b/packages/kit/src/components/TradingView/TradingViewNative/data/useTradingViewNativeKLine.ts index fa00b37f6b84..8c36fb374ce2 100644 --- a/packages/kit/src/components/TradingView/TradingViewNative/data/useTradingViewNativeKLine.ts +++ b/packages/kit/src/components/TradingView/TradingViewNative/data/useTradingViewNativeKLine.ts @@ -914,7 +914,9 @@ async function recoverOlderHistoryFromBoundary({ }); const mergedPoints = mergeKLinePoints(points, pagePoints); if (mergedPoints.length >= normalizedTargetPointCount) { - points = mergedPoints.slice(-normalizedTargetPointCount); + // Keep the complete page as preload data. Truncating it here would + // make the viewport request the discarded candles again immediately. + points = mergedPoints; cursorTimeTo = (points[0]?.t ?? pageTimeFrom) - 1; onProgress?.(buildResult()); break; From c23d4dc59e75500c2044c63dcafa7cf51254488b Mon Sep 17 00:00:00 2001 From: limichange Date: Tue, 18 Aug 2026 13:33:41 +0800 Subject: [PATCH 07/15] fix: keep all returned kline history OK-60116 --- .../data/useTradingViewNativeKLine.test.ts | 14 ++++++++++---- .../data/useTradingViewNativeKLine.ts | 4 +--- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/packages/kit/src/components/TradingView/TradingViewNative/data/useTradingViewNativeKLine.test.ts b/packages/kit/src/components/TradingView/TradingViewNative/data/useTradingViewNativeKLine.test.ts index 1d394e137fec..2dc2b9d415dd 100644 --- a/packages/kit/src/components/TradingView/TradingViewNative/data/useTradingViewNativeKLine.test.ts +++ b/packages/kit/src/components/TradingView/TradingViewNative/data/useTradingViewNativeKLine.test.ts @@ -1111,11 +1111,13 @@ describe('TradingViewNative K-line data state machine', () => { ['1', 60], ['5', 5 * 60], ] as const)( - 'preloads %s-minute Market history from half a visible screen to one screen', + 'keeps all %s-minute Market history returned beyond the preload target', async (activeInterval, intervalSeconds) => { const currentTimestamp = 2_000_000; const initialFirstTimestamp = currentTimestamp - 298 * intervalSeconds; - const olderFirstTimestamp = initialFirstTimestamp - 30 * intervalSeconds; + const returnedOlderPointCount = 45; + const olderFirstTimestamp = + initialFirstTimestamp - returnedOlderPointCount * intervalSeconds; mockHistoryBatchSize = 299; mockHistoryRequestCandleCount = 2000; mockReadTradingViewNativeActiveInterval.mockReturnValue(activeInterval); @@ -1131,7 +1133,7 @@ describe('TradingViewNative K-line data state machine', () => { ) .mockResolvedValueOnce( buildMultiPointResponse( - Array.from({ length: 30 }, (_, index) => ({ + Array.from({ length: returnedOlderPointCount }, (_, index) => ({ close: 70 + index, timestamp: olderFirstTimestamp + index * intervalSeconds, })), @@ -1163,7 +1165,11 @@ describe('TradingViewNative K-line data state machine', () => { timeTo: initialFirstTimestamp - 1, }), ); - await waitFor(() => expect(result.current.points).toHaveLength(329)); + await waitFor(() => + expect(result.current.points).toHaveLength( + 299 + returnedOlderPointCount, + ), + ); expect(result.current.points[0]?.t).toBe(olderFirstTimestamp); }, ); diff --git a/packages/kit/src/components/TradingView/TradingViewNative/data/useTradingViewNativeKLine.ts b/packages/kit/src/components/TradingView/TradingViewNative/data/useTradingViewNativeKLine.ts index 8c36fb374ce2..069a65804a90 100644 --- a/packages/kit/src/components/TradingView/TradingViewNative/data/useTradingViewNativeKLine.ts +++ b/packages/kit/src/components/TradingView/TradingViewNative/data/useTradingViewNativeKLine.ts @@ -3418,9 +3418,7 @@ export function useTradingViewNativeKLine({ const receivedOlderPoints = normalizeKLinePoints(data.points).filter( (point) => point.t < earliestTimestamp, ); - let olderPoints = isMarketMinuteHistory - ? receivedOlderPoints.slice(-olderHistoryPreloadPointCount) - : receivedOlderPoints; + let olderPoints = receivedOlderPoints; let paginationCursorTimestamp = olderPoints[0]?.t; const pageHasMoreHistory = historyProvider.hasMoreHistory({ historySource, From 22d94a73be7929563e311a933313a288c1256eae Mon Sep 17 00:00:00 2001 From: limichange Date: Tue, 18 Aug 2026 13:57:13 +0800 Subject: [PATCH 08/15] fix: keep address kline history stable OK-60116 --- .../data/getTradingViewNativeSource.test.ts | 10 +++----- .../data/getTradingViewNativeSource.ts | 12 +++++---- ...reateTradingViewNativeDataProvider.test.ts | 2 +- .../data/useTradingViewNativeKLine.test.ts | 25 +++++++++++++------ .../data/useTradingViewNativeKLine.ts | 5 ++-- 5 files changed, 31 insertions(+), 23 deletions(-) diff --git a/packages/kit/src/components/TradingView/TradingViewNative/data/getTradingViewNativeSource.test.ts b/packages/kit/src/components/TradingView/TradingViewNative/data/getTradingViewNativeSource.test.ts index 707e90707b20..77161305109c 100644 --- a/packages/kit/src/components/TradingView/TradingViewNative/data/getTradingViewNativeSource.test.ts +++ b/packages/kit/src/components/TradingView/TradingViewNative/data/getTradingViewNativeSource.test.ts @@ -72,9 +72,7 @@ describe('TradingViewNative source resolver', () => { symbol: 'TOKEN', realtime: 'websocket', }); - expect(getTradingViewNativeSourceKey(source)).toBe( - 'market:evm--1:0xabc:TOKEN', - ); + expect(getTradingViewNativeSourceKey(source)).toBe('market:evm--1:0xabc'); }); it('keeps the native-token identity for interval persistence', () => { @@ -96,7 +94,7 @@ describe('TradingViewNative source resolver', () => { realtime: 'disabled', }); expect(getTradingViewNativeSourceKey(source)).toBe( - 'market:evm--1:0xeeee:ETH:native', + 'market:evm--1:0xeeee:native', ); }); @@ -116,9 +114,7 @@ describe('TradingViewNative source resolver', () => { tokenAddress: '0xAbC', }); - expect(getTradingViewNativeSourceKey(source)).toBe( - 'market:evm--1:0xabc:ETH', - ); + expect(getTradingViewNativeSourceKey(source)).toBe('market:evm--1:0xabc'); }); it('keeps a CoinGecko fallback hint inside the Market source', () => { diff --git a/packages/kit/src/components/TradingView/TradingViewNative/data/getTradingViewNativeSource.ts b/packages/kit/src/components/TradingView/TradingViewNative/data/getTradingViewNativeSource.ts index a81ccfe1fe93..8f2d17f0db12 100644 --- a/packages/kit/src/components/TradingView/TradingViewNative/data/getTradingViewNativeSource.ts +++ b/packages/kit/src/components/TradingView/TradingViewNative/data/getTradingViewNativeSource.ts @@ -32,11 +32,13 @@ export function getTradingViewNativeSourceKey( if (source.kind === 'hyperliquid') { return `hyperliquid:${source.environment}:${source.coin.trim()}`; } - const marketSourceKey = `${getTradingViewNativeMarketTokenKey( - source, - )}:${normalizeMarketSymbol(source.symbol)}${ - source.isNative ? ':native' : '' - }`; + const marketTokenKey = getTradingViewNativeMarketTokenKey(source); + const hasTokenAddress = Boolean( + normalizeMarketTokenAddress(source.tokenAddress), + ); + const marketSourceKey = `${marketTokenKey}${ + hasTokenAddress ? '' : `:${normalizeMarketSymbol(source.symbol)}` + }${source.isNative ? ':native' : ''}`; const normalizedFallbackCoinGeckoId = source.fallbackCoinGeckoId?.trim(); return normalizedFallbackCoinGeckoId ? `${marketSourceKey}:coingecko:${normalizedFallbackCoinGeckoId}` diff --git a/packages/kit/src/components/TradingView/TradingViewNative/data/providers/createTradingViewNativeDataProvider.test.ts b/packages/kit/src/components/TradingView/TradingViewNative/data/providers/createTradingViewNativeDataProvider.test.ts index a8590326086c..f4ec9b636781 100644 --- a/packages/kit/src/components/TradingView/TradingViewNative/data/providers/createTradingViewNativeDataProvider.test.ts +++ b/packages/kit/src/components/TradingView/TradingViewNative/data/providers/createTradingViewNativeDataProvider.test.ts @@ -338,7 +338,7 @@ describe('TradingViewNative data providers', () => { }); expect(provider.getHistoryRequestCandleCount(getInterval('60'))).toBe(2000); - expect(provider.key).toBe('market:stock--0:stock-aapl:AAPL'); + expect(provider.key).toBe('market:stock--0:stock-aapl'); await expect( provider.fetchHistory({ interval: getInterval('60'), diff --git a/packages/kit/src/components/TradingView/TradingViewNative/data/useTradingViewNativeKLine.test.ts b/packages/kit/src/components/TradingView/TradingViewNative/data/useTradingViewNativeKLine.test.ts index 2dc2b9d415dd..81e9f16e48ba 100644 --- a/packages/kit/src/components/TradingView/TradingViewNative/data/useTradingViewNativeKLine.test.ts +++ b/packages/kit/src/components/TradingView/TradingViewNative/data/useTradingViewNativeKLine.test.ts @@ -184,11 +184,13 @@ function buildMarketSource({ fallbackCoinGeckoId, isNative, realtime = 'disabled', + symbol = 'TOKEN', tokenAddress = '0x123', }: { fallbackCoinGeckoId?: string; isNative?: boolean; realtime?: 'disabled' | 'websocket'; + symbol?: string; tokenAddress?: string; } = {}): ITradingViewNativeSource { return { @@ -197,7 +199,7 @@ function buildMarketSource({ ...(isNative ? { isNative: true } : {}), networkId: 'evm--1', tokenAddress, - symbol: 'TOKEN', + symbol, realtime, }; } @@ -530,9 +532,7 @@ describe('TradingViewNative K-line data state machine', () => { rerender({ isNative: true }); expect(result.current.intervalConfig.activeInterval).toBe('240'); - expect(result.current.dataProviderKey).toBe( - 'market:evm--1:0xeeee:TOKEN:native', - ); + expect(result.current.dataProviderKey).toBe('market:evm--1:0xeeee:native'); expect(result.current.points).toEqual([]); expect(mockSaveTradingViewNativeActiveInterval).not.toHaveBeenCalledWith({ interval: '15', @@ -3708,17 +3708,24 @@ describe('TradingViewNative K-line data state machine', () => { expect(result.current.points[0]?.c).toBe(200); }); - it('enables Market realtime without restarting in-flight history', async () => { + it('applies Market metadata without restarting in-flight address history', async () => { const historyRequest = createDeferred(); mockFetchHistory.mockReturnValue(historyRequest.promise); const { result, rerender } = renderHook( - ({ realtime }: { realtime: 'disabled' | 'websocket' }) => + ({ + realtime, + symbol, + }: { + realtime: 'disabled' | 'websocket'; + symbol: string; + }) => useTradingViewNativeKLine({ - source: buildMarketSource({ realtime }), + source: buildMarketSource({ realtime, symbol }), }), { initialProps: { realtime: 'disabled' as 'disabled' | 'websocket', + symbol: '', }, }, ); @@ -3726,11 +3733,13 @@ describe('TradingViewNative K-line data state machine', () => { await waitFor(() => expect(mockFetchHistory).toHaveBeenCalledTimes(1)); const initialHistorySignal = mockFetchHistory.mock.calls[0]?.[0].signal; expect(mockSubscribeRealtime).not.toHaveBeenCalled(); + expect(result.current.dataProviderKey).toBe('market:evm--1:0x123'); - rerender({ realtime: 'websocket' }); + rerender({ realtime: 'websocket', symbol: 'MSTRon' }); await waitFor(() => expect(mockSubscribeRealtime).toHaveBeenCalledTimes(1)); expect(mockFetchHistory).toHaveBeenCalledTimes(1); expect(initialHistorySignal?.aborted).toBe(false); + expect(result.current.dataProviderKey).toBe('market:evm--1:0x123'); await act(async () => { historyRequest.resolve(buildResponse(100)); diff --git a/packages/kit/src/components/TradingView/TradingViewNative/data/useTradingViewNativeKLine.ts b/packages/kit/src/components/TradingView/TradingViewNative/data/useTradingViewNativeKLine.ts index 069a65804a90..efd1071d0f8c 100644 --- a/packages/kit/src/components/TradingView/TradingViewNative/data/useTradingViewNativeKLine.ts +++ b/packages/kit/src/components/TradingView/TradingViewNative/data/useTradingViewNativeKLine.ts @@ -1660,6 +1660,7 @@ export function useTradingViewNativeKLine({ const marketTokenAddress = source.kind === 'market' ? source.tokenAddress : ''; const marketSymbol = source.kind === 'market' ? source.symbol : ''; + const marketHistorySymbol = marketTokenAddress.trim() ? '' : marketSymbol; const marketRealtime = source.kind === 'market' ? source.realtime : 'disabled'; const rawHistoryProvider = useMemo(() => { @@ -1676,7 +1677,7 @@ export function useTradingViewNativeKLine({ isNative: marketIsNative, networkId: marketNetworkId, tokenAddress: marketTokenAddress, - symbol: marketSymbol, + symbol: marketHistorySymbol, realtime: 'disabled', }); }, [ @@ -1684,8 +1685,8 @@ export function useTradingViewNativeKLine({ hyperliquidEnvironment, marketFallbackCoinGeckoId, marketIsNative, + marketHistorySymbol, marketNetworkId, - marketSymbol, marketTokenAddress, sourceKind, ]); From 2c7b5bf9956ce27bdb368df1415c380c7f9fe30f Mon Sep 17 00:00:00 2001 From: limichange Date: Tue, 18 Aug 2026 14:24:51 +0800 Subject: [PATCH 09/15] fix: exhaust sparse kline history ranges OK-60116 --- .../data/useTradingViewNativeKLine.test.ts | 240 ++++++++++-------- .../data/useTradingViewNativeKLine.ts | 49 +--- 2 files changed, 145 insertions(+), 144 deletions(-) diff --git a/packages/kit/src/components/TradingView/TradingViewNative/data/useTradingViewNativeKLine.test.ts b/packages/kit/src/components/TradingView/TradingViewNative/data/useTradingViewNativeKLine.test.ts index 81e9f16e48ba..6aae1984ced5 100644 --- a/packages/kit/src/components/TradingView/TradingViewNative/data/useTradingViewNativeKLine.test.ts +++ b/packages/kit/src/components/TradingView/TradingViewNative/data/useTradingViewNativeKLine.test.ts @@ -783,56 +783,79 @@ describe('TradingViewNative K-line data state machine', () => { }, ); - it('keeps a capped sparse recovery page as viewport preload data', async () => { + it('exhausts every capped page in a sparse recovery range', async () => { const intervalSeconds = 60; + const cappedPageStartTimestamp = 808_460; mockHistoryBatchSize = 299; mockHistoryRequestCandleCount = 2000; mockReadTradingViewNativeActiveInterval.mockReturnValue('1'); jest.spyOn(Date, 'now').mockReturnValue(2_000_000_000); let activeIntervalRequestCount = 0; let dailyRequestCount = 0; - mockFetchHistory.mockImplementation(async ({ interval }) => { - if (interval.value === '1W') { - return buildResponse(50, 100_000); - } - if (interval.value === '1D') { - dailyRequestCount += 1; - return dailyRequestCount === 1 - ? buildResponse(60, 110_000) - : buildMultiPointResponse([ - { close: 70, timestamp: 740_000 }, - { close: 80, timestamp: 830_000 }, - ]); - } + mockFetchHistory.mockImplementation( + async ({ interval, timeFrom, timeTo }) => { + if (interval.value === '1W') { + return buildResponse(50, 100_000); + } + if (interval.value === '1D') { + dailyRequestCount += 1; + return dailyRequestCount === 1 + ? buildResponse(60, 110_000) + : buildMultiPointResponse([ + { close: 70, timestamp: 740_000 }, + { close: 80, timestamp: 830_000 }, + ]); + } - activeIntervalRequestCount += 1; - if (activeIntervalRequestCount === 1) { + activeIntervalRequestCount += 1; + if (activeIntervalRequestCount === 1) { + return buildMultiPointResponse( + Array.from({ length: 7 }, (_, index) => ({ + close: 100 + index, + timestamp: 1_000_000 + index * intervalSeconds, + })), + ); + } + if (activeIntervalRequestCount === 2) { + expect({ timeFrom, timeTo }).toEqual({ + timeFrom: 830_000, + timeTo: 916_399, + }); + return buildMultiPointResponse([ + { close: 90, timestamp: 830_100 }, + { close: 91, timestamp: 830_160 }, + ]); + } + if (activeIntervalRequestCount === 3) { + expect({ timeFrom, timeTo }).toEqual({ + timeFrom: 740_000, + timeTo: 826_399, + }); + return buildMultiPointResponse( + Array.from({ length: 299 }, (_, index) => ({ + close: 200 + index, + timestamp: cappedPageStartTimestamp + index * intervalSeconds, + })), + ); + } + expect({ timeFrom, timeTo }).toEqual({ + timeFrom: 740_000, + timeTo: cappedPageStartTimestamp - 1, + }); return buildMultiPointResponse( - Array.from({ length: 7 }, (_, index) => ({ - close: 100 + index, - timestamp: 1_000_000 + index * intervalSeconds, + Array.from({ length: 5 }, (_, index) => ({ + close: 150 + index, + timestamp: 740_100 + index * intervalSeconds, })), ); - } - if (activeIntervalRequestCount === 2) { - return buildMultiPointResponse([ - { close: 90, timestamp: 830_100 }, - { close: 91, timestamp: 830_160 }, - ]); - } - return buildMultiPointResponse( - Array.from({ length: 299 }, (_, index) => ({ - close: 200 + index, - timestamp: 740_100 + index * intervalSeconds, - })), - ); - }); + }, + ); const { result } = renderHook(() => useTradingViewNativeKLine({ source: buildMarketSource() }), ); - await waitFor(() => expect(result.current.points).toHaveLength(308)); - expect(activeIntervalRequestCount).toBe(3); + await waitFor(() => expect(result.current.points).toHaveLength(313)); + expect(activeIntervalRequestCount).toBe(4); const visiblePointCount = 164; act(() => @@ -842,7 +865,7 @@ describe('TradingViewNative K-line data state machine', () => { }), ); - expect(activeIntervalRequestCount).toBe(3); + expect(activeIntervalRequestCount).toBe(4); }); it('crosses more than eight empty active days during initial sparse recovery', async () => { @@ -1050,34 +1073,40 @@ describe('TradingViewNative K-line data state machine', () => { mockReadTradingViewNativeActiveInterval.mockReturnValue(activeInterval); let activeIntervalRequestCount = 0; let dailyRequestCount = 0; - mockFetchHistory.mockImplementation(async ({ interval }) => { - if (interval.value === '1W') { - return buildResponse(50, 100_000); - } - if (interval.value === '1D') { - dailyRequestCount += 1; - return dailyRequestCount === 1 - ? buildResponse(60, 110_000) - : buildResponse(70, 920_000); - } + const recoveryPoints = Array.from({ length: 98 }, (_, index) => ({ + close: 70 + index, + timestamp: 920_100 + index * intervalSeconds, + })); + mockFetchHistory.mockImplementation( + async ({ interval, timeFrom, timeTo }) => { + if (interval.value === '1W') { + return buildResponse(50, 100_000); + } + if (interval.value === '1D') { + dailyRequestCount += 1; + return dailyRequestCount === 1 + ? buildResponse(60, 110_000) + : buildResponse(70, 920_000); + } - activeIntervalRequestCount += 1; - if (activeIntervalRequestCount === 1) { - return buildMultiPointResponse([ - { close: 100, timestamp: 1_000_000 }, - { close: 110, timestamp: 1_000_000 + intervalSeconds }, - ]); - } - if (activeIntervalRequestCount === 2) { - return buildResponse(90, 995_000); - } - return buildMultiPointResponse( - Array.from({ length: 98 }, (_, index) => ({ - close: 70 + index, - timestamp: 920_100 + index * intervalSeconds, - })), - ); - }); + activeIntervalRequestCount += 1; + if (activeIntervalRequestCount === 1) { + return buildMultiPointResponse([ + { close: 100, timestamp: 1_000_000 }, + { close: 110, timestamp: 1_000_000 + intervalSeconds }, + ]); + } + if (activeIntervalRequestCount === 2) { + return buildResponse(90, 995_000); + } + return buildMultiPointResponse( + recoveryPoints.filter( + (point) => + point.timestamp >= timeFrom && point.timestamp <= timeTo, + ), + ); + }, + ); const { result } = renderHook(() => useTradingViewNativeKLine({ source: buildMarketSource() }), ); @@ -1103,7 +1132,7 @@ describe('TradingViewNative K-line data state machine', () => { expect(result.current.points.some((point) => point.t === 995_000)).toBe( true, ); - expect(mockFetchHistory).toHaveBeenCalledTimes(6); + expect(mockFetchHistory).toHaveBeenCalledTimes(7); }, ); @@ -1174,7 +1203,7 @@ describe('TradingViewNative K-line data state machine', () => { }, ); - it('continues sparse minute preloading until the full-screen buffer target', async () => { + it('loads every sparse minute range located by daily history', async () => { const currentTimestamp = 100_000_000; const initialFirstTimestamp = 90_000_000; const activeDays = Array.from({ length: 100 }, (_, index) => ({ @@ -1237,7 +1266,7 @@ describe('TradingViewNative K-line data state machine', () => { expect(dailyRequestCount).toBe(2); }); - it('continues paging inside a sparse recovery batch after a full Market page', async () => { + it('exhausts all located sparse ranges after load-more starts recovery', async () => { const intervalSeconds = 60; const currentTimestamp = 1_000_120; const boundaryTimestamp = 100_000; @@ -1306,20 +1335,12 @@ describe('TradingViewNative K-line data state machine', () => { startIndex: 0, }), ); - await waitFor(() => expect(result.current.points).toHaveLength(6)); - - act(() => - result.current.handleVisiblePointRangeChange({ - endIndex: 5, - startIndex: 0, - }), - ); await waitFor(() => expect(result.current.points).toHaveLength(12)); const activeIntervalRequests = mockFetchHistory.mock.calls .map(([request]) => request) .filter((request) => request.interval.value === '1'); - expect(activeIntervalRequests).toHaveLength(6); + expect(activeIntervalRequests).toHaveLength(7); expect(activeIntervalRequests[4]).toEqual( expect.objectContaining({ timeFrom: olderActiveDayTimestamp, @@ -1332,6 +1353,12 @@ describe('TradingViewNative K-line data state machine', () => { timeTo: olderBatchPoints[3].timestamp - 1, }), ); + expect(activeIntervalRequests[6]).toEqual( + expect.objectContaining({ + timeFrom: olderActiveDayTimestamp, + timeTo: olderBatchPoints[0].timestamp - 1, + }), + ); expect(result.current.points[0]?.t).toBe(olderBatchPoints[0].timestamp); }); @@ -1384,35 +1411,40 @@ describe('TradingViewNative K-line data state machine', () => { let activeIntervalRequestCount = 0; let dailyRequestCount = 0; let weeklyRequestCount = 0; - mockFetchHistory.mockImplementation(async ({ interval }) => { - if (interval.value === '1W') { - weeklyRequestCount += 1; - return weeklyRequestCount <= 3 ? null : buildResponse(50, 100_000); - } - if (interval.value === '1D') { - dailyRequestCount += 1; - return dailyRequestCount === 1 - ? buildResponse(60, 110_000) - : buildResponse(70, 920_000); - } + const recoveryPoints = Array.from({ length: 99 }, (_, index) => ({ + close: 70 + index, + timestamp: 920_100 + index * 60, + })); + mockFetchHistory.mockImplementation( + async ({ interval, timeFrom, timeTo }) => { + if (interval.value === '1W') { + weeklyRequestCount += 1; + return weeklyRequestCount <= 3 ? null : buildResponse(50, 100_000); + } + if (interval.value === '1D') { + dailyRequestCount += 1; + return dailyRequestCount === 1 + ? buildResponse(60, 110_000) + : buildResponse(70, 920_000); + } - activeIntervalRequestCount += 1; - if (activeIntervalRequestCount === 1) { - return buildMultiPointResponse([ - { close: 100, timestamp: 1_000_000 }, - { close: 110, timestamp: 1_000_060 }, - ]); - } - if (activeIntervalRequestCount <= 3) { - return { points: [], total: 0 }; - } - return buildMultiPointResponse( - Array.from({ length: 99 }, (_, index) => ({ - close: 70 + index, - timestamp: 920_100 + index * 60, - })), - ); - }); + activeIntervalRequestCount += 1; + if (activeIntervalRequestCount === 1) { + return buildMultiPointResponse([ + { close: 100, timestamp: 1_000_000 }, + { close: 110, timestamp: 1_000_060 }, + ]); + } + if (activeIntervalRequestCount <= 3) { + return { points: [], total: 0 }; + } + return buildMultiPointResponse( + recoveryPoints.filter( + (point) => point.timestamp >= timeFrom && point.timestamp <= timeTo, + ), + ); + }, + ); const { result } = renderHook(() => useTradingViewNativeKLine({ source: buildMarketSource() }), ); @@ -1428,7 +1460,7 @@ describe('TradingViewNative K-line data state machine', () => { act(() => result.current.handleVisiblePointRangeChange({ startIndex: 0 })); await waitFor(() => expect(result.current.points).toHaveLength(101)); expect(weeklyRequestCount).toBe(4); - expect(activeIntervalRequestCount).toBe(4); + expect(activeIntervalRequestCount).toBe(5); }); it('keeps load-more ownership when aborted initial recovery settles', async () => { diff --git a/packages/kit/src/components/TradingView/TradingViewNative/data/useTradingViewNativeKLine.ts b/packages/kit/src/components/TradingView/TradingViewNative/data/useTradingViewNativeKLine.ts index efd1071d0f8c..c050519a8e70 100644 --- a/packages/kit/src/components/TradingView/TradingViewNative/data/useTradingViewNativeKLine.ts +++ b/packages/kit/src/components/TradingView/TradingViewNative/data/useTradingViewNativeKLine.ts @@ -64,7 +64,7 @@ const HISTORY_BOUNDARY_SEARCH_INTERVAL_VALUE: ITradingViewNativeChartInterval = '1W'; const SPARSE_MARKET_HISTORY_LOCATOR_INTERVAL_VALUE: ITradingViewNativeChartInterval = '1D'; -const MAX_SPARSE_MARKET_HISTORY_REQUEST_COUNT = 8; +const SPARSE_MARKET_HISTORY_REQUEST_RANGE_COUNT = 8; const HISTORY_RETRY_DELAYS = [1000, 3000] as const; const MAX_VIEWPORT_HISTORY_PAGE_COUNT = 20; const MAX_VIEWPORT_HISTORY_BOUNDARY_SEARCH_COUNT = 32; @@ -750,10 +750,10 @@ function getSparseHistoryRequestRanges({ locatorIntervalSeconds: number; timeTo: number; }) { - // Spread every locator candidate across the fixed request budget instead of - // consuming one minute-history request for each active day. + // Group locator candidates into a small fixed number of chronological + // ranges instead of issuing one minute-history request per active day. const activeDayBatchSize = Math.max( - Math.ceil(activeDays.length / MAX_SPARSE_MARKET_HISTORY_REQUEST_COUNT), + Math.ceil(activeDays.length / SPARSE_MARKET_HISTORY_REQUEST_RANGE_COUNT), 1, ); const ranges: { timeFrom: number; timeTo: number }[] = []; @@ -782,7 +782,6 @@ async function recoverOlderHistoryFromBoundary({ onProgress, seriesKey, signal, - targetPointCount = 1, timeTo, }: { historyProvider: ITradingViewNativeDataProvider; @@ -790,7 +789,6 @@ async function recoverOlderHistoryFromBoundary({ onProgress?: (result: IHistoryGapRecoveryResult) => void; seriesKey: string; signal: AbortSignal; - targetPointCount?: number; timeTo: number; }): Promise { const boundaryPage = await (getHistoryBoundaryPrefetchPage(seriesKey) ?? @@ -845,7 +843,6 @@ async function recoverOlderHistoryFromBoundary({ return null; } - const normalizedTargetPointCount = Math.max(Math.floor(targetPointCount), 1); const requestRanges = getSparseHistoryRequestRanges({ activeDays, boundaryTimestamp, @@ -855,7 +852,6 @@ async function recoverOlderHistoryFromBoundary({ let cursorTimeTo = Math.floor(timeTo); let historySource: 'fallback' | undefined; let points: IMarketTokenKLineDataPoint[] = []; - let requestCount = 0; const buildResult = (): IHistoryGapRecoveryResult => { const hasMoreBefore = cursorTimeTo >= boundaryTimestamp; return { @@ -867,20 +863,14 @@ async function recoverOlderHistoryFromBoundary({ }; }; for (const requestRange of requestRanges) { - if ( - cursorTimeTo < boundaryTimestamp || - points.length >= normalizedTargetPointCount || - requestCount >= MAX_SPARSE_MARKET_HISTORY_REQUEST_COUNT - ) { + if (cursorTimeTo < boundaryTimestamp) { break; } const pageTimeFrom = requestRange.timeFrom; let pageTimeTo = Math.min(cursorTimeTo, requestRange.timeTo); - while ( - pageTimeTo >= pageTimeFrom && - points.length < normalizedTargetPointCount && - requestCount < MAX_SPARSE_MARKET_HISTORY_REQUEST_COUNT - ) { + // The daily locator defines the complete sparse-history range. Exhaust + // every capped minute page instead of stopping at a viewport-sized count. + while (pageTimeTo >= pageTimeFrom) { const data = await fetchRequiredHistoryPage({ historyProvider, request: { @@ -892,7 +882,6 @@ async function recoverOlderHistoryFromBoundary({ unavailableMessage: 'No candle history response is available for sparse history recovery', }); - requestCount += 1; if (signal.aborted) { return null; } @@ -912,17 +901,7 @@ async function recoverOlderHistoryFromBoundary({ points: data.points, to: pageTimeTo, }); - const mergedPoints = mergeKLinePoints(points, pagePoints); - if (mergedPoints.length >= normalizedTargetPointCount) { - // Keep the complete page as preload data. Truncating it here would - // make the viewport request the discarded candles again immediately. - points = mergedPoints; - cursorTimeTo = (points[0]?.t ?? pageTimeFrom) - 1; - onProgress?.(buildResult()); - break; - } - - points = mergedPoints; + points = mergeKLinePoints(points, pagePoints); const pageHasMoreHistory = historyProvider.hasMoreHistory({ historySource: data.historySource, interval, @@ -3472,10 +3451,6 @@ export function useTradingViewNativeKLine({ if (shouldRecoverSparseHistory) { publishOlderPoints(olderPoints); const recoveryTimeTo = Math.max(timeFrom - 1, 0); - const recoveryTargetPointCount = Math.max( - olderHistoryPreloadPointCount - olderPoints.length, - 1, - ); const applyRecoveryProgress = createHistoryGapRecoveryProgressHandler({ coverageState: historyCoverageRef.current, @@ -3495,7 +3470,6 @@ export function useTradingViewNativeKLine({ onProgress: applyRecoveryProgress, seriesKey, signal: abortController.signal, - targetPointCount: recoveryTargetPointCount, timeTo: recoveryTimeTo, }); if ( @@ -4183,11 +4157,6 @@ export function useTradingViewNativeKLine({ onProgress: applyRecoveryProgress, seriesKey, signal: abortController.signal, - targetPointCount: Math.max( - TRADING_VIEW_NATIVE_TIME_RANGE_MAX_CANDLE_COUNT - - receivedHistoryPointCount, - 1, - ), timeTo: Math.max(initialEarliestTimestamp - 1, 0), }); if ( From 28a154061a192e7e028e426570597ca7f47c019f Mon Sep 17 00:00:00 2001 From: limichange Date: Tue, 18 Aug 2026 15:09:34 +0800 Subject: [PATCH 10/15] fix: scan sparse kline history by time blocks OK-60116 --- .../data/useTradingViewNativeKLine.test.ts | 596 +++++------------- .../data/useTradingViewNativeKLine.ts | 231 +++---- 2 files changed, 254 insertions(+), 573 deletions(-) diff --git a/packages/kit/src/components/TradingView/TradingViewNative/data/useTradingViewNativeKLine.test.ts b/packages/kit/src/components/TradingView/TradingViewNative/data/useTradingViewNativeKLine.test.ts index 6aae1984ced5..1818fb11a48d 100644 --- a/packages/kit/src/components/TradingView/TradingViewNative/data/useTradingViewNativeKLine.test.ts +++ b/packages/kit/src/components/TradingView/TradingViewNative/data/useTradingViewNativeKLine.test.ts @@ -723,51 +723,41 @@ describe('TradingViewNative K-line data state machine', () => { ] as const)( 'backfills %s-minute Market history after a sparse initial page', async (activeInterval, intervalSeconds) => { + const initialTimestamp = 1_000_000; + const boundaryTimestamp = initialTimestamp - 2000 * intervalSeconds; + const historicalPoints = Array.from({ length: 196 }, (_, index) => ({ + close: 70 + index, + timestamp: initialTimestamp - (196 - index) * intervalSeconds, + })); mockHistoryBatchSize = 299; mockHistoryRequestCandleCount = 2000; mockReadTradingViewNativeActiveInterval.mockReturnValue(activeInterval); let activeIntervalRequestCount = 0; - let dailyRequestCount = 0; - const firstScan = - createDeferred(); mockFetchHistory.mockImplementation( async ({ interval, timeFrom, timeTo }) => { if (interval.value === '1W') { - return buildResponse(50, 100_000); + return buildResponse(50, boundaryTimestamp - 3600); } if (interval.value === '1D') { - dailyRequestCount += 1; - return dailyRequestCount === 1 - ? buildResponse(60, 110_000) - : buildMultiPointResponse([ - { close: 70, timestamp: 740_000 }, - { close: 80, timestamp: 830_000 }, - ]); + return buildResponse(60, boundaryTimestamp); } activeIntervalRequestCount += 1; if (activeIntervalRequestCount === 1) { return buildMultiPointResponse([ - { close: 100, timestamp: 1_000_000 }, - { close: 110, timestamp: 1_000_000 + intervalSeconds }, + { close: 100, timestamp: initialTimestamp }, + { close: 110, timestamp: initialTimestamp + intervalSeconds }, ]); } - if (activeIntervalRequestCount === 2) { - expect({ timeFrom, timeTo }).toEqual({ - timeFrom: 830_000, - timeTo: 916_399, - }); - return firstScan.promise; - } expect({ timeFrom, timeTo }).toEqual({ - timeFrom: 740_000, - timeTo: 826_399, + timeFrom: boundaryTimestamp, + timeTo: initialTimestamp - 1, }); return buildMultiPointResponse( - Array.from({ length: 196 }, (_, index) => ({ - close: 90 + index, - timestamp: 740_000 + index * intervalSeconds, - })), + historicalPoints.filter( + (point) => + point.timestamp >= timeFrom && point.timestamp <= timeTo, + ), ); }, ); @@ -775,36 +765,42 @@ describe('TradingViewNative K-line data state machine', () => { useTradingViewNativeKLine({ source: buildMarketSource() }), ); - await waitFor(() => expect(result.current.points).toHaveLength(2)); - act(() => firstScan.resolve({ points: [], total: 0 })); await waitFor(() => expect(result.current.points).toHaveLength(198)); - expect(mockFetchHistory).toHaveBeenCalledTimes(6); - expect(result.current.points[0]?.t).toBe(740_000); + expect(mockFetchHistory).toHaveBeenCalledTimes(4); + expect(activeIntervalRequestCount).toBe(2); + expect(result.current.points[0]?.t).toBe(historicalPoints[0]?.timestamp); }, ); - it('exhausts every capped page in a sparse recovery range', async () => { + it('continues through short and empty time blocks until the next-screen target', async () => { const intervalSeconds = 60; - const cappedPageStartTimestamp = 808_460; + const initialTimestamp = 1_000_000; + const requestCandleCount = 200; + const firstRange = { timeFrom: 987_999, timeTo: 999_999 }; + const secondRange = { timeFrom: 975_998, timeTo: 987_998 }; + const thirdRange = { timeFrom: 963_997, timeTo: 975_997 }; + const boundaryTimestamp = 900_000; + const firstRangePoints = Array.from({ length: 22 }, (_, index) => ({ + close: 70 + index, + timestamp: firstRange.timeFrom + 1 + index * intervalSeconds, + })); + const thirdRangePoints = Array.from({ length: 169 }, (_, index) => ({ + close: 100 + index, + timestamp: thirdRange.timeFrom + 1 + index * intervalSeconds, + })); + const nextLoadMoreRequest = + createDeferred(); mockHistoryBatchSize = 299; - mockHistoryRequestCandleCount = 2000; + mockHistoryRequestCandleCount = requestCandleCount; mockReadTradingViewNativeActiveInterval.mockReturnValue('1'); - jest.spyOn(Date, 'now').mockReturnValue(2_000_000_000); let activeIntervalRequestCount = 0; - let dailyRequestCount = 0; mockFetchHistory.mockImplementation( async ({ interval, timeFrom, timeTo }) => { if (interval.value === '1W') { - return buildResponse(50, 100_000); + return buildResponse(50, boundaryTimestamp - 3600); } if (interval.value === '1D') { - dailyRequestCount += 1; - return dailyRequestCount === 1 - ? buildResponse(60, 110_000) - : buildMultiPointResponse([ - { close: 70, timestamp: 740_000 }, - { close: 80, timestamp: 830_000 }, - ]); + return buildResponse(60, boundaryTimestamp); } activeIntervalRequestCount += 1; @@ -812,255 +808,148 @@ describe('TradingViewNative K-line data state machine', () => { return buildMultiPointResponse( Array.from({ length: 7 }, (_, index) => ({ close: 100 + index, - timestamp: 1_000_000 + index * intervalSeconds, + timestamp: initialTimestamp + index * intervalSeconds, })), ); } if (activeIntervalRequestCount === 2) { - expect({ timeFrom, timeTo }).toEqual({ - timeFrom: 830_000, - timeTo: 916_399, - }); - return buildMultiPointResponse([ - { close: 90, timestamp: 830_100 }, - { close: 91, timestamp: 830_160 }, - ]); + expect({ timeFrom, timeTo }).toEqual(firstRange); + return buildMultiPointResponse(firstRangePoints); } if (activeIntervalRequestCount === 3) { - expect({ timeFrom, timeTo }).toEqual({ - timeFrom: 740_000, - timeTo: 826_399, - }); - return buildMultiPointResponse( - Array.from({ length: 299 }, (_, index) => ({ - close: 200 + index, - timestamp: cappedPageStartTimestamp + index * intervalSeconds, - })), - ); + expect({ timeFrom, timeTo }).toEqual(secondRange); + return { points: [], total: 0 }; } - expect({ timeFrom, timeTo }).toEqual({ - timeFrom: 740_000, - timeTo: cappedPageStartTimestamp - 1, - }); - return buildMultiPointResponse( - Array.from({ length: 5 }, (_, index) => ({ - close: 150 + index, - timestamp: 740_100 + index * intervalSeconds, - })), - ); + if (activeIntervalRequestCount === 4) { + expect({ timeFrom, timeTo }).toEqual(thirdRange); + return buildMultiPointResponse(thirdRangePoints); + } + return nextLoadMoreRequest.promise; }, ); - const { result } = renderHook(() => + const { result, unmount } = renderHook(() => useTradingViewNativeKLine({ source: buildMarketSource() }), ); - await waitFor(() => expect(result.current.points).toHaveLength(313)); + await waitFor(() => expect(result.current.points).toHaveLength(198)); expect(activeIntervalRequestCount).toBe(4); + expect(result.current.points[0]?.t).toBe(thirdRangePoints[0]?.timestamp); - const visiblePointCount = 164; act(() => result.current.handleVisiblePointRangeChange({ endIndex: result.current.points.length, - startIndex: result.current.points.length - visiblePointCount, + startIndex: 0, + }), + ); + await waitFor(() => expect(activeIntervalRequestCount).toBe(5)); + expect( + mockFetchHistory.mock.calls.filter( + ([request]) => request.interval.value === '1', + )[4]?.[0], + ).toEqual( + expect.objectContaining({ + timeTo: thirdRange.timeFrom - 1, }), ); - expect(activeIntervalRequestCount).toBe(4); + unmount(); }); - it('crosses more than eight empty active days during initial sparse recovery', async () => { - const currentTimestamp = 30_000_000; - const oldestActiveDayTimestamp = 10_000_000; - const boundaryTimestamp = oldestActiveDayTimestamp - 24 * 60 * 60; - const activeDays = Array.from({ length: 10 }, (_, index) => ({ + it('refines a capped time block without skipping its uncovered timestamps', async () => { + const intervalSeconds = 60; + const initialTimestamp = 1_000_000; + const boundaryTimestamp = 800_000; + const cappedPoints = Array.from({ length: 299 }, (_, index) => ({ close: 70 + index, - timestamp: oldestActiveDayTimestamp + index * 24 * 60 * 60, + timestamp: 880_100 + index * 400, })); mockHistoryBatchSize = 299; mockHistoryRequestCandleCount = 2000; mockReadTradingViewNativeActiveInterval.mockReturnValue('1'); - jest.spyOn(Date, 'now').mockReturnValue(currentTimestamp * 1000); let activeIntervalRequestCount = 0; - let dailyRequestCount = 0; mockFetchHistory.mockImplementation( async ({ interval, timeFrom, timeTo }) => { if (interval.value === '1W') { return buildResponse(50, boundaryTimestamp - 3600); } if (interval.value === '1D') { - dailyRequestCount += 1; - return dailyRequestCount === 1 - ? buildResponse(60, boundaryTimestamp) - : buildMultiPointResponse(activeDays); + return buildResponse(60, boundaryTimestamp); } activeIntervalRequestCount += 1; if (activeIntervalRequestCount === 1) { - return buildMultiPointResponse([ - { close: 100, timestamp: currentTimestamp - 60 }, - { close: 110, timestamp: currentTimestamp }, - ]); - } - if ( - timeFrom <= oldestActiveDayTimestamp && - timeTo >= oldestActiveDayTimestamp - ) { - return buildResponse(60, oldestActiveDayTimestamp); + return buildMultiPointResponse( + Array.from({ length: 7 }, (_, index) => ({ + close: 100 + index, + timestamp: initialTimestamp + index * intervalSeconds, + })), + ); } - return { points: [], total: 0 }; + return buildMultiPointResponse( + cappedPoints.filter( + (point) => point.timestamp >= timeFrom && point.timestamp <= timeTo, + ), + ); }, ); const { result } = renderHook(() => useTradingViewNativeKLine({ source: buildMarketSource() }), ); - await waitFor(() => expect(result.current.points).toHaveLength(3)); - expect(result.current.points[0]?.t).toBe(oldestActiveDayTimestamp); - expect(activeIntervalRequestCount).toBe(6); - expect(activeIntervalRequestCount - 1).toBeLessThanOrEqual(8); + await waitFor(() => expect(result.current.points).toHaveLength(306)); + expect(activeIntervalRequestCount).toBe(4); + const activeIntervalRequests = mockFetchHistory.mock.calls + .map(([request]) => request) + .filter((request) => request.interval.value === '1'); + expect(activeIntervalRequests.slice(1)).toEqual([ + expect.objectContaining({ timeFrom: 879_999, timeTo: 999_999 }), + expect.objectContaining({ timeFrom: 940_000, timeTo: 999_999 }), + expect.objectContaining({ timeFrom: 879_999, timeTo: 939_999 }), + ]); }); - it.each([ - ['1', 60], - ['5', 5 * 60], - ] as const)( - 'locates %s-minute Market history across a gap longer than 60 days', - async (activeInterval, intervalSeconds) => { - const currentTimestamp = 20_000_000; - const recentTimestamp = currentTimestamp - intervalSeconds; - const oldActiveDay = recentTimestamp - 100 * 24 * 60 * 60; - const boundaryTimestamp = oldActiveDay - 24 * 60 * 60; - mockHistoryBatchSize = 299; - mockHistoryRequestCandleCount = 2000; - mockReadTradingViewNativeActiveInterval.mockReturnValue(activeInterval); - jest.spyOn(Date, 'now').mockReturnValue(currentTimestamp * 1000); - let activeIntervalRequestCount = 0; - let dailyRequestCount = 0; - mockFetchHistory.mockImplementation(async ({ interval }) => { - if (interval.value === '1W') { - return buildResponse(50, boundaryTimestamp - 3600); - } - if (interval.value === '1D') { - dailyRequestCount += 1; - return dailyRequestCount === 1 - ? buildResponse(60, boundaryTimestamp) - : buildResponse(70, oldActiveDay); - } - - activeIntervalRequestCount += 1; - if (activeIntervalRequestCount === 1) { - return buildMultiPointResponse([ - { close: 100, timestamp: recentTimestamp }, - { close: 110, timestamp: currentTimestamp }, - ]); - } - return buildMultiPointResponse( - Array.from({ length: 196 }, (_, index) => ({ - close: 70 + index, - timestamp: oldActiveDay + index * intervalSeconds, - })), - ); - }); - const { result } = renderHook(() => - useTradingViewNativeKLine({ source: buildMarketSource() }), - ); - - await waitFor(() => expect(result.current.points).toHaveLength(198)); - expect(recentTimestamp - oldActiveDay).toBeGreaterThan(60 * 24 * 60 * 60); - expect(mockFetchHistory).toHaveBeenCalledTimes(5); - expect( - mockFetchHistory.mock.calls.find( - ([request]) => - request.interval.value === activeInterval && - request.timeFrom === oldActiveDay, - )?.[0], - ).toEqual( - expect.objectContaining({ - timeFrom: oldActiveDay, - timeTo: oldActiveDay + 24 * 60 * 60 - 1, - }), - ); - expect(result.current.points[0]?.t).toBe(oldActiveDay); - }, - ); - - it.each([ - ['1', 60], - ['5', 5 * 60], - ] as const)( - 'recovers %s-minute Market history across an empty time window', - async (activeInterval, intervalSeconds) => { - mockHistoryBatchSize = 2; - mockHistoryRequestCandleCount = 2; - mockReadTradingViewNativeActiveInterval.mockReturnValue(activeInterval); - jest.spyOn(Date, 'now').mockReturnValue(2_000_000_000); - let activeIntervalRequestCount = 0; - let dailyRequestCount = 0; - mockFetchHistory.mockImplementation(async ({ interval }) => { - if (interval.value === '1W') { - return buildResponse(50, 100_000); - } - if (interval.value === '1D') { - dailyRequestCount += 1; - return dailyRequestCount === 1 - ? buildResponse(60, 110_000) - : buildResponse(70, 920_000); - } - - activeIntervalRequestCount += 1; - if (activeIntervalRequestCount === 1) { - return buildMultiPointResponse([ - { close: 100, timestamp: 1_000_000 }, - { close: 110, timestamp: 1_000_000 + intervalSeconds }, - ]); - } - if (activeIntervalRequestCount === 2) { - return { points: [], total: 0 }; - } - return buildMultiPointResponse( - Array.from({ length: 99 }, (_, index) => ({ - close: 70 + index, - timestamp: 920_100 + index * intervalSeconds, - })), - ); - }); - const { result } = renderHook(() => - useTradingViewNativeKLine({ source: buildMarketSource() }), - ); + it('crosses more than eight empty time blocks before reaching the global boundary', async () => { + const initialTimestamp = 1_000_000; + const boundaryTimestamp = 998_790; + mockHistoryBatchSize = 299; + mockHistoryRequestCandleCount = 2; + mockReadTradingViewNativeActiveInterval.mockReturnValue('1'); + let activeIntervalRequestCount = 0; + mockFetchHistory.mockImplementation(async ({ interval }) => { + if (interval.value === '1W') { + return buildResponse(50, boundaryTimestamp - 3600); + } + if (interval.value === '1D') { + return buildResponse(60, boundaryTimestamp); + } - await waitFor(() => expect(result.current.points).toHaveLength(2)); - act(() => - result.current.handleVisiblePointRangeChange({ startIndex: 0 }), - ); + activeIntervalRequestCount += 1; + return activeIntervalRequestCount === 1 + ? buildMultiPointResponse([ + { close: 100, timestamp: initialTimestamp }, + { close: 110, timestamp: initialTimestamp + 60 }, + ]) + : { points: [], total: 0 }; + }); + const { result } = renderHook(() => + useTradingViewNativeKLine({ source: buildMarketSource() }), + ); - await waitFor(() => expect(result.current.points).toHaveLength(101)); - expect(result.current.points[0]?.t).toBe(920_100); - const recoveryTimeTo = 999_999 - 99 * intervalSeconds - 1; - expect( - mockFetchHistory.mock.calls.filter( - ([request]) => request.interval.value === activeInterval, - )[2]?.[0], - ).toEqual( - expect.objectContaining({ - timeFrom: 920_000, - timeTo: recoveryTimeTo, - }), - ); - expect(result.current.calendarAvailableTimeRange).toEqual({ - from: 110_000, - }); - expect( - mockFetchHistory.mock.calls.filter( - ([request]) => request.interval.value === '1W', - ), - ).toHaveLength(1); - expect( - mockFetchHistory.mock.calls.filter( - ([request]) => request.interval.value === '1D', - ), - ).toHaveLength(2); - }, - ); + await waitFor(() => expect(activeIntervalRequestCount).toBe(11)); + expect(result.current.points).toHaveLength(2); + expect(result.current.calendarAvailableTimeRange).toEqual({ + from: boundaryTimestamp, + }); + const recoveryRequests = mockFetchHistory.mock.calls + .map(([request]) => request) + .filter((request) => request.interval.value === '1') + .slice(1); + expect(recoveryRequests).toHaveLength(10); + recoveryRequests.slice(1).forEach((request, index) => { + expect(request.timeTo).toBe(recoveryRequests[index].timeFrom - 1); + }); + expect(recoveryRequests.at(-1)?.timeFrom).toBe(boundaryTimestamp); + }); it.each([ ['1', 60], @@ -1068,11 +957,17 @@ describe('TradingViewNative K-line data state machine', () => { ] as const)( 'continues %s-minute Market load-more after a short non-empty page', async (activeInterval, intervalSeconds) => { - mockHistoryBatchSize = 2; - mockHistoryRequestCandleCount = 2; + const boundaryTimestamp = 110_000; + const standardTimeFrom = 999_999 - 99 * intervalSeconds; + const recoveryTimeTo = standardTimeFrom - 1; + const recoveryTimeFrom = Math.max( + recoveryTimeTo - 2000 * intervalSeconds, + boundaryTimestamp, + ); + mockHistoryBatchSize = 299; + mockHistoryRequestCandleCount = 2000; mockReadTradingViewNativeActiveInterval.mockReturnValue(activeInterval); let activeIntervalRequestCount = 0; - let dailyRequestCount = 0; const recoveryPoints = Array.from({ length: 98 }, (_, index) => ({ close: 70 + index, timestamp: 920_100 + index * intervalSeconds, @@ -1083,18 +978,17 @@ describe('TradingViewNative K-line data state machine', () => { return buildResponse(50, 100_000); } if (interval.value === '1D') { - dailyRequestCount += 1; - return dailyRequestCount === 1 - ? buildResponse(60, 110_000) - : buildResponse(70, 920_000); + return buildResponse(60, boundaryTimestamp); } activeIntervalRequestCount += 1; if (activeIntervalRequestCount === 1) { - return buildMultiPointResponse([ - { close: 100, timestamp: 1_000_000 }, - { close: 110, timestamp: 1_000_000 + intervalSeconds }, - ]); + return buildMultiPointResponse( + Array.from({ length: 299 }, (_, index) => ({ + close: 100 + index, + timestamp: 1_000_000 + index * intervalSeconds, + })), + ); } if (activeIntervalRequestCount === 2) { return buildResponse(90, 995_000); @@ -1111,28 +1005,27 @@ describe('TradingViewNative K-line data state machine', () => { useTradingViewNativeKLine({ source: buildMarketSource() }), ); - await waitFor(() => expect(result.current.points).toHaveLength(2)); + await waitFor(() => expect(result.current.points).toHaveLength(299)); act(() => result.current.handleVisiblePointRangeChange({ startIndex: 0 }), ); - await waitFor(() => expect(result.current.points).toHaveLength(101)); + await waitFor(() => expect(result.current.points).toHaveLength(398)); expect(result.current.points[0]?.t).toBe(920_100); - const recoveryTimeTo = 999_999 - 99 * intervalSeconds - 1; expect( mockFetchHistory.mock.calls.filter( ([request]) => request.interval.value === activeInterval, )[2]?.[0], ).toEqual( expect.objectContaining({ - timeFrom: 920_000, + timeFrom: recoveryTimeFrom, timeTo: recoveryTimeTo, }), ); expect(result.current.points.some((point) => point.t === 995_000)).toBe( true, ); - expect(mockFetchHistory).toHaveBeenCalledTimes(7); + expect(mockFetchHistory).toHaveBeenCalledTimes(5); }, ); @@ -1203,165 +1096,6 @@ describe('TradingViewNative K-line data state machine', () => { }, ); - it('loads every sparse minute range located by daily history', async () => { - const currentTimestamp = 100_000_000; - const initialFirstTimestamp = 90_000_000; - const activeDays = Array.from({ length: 100 }, (_, index) => ({ - close: 70 + index, - timestamp: 70_000_000 + index * 24 * 60 * 60, - })); - mockHistoryBatchSize = 299; - mockHistoryRequestCandleCount = 2000; - mockReadTradingViewNativeActiveInterval.mockReturnValue('1'); - jest.spyOn(Date, 'now').mockReturnValue(currentTimestamp * 1000); - let activeIntervalRequestCount = 0; - let dailyRequestCount = 0; - mockFetchHistory.mockImplementation( - async ({ interval, timeFrom, timeTo }) => { - if (interval.value === '1W') { - return buildResponse(50, 59_900_000); - } - if (interval.value === '1D') { - dailyRequestCount += 1; - return dailyRequestCount === 1 - ? buildResponse(60, 60_000_000) - : buildMultiPointResponse(activeDays); - } - - activeIntervalRequestCount += 1; - if (activeIntervalRequestCount === 1) { - return buildMultiPointResponse( - Array.from({ length: 299 }, (_, index) => ({ - close: 100 + index, - timestamp: initialFirstTimestamp + index * 60, - })), - ); - } - if (activeIntervalRequestCount === 2) { - return { points: [], total: 0 }; - } - return buildMultiPointResponse( - activeDays.filter( - (activeDay) => - activeDay.timestamp >= timeFrom && activeDay.timestamp <= timeTo, - ), - ); - }, - ); - const { result } = renderHook(() => - useTradingViewNativeKLine({ source: buildMarketSource() }), - ); - - await waitFor(() => expect(result.current.points).toHaveLength(299)); - act(() => - result.current.handleVisiblePointRangeChange({ - endIndex: 100, - startIndex: 0, - }), - ); - - await waitFor(() => expect(activeIntervalRequestCount).toBe(10)); - expect(result.current.points).toHaveLength(399); - expect(activeIntervalRequestCount - 2).toBe(8); - expect(dailyRequestCount).toBe(2); - }); - - it('exhausts all located sparse ranges after load-more starts recovery', async () => { - const intervalSeconds = 60; - const currentTimestamp = 1_000_120; - const boundaryTimestamp = 100_000; - const olderActiveDayTimestamp = 200_000; - const newerActiveDayTimestamp = 300_000; - const olderBatchPoints = Array.from({ length: 6 }, (_, index) => ({ - close: 70 + index, - timestamp: olderActiveDayTimestamp + 100 + index * intervalSeconds, - })); - const newerBatchPoints = Array.from({ length: 3 }, (_, index) => ({ - close: 80 + index, - timestamp: newerActiveDayTimestamp + 100 + index * intervalSeconds, - })); - const historicalPoints = [...olderBatchPoints, ...newerBatchPoints]; - mockHistoryBatchSize = 3; - mockHistoryRequestCandleCount = 2000; - mockReadTradingViewNativeActiveInterval.mockReturnValue('1'); - jest.spyOn(Date, 'now').mockReturnValue(currentTimestamp * 1000); - let activeIntervalRequestCount = 0; - let dailyRequestCount = 0; - mockFetchHistory.mockImplementation( - async ({ interval, timeFrom, timeTo }) => { - if (interval.value === '1W') { - return buildResponse(50, boundaryTimestamp - 3600); - } - if (interval.value === '1D') { - dailyRequestCount += 1; - return dailyRequestCount === 1 - ? buildResponse(60, boundaryTimestamp) - : buildMultiPointResponse([ - { close: 70, timestamp: olderActiveDayTimestamp }, - { close: 80, timestamp: newerActiveDayTimestamp }, - ]); - } - - activeIntervalRequestCount += 1; - if (activeIntervalRequestCount === 1) { - return buildMultiPointResponse( - Array.from({ length: 3 }, (_, index) => ({ - close: 100 + index, - timestamp: - currentTimestamp - - 2 * intervalSeconds + - index * intervalSeconds, - })), - ); - } - return buildMultiPointResponse( - historicalPoints - .filter( - (point) => - point.timestamp >= timeFrom && point.timestamp <= timeTo, - ) - .slice(-mockHistoryBatchSize), - ); - }, - ); - const { result } = renderHook(() => - useTradingViewNativeKLine({ source: buildMarketSource() }), - ); - - await waitFor(() => expect(result.current.points).toHaveLength(3)); - act(() => - result.current.handleVisiblePointRangeChange({ - endIndex: 3, - startIndex: 0, - }), - ); - await waitFor(() => expect(result.current.points).toHaveLength(12)); - - const activeIntervalRequests = mockFetchHistory.mock.calls - .map(([request]) => request) - .filter((request) => request.interval.value === '1'); - expect(activeIntervalRequests).toHaveLength(7); - expect(activeIntervalRequests[4]).toEqual( - expect.objectContaining({ - timeFrom: olderActiveDayTimestamp, - timeTo: olderActiveDayTimestamp + 24 * 60 * 60 - 1, - }), - ); - expect(activeIntervalRequests[5]).toEqual( - expect.objectContaining({ - timeFrom: olderActiveDayTimestamp, - timeTo: olderBatchPoints[3].timestamp - 1, - }), - ); - expect(activeIntervalRequests[6]).toEqual( - expect.objectContaining({ - timeFrom: olderActiveDayTimestamp, - timeTo: olderBatchPoints[0].timestamp - 1, - }), - ); - expect(result.current.points[0]?.t).toBe(olderBatchPoints[0].timestamp); - }); - it('stops sparse Market pagination at the refined daily boundary', async () => { mockHistoryBatchSize = 2; mockHistoryRequestCandleCount = 2; @@ -1405,11 +1139,10 @@ describe('TradingViewNative K-line data state machine', () => { it('keeps sparse pagination retryable after boundary prefetch fails', async () => { jest.useFakeTimers(); - mockHistoryBatchSize = 2; - mockHistoryRequestCandleCount = 2; + mockHistoryBatchSize = 299; + mockHistoryRequestCandleCount = 2000; mockReadTradingViewNativeActiveInterval.mockReturnValue('1'); let activeIntervalRequestCount = 0; - let dailyRequestCount = 0; let weeklyRequestCount = 0; const recoveryPoints = Array.from({ length: 99 }, (_, index) => ({ close: 70 + index, @@ -1422,18 +1155,17 @@ describe('TradingViewNative K-line data state machine', () => { return weeklyRequestCount <= 3 ? null : buildResponse(50, 100_000); } if (interval.value === '1D') { - dailyRequestCount += 1; - return dailyRequestCount === 1 - ? buildResponse(60, 110_000) - : buildResponse(70, 920_000); + return buildResponse(60, 110_000); } activeIntervalRequestCount += 1; if (activeIntervalRequestCount === 1) { - return buildMultiPointResponse([ - { close: 100, timestamp: 1_000_000 }, - { close: 110, timestamp: 1_000_060 }, - ]); + return buildMultiPointResponse( + Array.from({ length: 299 }, (_, index) => ({ + close: 100 + index, + timestamp: 1_000_000 + index * 60, + })), + ); } if (activeIntervalRequestCount <= 3) { return { points: [], total: 0 }; @@ -1449,7 +1181,7 @@ describe('TradingViewNative K-line data state machine', () => { useTradingViewNativeKLine({ source: buildMarketSource() }), ); - await waitFor(() => expect(result.current.points).toHaveLength(2)); + await waitFor(() => expect(result.current.points).toHaveLength(299)); act(() => result.current.handleVisiblePointRangeChange({ startIndex: 0 })); await act(async () => { await jest.advanceTimersByTimeAsync(4001); @@ -1458,9 +1190,9 @@ describe('TradingViewNative K-line data state machine', () => { expect(activeIntervalRequestCount).toBe(2); act(() => result.current.handleVisiblePointRangeChange({ startIndex: 0 })); - await waitFor(() => expect(result.current.points).toHaveLength(101)); + await waitFor(() => expect(result.current.points).toHaveLength(398)); expect(weeklyRequestCount).toBe(4); - expect(activeIntervalRequestCount).toBe(5); + expect(activeIntervalRequestCount).toBe(4); }); it('keeps load-more ownership when aborted initial recovery settles', async () => { diff --git a/packages/kit/src/components/TradingView/TradingViewNative/data/useTradingViewNativeKLine.ts b/packages/kit/src/components/TradingView/TradingViewNative/data/useTradingViewNativeKLine.ts index c050519a8e70..3cb49e2ad36c 100644 --- a/packages/kit/src/components/TradingView/TradingViewNative/data/useTradingViewNativeKLine.ts +++ b/packages/kit/src/components/TradingView/TradingViewNative/data/useTradingViewNativeKLine.ts @@ -62,9 +62,6 @@ const HISTORY_BOUNDARY_PREFETCH_CACHE_MAX_SIZE = 100; const HISTORY_BOUNDARY_PREFETCH_CACHE_TTL = 24 * 60 * 60 * 1000; const HISTORY_BOUNDARY_SEARCH_INTERVAL_VALUE: ITradingViewNativeChartInterval = '1W'; -const SPARSE_MARKET_HISTORY_LOCATOR_INTERVAL_VALUE: ITradingViewNativeChartInterval = - '1D'; -const SPARSE_MARKET_HISTORY_REQUEST_RANGE_COUNT = 8; const HISTORY_RETRY_DELAYS = [1000, 3000] as const; const MAX_VIEWPORT_HISTORY_PAGE_COUNT = 20; const MAX_VIEWPORT_HISTORY_BOUNDARY_SEARCH_COUNT = 32; @@ -739,49 +736,13 @@ function prefetchHistoryBoundaryPage({ return promise; } -function getSparseHistoryRequestRanges({ - activeDays, - boundaryTimestamp, - locatorIntervalSeconds, - timeTo, -}: { - activeDays: IMarketTokenKLineDataPoint[]; - boundaryTimestamp: number; - locatorIntervalSeconds: number; - timeTo: number; -}) { - // Group locator candidates into a small fixed number of chronological - // ranges instead of issuing one minute-history request per active day. - const activeDayBatchSize = Math.max( - Math.ceil(activeDays.length / SPARSE_MARKET_HISTORY_REQUEST_RANGE_COUNT), - 1, - ); - const ranges: { timeFrom: number; timeTo: number }[] = []; - for ( - let batchEndIndex = activeDays.length; - batchEndIndex > 0; - batchEndIndex -= activeDayBatchSize - ) { - const batchStartIndex = Math.max(batchEndIndex - activeDayBatchSize, 0); - const oldestActiveDay = activeDays[batchStartIndex]; - const newestActiveDay = activeDays[batchEndIndex - 1]; - if (!oldestActiveDay || !newestActiveDay) { - break; - } - ranges.push({ - timeFrom: Math.max(oldestActiveDay.t, boundaryTimestamp), - timeTo: Math.min(timeTo, newestActiveDay.t + locatorIntervalSeconds - 1), - }); - } - return ranges; -} - async function recoverOlderHistoryFromBoundary({ historyProvider, interval, onProgress, seriesKey, signal, + targetPointCount, timeTo, }: { historyProvider: ITradingViewNativeDataProvider; @@ -789,6 +750,7 @@ async function recoverOlderHistoryFromBoundary({ onProgress?: (result: IHistoryGapRecoveryResult) => void; seriesKey: string; signal: AbortSignal; + targetPointCount: number; timeTo: number; }): Promise { const boundaryPage = await (getHistoryBoundaryPrefetchPage(seriesKey) ?? @@ -812,46 +774,10 @@ async function recoverOlderHistoryFromBoundary({ }; } - const locatorInterval = TRADING_VIEW_NATIVE_KLINE_INTERVALS.find( - (candidate) => - candidate.value === SPARSE_MARKET_HISTORY_LOCATOR_INTERVAL_VALUE, - ); - if (!locatorInterval) { - return null; - } - // A daily lookup skips empty calendar spans before minute requests are made. - const locatorData = await fetchRequiredHistoryPage({ - historyProvider, - request: { - interval: locatorInterval, - signal, - timeFrom: boundaryTimestamp, - timeTo, - }, - unavailableMessage: - 'No daily candle history response is available for sparse history recovery', - }); - if (signal.aborted) { - return null; - } - const activeDays = normalizeKLinePointsInRange({ - from: boundaryTimestamp, - points: locatorData.points, - to: timeTo, - }); - if (!activeDays.length) { - return null; - } - - const requestRanges = getSparseHistoryRequestRanges({ - activeDays, - boundaryTimestamp, - locatorIntervalSeconds: locatorInterval.seconds, - timeTo, - }); let cursorTimeTo = Math.floor(timeTo); let historySource: 'fallback' | undefined; let points: IMarketTokenKLineDataPoint[] = []; + const normalizedTargetPointCount = Math.max(Math.floor(targetPointCount), 1); const buildResult = (): IHistoryGapRecoveryResult => { const hasMoreBefore = cursorTimeTo >= boundaryTimestamp; return { @@ -862,75 +788,88 @@ async function recoverOlderHistoryFromBoundary({ points, }; }; - for (const requestRange of requestRanges) { - if (cursorTimeTo < boundaryTimestamp) { - break; + const fetchTimeRange = async ( + rangeTimeFrom: number, + rangeTimeTo: number, + ): Promise<'aborted' | 'complete' | 'fallback'> => { + const data = await fetchRequiredHistoryPage({ + historyProvider, + request: { + interval, + signal, + timeFrom: rangeTimeFrom, + timeTo: rangeTimeTo, + }, + unavailableMessage: + 'No candle history response is available for sparse history recovery', + }); + if (signal.aborted) { + return 'aborted'; } - const pageTimeFrom = requestRange.timeFrom; - let pageTimeTo = Math.min(cursorTimeTo, requestRange.timeTo); - // The daily locator defines the complete sparse-history range. Exhaust - // every capped minute page instead of stopping at a viewport-sized count. - while (pageTimeTo >= pageTimeFrom) { - const data = await fetchRequiredHistoryPage({ - historyProvider, - request: { - interval, - signal, - timeFrom: pageTimeFrom, - timeTo: pageTimeTo, - }, - unavailableMessage: - 'No candle history response is available for sparse history recovery', - }); - if (signal.aborted) { - return null; - } - historySource = data.historySource; - if (historySource === 'fallback') { - return { - boundaryTimestamp, - cursorTimestamp: pageTimeFrom, - hasMoreBefore: false, - historySource, - points: [], - }; - } - const pagePoints = normalizeKLinePointsInRange({ - from: pageTimeFrom, - points: data.points, - to: pageTimeTo, - }); - points = mergeKLinePoints(points, pagePoints); - const pageHasMoreHistory = historyProvider.hasMoreHistory({ - historySource: data.historySource, - interval, - receivedPointCount: data.points.length, - }); - if (!pageHasMoreHistory) { - cursorTimeTo = pageTimeFrom - 1; - onProgress?.(buildResult()); - break; - } + historySource = data.historySource; + if (historySource === 'fallback') { + return 'fallback'; + } + const rangePoints = normalizeKLinePointsInRange({ + from: rangeTimeFrom, + points: data.points, + to: rangeTimeTo, + }); + points = mergeKLinePoints(points, rangePoints); + onProgress?.(buildResult()); - const earliestPageTimestamp = pagePoints[0]?.t; - if (earliestPageTimestamp === undefined) { - return buildResult(); - } - const nextPageTimeTo = earliestPageTimestamp - 1; - if (nextPageTimeTo < pageTimeFrom) { - cursorTimeTo = pageTimeFrom - 1; - onProgress?.(buildResult()); - break; - } - if (nextPageTimeTo >= pageTimeTo) { - return buildResult(); - } + const rangeMayBeTruncated = historyProvider.hasMoreHistory({ + historySource: data.historySource, + interval, + receivedPointCount: rangePoints.length, + }); + if (!rangeMayBeTruncated || rangeTimeFrom >= rangeTimeTo) { + return 'complete'; + } - cursorTimeTo = nextPageTimeTo; - pageTimeTo = nextPageTimeTo; - onProgress?.(buildResult()); + // A capped response only means this time block needs finer coverage. It + // must never move the cursor across chart history that was not requested. + const midpoint = Math.floor((rangeTimeFrom + rangeTimeTo) / 2); + const newerRangeResult = await fetchTimeRange(midpoint + 1, rangeTimeTo); + if (newerRangeResult !== 'complete') { + return newerRangeResult; + } + return fetchTimeRange(rangeTimeFrom, midpoint); + }; + const requestCandleCount = Math.max( + Math.floor(historyProvider.getHistoryRequestCandleCount(interval)), + 1, + ); + while ( + cursorTimeTo >= boundaryTimestamp && + points.length < normalizedTargetPointCount + ) { + const rangeTimeTo = cursorTimeTo; + const rangeTimeFrom = Math.max( + getHistoryTimeFrom({ + candleCount: requestCandleCount, + intervalSeconds: interval.seconds, + timeTo: rangeTimeTo, + }), + boundaryTimestamp, + ); + const rangeResult = await fetchTimeRange(rangeTimeFrom, rangeTimeTo); + if (rangeResult === 'aborted') { + return null; } + if (rangeResult === 'fallback') { + return { + boundaryTimestamp, + cursorTimestamp: rangeTimeFrom, + hasMoreBefore: false, + historySource, + points: [], + }; + } + + cursorTimeTo = rangeTimeFrom - 1; + onProgress?.(buildResult()); } return buildResult(); @@ -3451,6 +3390,10 @@ export function useTradingViewNativeKLine({ if (shouldRecoverSparseHistory) { publishOlderPoints(olderPoints); const recoveryTimeTo = Math.max(timeFrom - 1, 0); + const recoveryTargetPointCount = Math.max( + olderHistoryPreloadPointCount - olderPoints.length, + 1, + ); const applyRecoveryProgress = createHistoryGapRecoveryProgressHandler({ coverageState: historyCoverageRef.current, @@ -3470,6 +3413,7 @@ export function useTradingViewNativeKLine({ onProgress: applyRecoveryProgress, seriesKey, signal: abortController.signal, + targetPointCount: recoveryTargetPointCount, timeTo: recoveryTimeTo, }); if ( @@ -4157,6 +4101,11 @@ export function useTradingViewNativeKLine({ onProgress: applyRecoveryProgress, seriesKey, signal: abortController.signal, + targetPointCount: Math.max( + TRADING_VIEW_NATIVE_TIME_RANGE_MAX_CANDLE_COUNT - + receivedHistoryPointCount, + 1, + ), timeTo: Math.max(initialEarliestTimestamp - 1, 0), }); if ( From 1aba8e86278605466d405d8da907d3ca648dcb70 Mon Sep 17 00:00:00 2001 From: limichange Date: Tue, 18 Aug 2026 16:32:19 +0800 Subject: [PATCH 11/15] fix: update TradingView source key assertion OK-60116 --- .../Swap/pages/modal/swapKLineTradingViewNativeUtils.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/kit/src/views/Swap/pages/modal/swapKLineTradingViewNativeUtils.test.ts b/packages/kit/src/views/Swap/pages/modal/swapKLineTradingViewNativeUtils.test.ts index 614d5d75d8a1..b7c274841f80 100644 --- a/packages/kit/src/views/Swap/pages/modal/swapKLineTradingViewNativeUtils.test.ts +++ b/packages/kit/src/views/Swap/pages/modal/swapKLineTradingViewNativeUtils.test.ts @@ -111,7 +111,7 @@ describe('Swap K-line TradingViewNative source', () => { realtime: 'disabled', }); expect(getSwapKLineTradingViewNativeSourceKey(source)).toBe( - 'market:evm--1:0xabc:ETH', + 'market:evm--1:0xabc', ); }); From 2f3ff51f35e003094d8b9be6c088e403af05307b Mon Sep 17 00:00:00 2001 From: limichange Date: Tue, 18 Aug 2026 20:13:23 +0800 Subject: [PATCH 12/15] fix: avoid recursive sparse kline requests OK-60116 --- .../data/useTradingViewNativeKLine.test.ts | 45 +++++--- .../data/useTradingViewNativeKLine.ts | 105 +++++++----------- 2 files changed, 73 insertions(+), 77 deletions(-) diff --git a/packages/kit/src/components/TradingView/TradingViewNative/data/useTradingViewNativeKLine.test.ts b/packages/kit/src/components/TradingView/TradingViewNative/data/useTradingViewNativeKLine.test.ts index 1818fb11a48d..2d5482a43df5 100644 --- a/packages/kit/src/components/TradingView/TradingViewNative/data/useTradingViewNativeKLine.test.ts +++ b/packages/kit/src/components/TradingView/TradingViewNative/data/useTradingViewNativeKLine.test.ts @@ -772,7 +772,7 @@ describe('TradingViewNative K-line data state machine', () => { }, ); - it('continues through short and empty time blocks until the next-screen target', async () => { + it('continues through short and empty time windows until the next-screen target', async () => { const intervalSeconds = 60; const initialTimestamp = 1_000_000; const requestCandleCount = 200; @@ -855,7 +855,7 @@ describe('TradingViewNative K-line data state machine', () => { unmount(); }); - it('refines a capped time block without skipping its uncovered timestamps', async () => { + it('uses the earliest candle in a capped boundary page as the next cursor', async () => { const intervalSeconds = 60; const initialTimestamp = 1_000_000; const boundaryTimestamp = 800_000; @@ -863,6 +863,8 @@ describe('TradingViewNative K-line data state machine', () => { close: 70 + index, timestamp: 880_100 + index * 400, })); + const nextLoadMoreRequest = + createDeferred(); mockHistoryBatchSize = 299; mockHistoryRequestCandleCount = 2000; mockReadTradingViewNativeActiveInterval.mockReturnValue('1'); @@ -885,30 +887,43 @@ describe('TradingViewNative K-line data state machine', () => { })), ); } - return buildMultiPointResponse( - cappedPoints.filter( - (point) => point.timestamp >= timeFrom && point.timestamp <= timeTo, - ), - ); + if (activeIntervalRequestCount === 2) { + expect({ timeFrom, timeTo }).toEqual({ + timeFrom: 879_999, + timeTo: initialTimestamp - 1, + }); + return buildMultiPointResponse(cappedPoints); + } + return nextLoadMoreRequest.promise; }, ); - const { result } = renderHook(() => + const { result, unmount } = renderHook(() => useTradingViewNativeKLine({ source: buildMarketSource() }), ); await waitFor(() => expect(result.current.points).toHaveLength(306)); - expect(activeIntervalRequestCount).toBe(4); + expect(activeIntervalRequestCount).toBe(2); + + act(() => + result.current.handleVisiblePointRangeChange({ + endIndex: result.current.points.length, + startIndex: 0, + }), + ); + await waitFor(() => expect(activeIntervalRequestCount).toBe(3)); const activeIntervalRequests = mockFetchHistory.mock.calls .map(([request]) => request) .filter((request) => request.interval.value === '1'); - expect(activeIntervalRequests.slice(1)).toEqual([ - expect.objectContaining({ timeFrom: 879_999, timeTo: 999_999 }), - expect.objectContaining({ timeFrom: 940_000, timeTo: 999_999 }), - expect.objectContaining({ timeFrom: 879_999, timeTo: 939_999 }), - ]); + expect(activeIntervalRequests[2]).toEqual( + expect.objectContaining({ + timeTo: (cappedPoints[0]?.timestamp ?? 0) - 1, + }), + ); + + unmount(); }); - it('crosses more than eight empty time blocks before reaching the global boundary', async () => { + it('continues through empty time windows until the real history boundary', async () => { const initialTimestamp = 1_000_000; const boundaryTimestamp = 998_790; mockHistoryBatchSize = 299; diff --git a/packages/kit/src/components/TradingView/TradingViewNative/data/useTradingViewNativeKLine.ts b/packages/kit/src/components/TradingView/TradingViewNative/data/useTradingViewNativeKLine.ts index 3cb49e2ad36c..cc00500ae89f 100644 --- a/packages/kit/src/components/TradingView/TradingViewNative/data/useTradingViewNativeKLine.ts +++ b/packages/kit/src/components/TradingView/TradingViewNative/data/useTradingViewNativeKLine.ts @@ -775,23 +775,35 @@ async function recoverOlderHistoryFromBoundary({ } let cursorTimeTo = Math.floor(timeTo); + let cursorTimestamp = cursorTimeTo + 1; let historySource: 'fallback' | undefined; let points: IMarketTokenKLineDataPoint[] = []; const normalizedTargetPointCount = Math.max(Math.floor(targetPointCount), 1); - const buildResult = (): IHistoryGapRecoveryResult => { - const hasMoreBefore = cursorTimeTo >= boundaryTimestamp; - return { + const requestCandleCount = Math.max( + Math.floor(historyProvider.getHistoryRequestCandleCount(interval)), + 1, + ); + const buildResult = (): IHistoryGapRecoveryResult => ({ + boundaryTimestamp, + cursorTimestamp, + hasMoreBefore: cursorTimestamp > boundaryTimestamp, + historySource, + points, + }); + + while ( + cursorTimeTo >= boundaryTimestamp && + points.length < normalizedTargetPointCount + ) { + const rangeTimeTo = cursorTimeTo; + const rangeTimeFrom = Math.max( + getHistoryTimeFrom({ + candleCount: requestCandleCount, + intervalSeconds: interval.seconds, + timeTo: rangeTimeTo, + }), boundaryTimestamp, - cursorTimestamp: hasMoreBefore ? cursorTimeTo + 1 : boundaryTimestamp, - hasMoreBefore, - historySource, - points, - }; - }; - const fetchTimeRange = async ( - rangeTimeFrom: number, - rangeTimeTo: number, - ): Promise<'aborted' | 'complete' | 'fallback'> => { + ); const data = await fetchRequiredHistoryPage({ historyProvider, request: { @@ -804,71 +816,40 @@ async function recoverOlderHistoryFromBoundary({ 'No candle history response is available for sparse history recovery', }); if (signal.aborted) { - return 'aborted'; + return null; } historySource = data.historySource; if (historySource === 'fallback') { - return 'fallback'; + return { + boundaryTimestamp, + cursorTimestamp: boundaryTimestamp, + hasMoreBefore: false, + historySource, + points: [], + }; } + const rangePoints = normalizeKLinePointsInRange({ from: rangeTimeFrom, points: data.points, to: rangeTimeTo, }); points = mergeKLinePoints(points, rangePoints); - onProgress?.(buildResult()); - const rangeMayBeTruncated = historyProvider.hasMoreHistory({ - historySource: data.historySource, + historySource, interval, receivedPointCount: rangePoints.length, }); - if (!rangeMayBeTruncated || rangeTimeFrom >= rangeTimeTo) { - return 'complete'; - } - - // A capped response only means this time block needs finer coverage. It - // must never move the cursor across chart history that was not requested. - const midpoint = Math.floor((rangeTimeFrom + rangeTimeTo) / 2); - const newerRangeResult = await fetchTimeRange(midpoint + 1, rangeTimeTo); - if (newerRangeResult !== 'complete') { - return newerRangeResult; - } - return fetchTimeRange(rangeTimeFrom, midpoint); - }; - const requestCandleCount = Math.max( - Math.floor(historyProvider.getHistoryRequestCandleCount(interval)), - 1, - ); - while ( - cursorTimeTo >= boundaryTimestamp && - points.length < normalizedTargetPointCount - ) { - const rangeTimeTo = cursorTimeTo; - const rangeTimeFrom = Math.max( - getHistoryTimeFrom({ - candleCount: requestCandleCount, - intervalSeconds: interval.seconds, - timeTo: rangeTimeTo, - }), - boundaryTimestamp, - ); - const rangeResult = await fetchTimeRange(rangeTimeFrom, rangeTimeTo); - if (rangeResult === 'aborted') { - return null; - } - if (rangeResult === 'fallback') { - return { - boundaryTimestamp, - cursorTimestamp: rangeTimeFrom, - hasMoreBefore: false, - historySource, - points: [], - }; - } - cursorTimeTo = rangeTimeFrom - 1; + // A short page only exhausts its requested time window. Keep walking older + // windows until the next-screen target or the real history boundary is met. + // A capped page resumes from its earliest returned candle without splitting + // and refetching the same range. + cursorTimestamp = rangeMayBeTruncated + ? (rangePoints[0]?.t ?? rangeTimeFrom) + : rangeTimeFrom; + cursorTimeTo = cursorTimestamp - 1; onProgress?.(buildResult()); } From a17846ca28a8a3fb9bbeec6270eec661484a1ffc Mon Sep 17 00:00:00 2001 From: limichange Date: Tue, 18 Aug 2026 21:46:20 +0800 Subject: [PATCH 13/15] fix: cap consecutive empty kline windows OK-60116 --- .../data/useTradingViewNativeKLine.test.ts | 128 ++++++++++++++++-- .../data/useTradingViewNativeKLine.ts | 18 ++- 2 files changed, 134 insertions(+), 12 deletions(-) diff --git a/packages/kit/src/components/TradingView/TradingViewNative/data/useTradingViewNativeKLine.test.ts b/packages/kit/src/components/TradingView/TradingViewNative/data/useTradingViewNativeKLine.test.ts index 2d5482a43df5..594307d021a8 100644 --- a/packages/kit/src/components/TradingView/TradingViewNative/data/useTradingViewNativeKLine.test.ts +++ b/packages/kit/src/components/TradingView/TradingViewNative/data/useTradingViewNativeKLine.test.ts @@ -923,12 +923,14 @@ describe('TradingViewNative K-line data state machine', () => { unmount(); }); - it('continues through empty time windows until the real history boundary', async () => { + it('stops a recovery batch after ten consecutive empty time windows', async () => { const initialTimestamp = 1_000_000; - const boundaryTimestamp = 998_790; + const boundaryTimestamp = 900_000; mockHistoryBatchSize = 299; mockHistoryRequestCandleCount = 2; mockReadTradingViewNativeActiveInterval.mockReturnValue('1'); + const resumedLoadMoreRequest = + createDeferred(); let activeIntervalRequestCount = 0; mockFetchHistory.mockImplementation(async ({ interval }) => { if (interval.value === '1W') { @@ -939,14 +941,18 @@ describe('TradingViewNative K-line data state machine', () => { } activeIntervalRequestCount += 1; - return activeIntervalRequestCount === 1 - ? buildMultiPointResponse([ - { close: 100, timestamp: initialTimestamp }, - { close: 110, timestamp: initialTimestamp + 60 }, - ]) - : { points: [], total: 0 }; + if (activeIntervalRequestCount === 1) { + return buildMultiPointResponse([ + { close: 100, timestamp: initialTimestamp }, + { close: 110, timestamp: initialTimestamp + 60 }, + ]); + } + if (activeIntervalRequestCount === 12) { + return resumedLoadMoreRequest.promise; + } + return { points: [], total: 0 }; }); - const { result } = renderHook(() => + const { result, unmount } = renderHook(() => useTradingViewNativeKLine({ source: buildMarketSource() }), ); @@ -963,7 +969,109 @@ describe('TradingViewNative K-line data state machine', () => { recoveryRequests.slice(1).forEach((request, index) => { expect(request.timeTo).toBe(recoveryRequests[index].timeFrom - 1); }); - expect(recoveryRequests.at(-1)?.timeFrom).toBe(boundaryTimestamp); + expect(recoveryRequests.at(-1)?.timeFrom).toBe(998_790); + expect(recoveryRequests.at(-1)?.timeFrom).toBeGreaterThan( + boundaryTimestamp, + ); + + act(() => result.current.handleVisiblePointRangeChange({ startIndex: 0 })); + await waitFor(() => expect(activeIntervalRequestCount).toBe(12)); + expect( + mockFetchHistory.mock.calls.filter( + ([request]) => request.interval.value === '1', + )[11]?.[0], + ).toEqual( + expect.objectContaining({ + timeTo: 998_789, + }), + ); + + unmount(); + }); + + it('resets the consecutive empty-window limit after receiving a candle', async () => { + const initialTimestamp = 1_000_000; + const boundaryTimestamp = 900_000; + mockHistoryBatchSize = 299; + mockHistoryRequestCandleCount = 2; + mockReadTradingViewNativeActiveInterval.mockReturnValue('1'); + let activeIntervalRequestCount = 0; + mockFetchHistory.mockImplementation(async ({ interval, timeFrom }) => { + if (interval.value === '1W') { + return buildResponse(50, boundaryTimestamp - 3600); + } + if (interval.value === '1D') { + return buildResponse(60, boundaryTimestamp); + } + + activeIntervalRequestCount += 1; + if (activeIntervalRequestCount === 1) { + return buildMultiPointResponse( + Array.from({ length: 196 }, (_, index) => ({ + close: 100 + index, + timestamp: initialTimestamp + index * 60, + })), + ); + } + const recoveryRequestCount = activeIntervalRequestCount - 1; + if (recoveryRequestCount === 10 || recoveryRequestCount === 20) { + return buildResponse(70 + recoveryRequestCount, timeFrom + 1); + } + return { points: [], total: 0 }; + }); + const { result } = renderHook(() => + useTradingViewNativeKLine({ source: buildMarketSource() }), + ); + + await waitFor(() => expect(result.current.points).toHaveLength(198)); + expect(activeIntervalRequestCount).toBe(21); + }); + + it('counts an empty load-more window toward the ten-window limit', async () => { + const initialTimestamp = 1_000_000; + const boundaryTimestamp = 900_000; + mockHistoryBatchSize = 299; + mockHistoryRequestCandleCount = 2; + mockReadTradingViewNativeActiveInterval.mockReturnValue('1'); + let activeIntervalRequestCount = 0; + mockFetchHistory.mockImplementation(async ({ interval }) => { + if (interval.value === '1W') { + return buildResponse(50, boundaryTimestamp - 3600); + } + if (interval.value === '1D') { + return buildResponse(60, boundaryTimestamp); + } + + activeIntervalRequestCount += 1; + if (activeIntervalRequestCount === 1) { + return buildMultiPointResponse( + Array.from({ length: 299 }, (_, index) => ({ + close: 100 + index, + timestamp: initialTimestamp + index * 60, + })), + ); + } + return { points: [], total: 0 }; + }); + const { result } = renderHook(() => + useTradingViewNativeKLine({ source: buildMarketSource() }), + ); + + await waitFor(() => expect(result.current.points).toHaveLength(299)); + act(() => result.current.handleVisiblePointRangeChange({ startIndex: 0 })); + await waitFor(() => + expect(result.current.calendarAvailableTimeRange).toEqual({ + from: boundaryTimestamp, + }), + ); + + expect(activeIntervalRequestCount).toBe(11); + expect( + mockFetchHistory.mock.calls + .map(([request]) => request) + .filter((request) => request.interval.value === '1') + .slice(2), + ).toHaveLength(9); }); it.each([ diff --git a/packages/kit/src/components/TradingView/TradingViewNative/data/useTradingViewNativeKLine.ts b/packages/kit/src/components/TradingView/TradingViewNative/data/useTradingViewNativeKLine.ts index cc00500ae89f..a3479104b236 100644 --- a/packages/kit/src/components/TradingView/TradingViewNative/data/useTradingViewNativeKLine.ts +++ b/packages/kit/src/components/TradingView/TradingViewNative/data/useTradingViewNativeKLine.ts @@ -63,6 +63,7 @@ const HISTORY_BOUNDARY_PREFETCH_CACHE_TTL = 24 * 60 * 60 * 1000; const HISTORY_BOUNDARY_SEARCH_INTERVAL_VALUE: ITradingViewNativeChartInterval = '1W'; const HISTORY_RETRY_DELAYS = [1000, 3000] as const; +const MAX_SPARSE_HISTORY_CONSECUTIVE_EMPTY_WINDOW_COUNT = 10; const MAX_VIEWPORT_HISTORY_PAGE_COUNT = 20; const MAX_VIEWPORT_HISTORY_BOUNDARY_SEARCH_COUNT = 32; const MAX_REALTIME_BUFFER_CANDLES = 160; @@ -738,6 +739,7 @@ function prefetchHistoryBoundaryPage({ async function recoverOlderHistoryFromBoundary({ historyProvider, + initialConsecutiveEmptyWindowCount = 0, interval, onProgress, seriesKey, @@ -746,6 +748,7 @@ async function recoverOlderHistoryFromBoundary({ timeTo, }: { historyProvider: ITradingViewNativeDataProvider; + initialConsecutiveEmptyWindowCount?: number; interval: ITradingViewNativeKLineInterval; onProgress?: (result: IHistoryGapRecoveryResult) => void; seriesKey: string; @@ -776,6 +779,10 @@ async function recoverOlderHistoryFromBoundary({ let cursorTimeTo = Math.floor(timeTo); let cursorTimestamp = cursorTimeTo + 1; + let consecutiveEmptyWindowCount = Math.max( + Math.floor(initialConsecutiveEmptyWindowCount), + 0, + ); let historySource: 'fallback' | undefined; let points: IMarketTokenKLineDataPoint[] = []; const normalizedTargetPointCount = Math.max(Math.floor(targetPointCount), 1); @@ -793,7 +800,9 @@ async function recoverOlderHistoryFromBoundary({ while ( cursorTimeTo >= boundaryTimestamp && - points.length < normalizedTargetPointCount + points.length < normalizedTargetPointCount && + consecutiveEmptyWindowCount < + MAX_SPARSE_HISTORY_CONSECUTIVE_EMPTY_WINDOW_COUNT ) { const rangeTimeTo = cursorTimeTo; const rangeTimeFrom = Math.max( @@ -835,6 +844,9 @@ async function recoverOlderHistoryFromBoundary({ points: data.points, to: rangeTimeTo, }); + consecutiveEmptyWindowCount = rangePoints.length + ? 0 + : consecutiveEmptyWindowCount + 1; points = mergeKLinePoints(points, rangePoints); const rangeMayBeTruncated = historyProvider.hasMoreHistory({ historySource, @@ -843,7 +855,8 @@ async function recoverOlderHistoryFromBoundary({ }); // A short page only exhausts its requested time window. Keep walking older - // windows until the next-screen target or the real history boundary is met. + // windows until the next-screen target, the real history boundary, or the + // consecutive-empty safety limit is met. // A capped page resumes from its earliest returned candle without splitting // and refetching the same range. cursorTimestamp = rangeMayBeTruncated @@ -3390,6 +3403,7 @@ export function useTradingViewNativeKLine({ }); const recovery = await recoverOlderHistoryFromBoundary({ historyProvider, + initialConsecutiveEmptyWindowCount: olderPoints.length ? 0 : 1, interval, onProgress: applyRecoveryProgress, seriesKey, From bfe2ae18c4e484f656e4c9d5928dbf7cac959f39 Mon Sep 17 00:00:00 2001 From: limichange Date: Wed, 19 Aug 2026 00:15:02 +0800 Subject: [PATCH 14/15] fix: keep kline history source consistent OK-60116 --- ...reateTradingViewNativeDataProvider.test.ts | 108 +++++++++++++++++- .../providers/market/marketDataProvider.ts | 35 ++++-- .../data/useTradingViewNativeKLine.test.ts | 106 ++++++++++++++++- .../data/useTradingViewNativeKLine.ts | 45 +++++--- 4 files changed, 260 insertions(+), 34 deletions(-) diff --git a/packages/kit/src/components/TradingView/TradingViewNative/data/providers/createTradingViewNativeDataProvider.test.ts b/packages/kit/src/components/TradingView/TradingViewNative/data/providers/createTradingViewNativeDataProvider.test.ts index f4ec9b636781..60fb15956d65 100644 --- a/packages/kit/src/components/TradingView/TradingViewNative/data/providers/createTradingViewNativeDataProvider.test.ts +++ b/packages/kit/src/components/TradingView/TradingViewNative/data/providers/createTradingViewNativeDataProvider.test.ts @@ -462,6 +462,7 @@ describe('TradingViewNative data providers', () => { chartRequest.resolve([[7200, 10]]); await Promise.all([firstRequest, secondRequest]); await expect(provider.fetchHistory(request)).resolves.toEqual({ + historySource: 'fallback', pointType: 'single', points: [], total: 0, @@ -553,7 +554,7 @@ describe('TradingViewNative data providers', () => { expect(mocks?.tokenInfoFetch).toHaveBeenCalledTimes(1); }); - it('keeps using CoinGecko after fallback selects the history source', async () => { + it('keeps using CoinGecko at every interval after fallback selects the history source', async () => { const mocks = globalMockBag.__tradingViewNativeProviderMocks; mocks?.coinGeckoFetchChart.mockResolvedValue([[7200, 10]]); mocks?.marketFetchHistory.mockImplementation((params) => @@ -597,14 +598,23 @@ describe('TradingViewNative data providers', () => { ).toBe(false); expect(provider.getHistoryRequestCandleCount(getInterval('60'))).toBe(720); - await provider.fetchHistory(request); + expect(provider.getHistoryRequestCandleCount(getInterval('1D'))).toBe( + 36_500, + ); + await provider.fetchHistory({ + ...request, + interval: getInterval('1D'), + }); expect(mocks?.marketFetchHistory).toHaveBeenNthCalledWith( 2, - expect.objectContaining({ primaryKLineDataUnavailable: true }), + expect.objectContaining({ + interval: '1D', + primaryKLineDataUnavailable: true, + }), ); }); - it('does not use CoinGecko after Market selects the history source', async () => { + it('does not use CoinGecko at any interval after Market selects the history source', async () => { const mocks = globalMockBag.__tradingViewNativeProviderMocks; mocks?.marketFetchHistory .mockResolvedValueOnce({ @@ -634,14 +644,102 @@ describe('TradingViewNative data providers', () => { points: [{ o: 10, h: 12, l: 9, c: 11, v: 1, t: 7200 }], total: 1, }); - await expect(provider.fetchHistory(request)).resolves.toEqual({ + await expect( + provider.fetchHistory({ + ...request, + interval: getInterval('1D'), + }), + ).resolves.toEqual({ points: [], total: 0, }); + expect(mocks?.marketFetchHistory).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ interval: '1D' }), + ); expect(mocks?.coinGeckoFetchChart).not.toHaveBeenCalled(); expect(mocks?.tokenInfoFetch).not.toHaveBeenCalled(); }); + it('keeps the first history source when another interval resolves concurrently', async () => { + const mocks = globalMockBag.__tradingViewNativeProviderMocks; + const allowFallback = createDeferred(); + mocks?.coinGeckoFetchChart.mockResolvedValue([[7200, 10]]); + mocks?.marketFetchHistory.mockImplementation(async (params) => { + if (params.interval === '1W') { + await allowFallback.promise; + return runMarketKLineDataFallback( + params, + { + tokenAddress: '', + networkId: 'btc--0', + interval: '1W', + timeFrom: 3600, + timeTo: 10_800, + }, + { primaryDataUnavailable: true }, + ); + } + if (params.interval === '1H') { + return { + points: [{ o: 10, h: 12, l: 9, c: 11, v: 1, t: 7200 }], + total: 1, + }; + } + return { points: [], total: 0 }; + }); + const provider = createTradingViewNativeDataProvider({ + kind: 'market', + fallbackCoinGeckoId: 'bitcoin', + networkId: 'btc--0', + tokenAddress: '', + symbol: 'BTC', + realtime: 'disabled', + }); + const weeklyRequest = provider.fetchHistory({ + interval: getInterval('1W'), + signal: new AbortController().signal, + timeFrom: 3600, + timeTo: 10_800, + }); + + await expect( + provider.fetchHistory({ + interval: getInterval('60'), + signal: new AbortController().signal, + timeFrom: 3600, + timeTo: 10_800, + }), + ).resolves.toEqual({ + points: [{ o: 10, h: 12, l: 9, c: 11, v: 1, t: 7200 }], + total: 1, + }); + + allowFallback.resolve(); + await expect(weeklyRequest).resolves.toEqual({ + historySource: undefined, + pointType: 'single', + points: [], + total: 0, + }); + expect(provider.getHistoryRequestCandleCount(getInterval('1D'))).toBe(2000); + + await provider.fetchHistory({ + interval: getInterval('1D'), + signal: new AbortController().signal, + timeFrom: 1, + timeTo: 99, + }); + expect(mocks?.marketFetchHistory).toHaveBeenNthCalledWith( + 3, + expect.objectContaining({ + interval: '1D', + kLineDataFallback: undefined, + primaryKLineDataUnavailable: false, + }), + ); + }); + it('adapts validated Market WS candles and owns subscription cleanup', async () => { const provider = createTradingViewNativeDataProvider({ kind: 'market', diff --git a/packages/kit/src/components/TradingView/TradingViewNative/data/providers/market/marketDataProvider.ts b/packages/kit/src/components/TradingView/TradingViewNative/data/providers/market/marketDataProvider.ts index c4db472212d9..09a91597ec5f 100644 --- a/packages/kit/src/components/TradingView/TradingViewNative/data/providers/market/marketDataProvider.ts +++ b/packages/kit/src/components/TradingView/TradingViewNative/data/providers/market/marketDataProvider.ts @@ -77,7 +77,7 @@ export function createTradingViewNativeMarketDataProvider({ let primaryHistoryUnavailable = !canUseMarketHistory || unavailableMarketHistoryTokenKeys.has(marketTokenKey); - const selectedHistorySources = new Map(); + let selectedHistorySource: 'fallback' | 'primary' | undefined; const marketHistoryPageSize = source.isNative || !source.tokenAddress.trim() ? MARKET_NATIVE_HISTORY_PAGE_SIZE @@ -91,8 +91,7 @@ export function createTradingViewNativeMarketDataProvider({ return { getHistoryRequestCandleCount: (interval) => - selectedHistorySources.get(interval.value) === 'fallback' || - primaryHistoryUnavailable + selectedHistorySource === 'fallback' || primaryHistoryUnavailable ? fallbackHistoryProvider.getHistoryRequestCandleCount(interval) : MARKET_HISTORY_REQUEST_CANDLE_COUNT, hasMoreHistory: (page) => @@ -105,11 +104,8 @@ export function createTradingViewNativeMarketDataProvider({ supportsRealtime: source.realtime === 'websocket', fetchHistory: async (request) => { const { interval, signal, timeFrom, timeTo } = request; - const selectedHistorySource = selectedHistorySources.get(interval.value); const selectHistorySource = (historySource: 'fallback' | 'primary') => { - if (!selectedHistorySources.has(interval.value)) { - selectedHistorySources.set(interval.value, historySource); - } + selectedHistorySource ??= historySource; }; let pointType: IMarketKLinePointType | undefined; let usedFallback = false; @@ -147,6 +143,9 @@ export function createTradingViewNativeMarketDataProvider({ selectHistorySource('fallback'); }, onPrimaryKLineDataUnavailable: () => { + if (selectedHistorySource === 'primary') { + return; + } primaryHistoryUnavailable = true; cacheUnavailableMarketHistoryTokenKey(marketTokenKey); }, @@ -159,12 +158,28 @@ export function createTradingViewNativeMarketDataProvider({ if (!data) { return null; } - if (!usedFallback && data.points.length) { - selectHistorySource('primary'); + const responseHistorySource = usedFallback ? 'fallback' : 'primary'; + if (data.points.length) { + selectHistorySource(responseHistorySource); + } + if ( + selectedHistorySource && + selectedHistorySource !== responseHistorySource + ) { + return { + ...data, + historySource: + selectedHistorySource === 'fallback' ? 'fallback' : undefined, + points: [], + total: 0, + ...(pointType ? { pointType } : {}), + }; } return { ...data, - ...(usedFallback ? { historySource: 'fallback' as const } : {}), + ...(selectedHistorySource === 'fallback' + ? { historySource: 'fallback' as const } + : {}), ...(pointType ? { pointType } : {}), }; }, diff --git a/packages/kit/src/components/TradingView/TradingViewNative/data/useTradingViewNativeKLine.test.ts b/packages/kit/src/components/TradingView/TradingViewNative/data/useTradingViewNativeKLine.test.ts index 594307d021a8..fb40636c88ed 100644 --- a/packages/kit/src/components/TradingView/TradingViewNative/data/useTradingViewNativeKLine.test.ts +++ b/packages/kit/src/components/TradingView/TradingViewNative/data/useTradingViewNativeKLine.test.ts @@ -356,6 +356,57 @@ describe('TradingViewNative K-line data state machine', () => { ).toBe(false); }); + it('keeps the selected history source across every interval', async () => { + mockFetchHistory.mockImplementation(async ({ interval }) => { + if (interval.value === '60') { + return buildResponse(100, 200_000); + } + if (interval.value === '1W') { + return { + ...buildResponse(80, 100_000), + historySource: 'fallback', + }; + } + return { + ...buildResponse(90, 110_000), + historySource: 'fallback', + }; + }); + const { result } = renderHook(() => + useTradingViewNativeKLine({ source: buildMarketSource() }), + ); + + await waitFor(() => expect(result.current.points).toHaveLength(1)); + mockEmitTradingViewNativeDebugEvent.mockClear(); + act(() => result.current.handleHistoryBoundaryPrefetch()); + await waitFor(() => + expect( + mockFetchHistory.mock.calls.some( + ([request]) => request.interval.value === '1W', + ), + ).toBe(true), + ); + + expect(result.current.calendarAvailableTimeRange).toBeUndefined(); + expect( + mockFetchHistory.mock.calls.some( + ([request]) => request.interval.value === '1D', + ), + ).toBe(false); + expect(mockEmitTradingViewNativeDebugEvent).toHaveBeenCalledWith( + expect.objectContaining({ + details: expect.objectContaining({ + historySource: 'fallback', + interval: '1W', + reason: 'source-mismatch', + selectedHistorySource: 'primary', + }), + level: 'warning', + name: 'history.response.dropped', + }), + ); + }); + it('logs a resolved response as aborted after its request is cancelled', async () => { const historyRequest = createDeferred(); @@ -855,6 +906,56 @@ describe('TradingViewNative K-line data state machine', () => { unmount(); }); + it('continues sparse recovery after dropping a fallback response', async () => { + const intervalSeconds = 60; + const initialTimestamp = 1_000_000; + const boundaryTimestamp = 900_000; + mockHistoryBatchSize = 299; + mockHistoryRequestCandleCount = 200; + mockReadTradingViewNativeActiveInterval.mockReturnValue('1'); + let activeIntervalRequestCount = 0; + mockFetchHistory.mockImplementation( + async ({ interval, timeFrom, timeTo }) => { + if (interval.value === '1W') { + return buildResponse(50, boundaryTimestamp - 3600); + } + if (interval.value === '1D') { + return buildResponse(60, boundaryTimestamp); + } + + activeIntervalRequestCount += 1; + if (activeIntervalRequestCount === 1) { + return buildMultiPointResponse( + Array.from({ length: 7 }, (_, index) => ({ + close: 100 + index, + timestamp: initialTimestamp + index * intervalSeconds, + })), + ); + } + if (activeIntervalRequestCount === 2) { + return { + ...buildResponse(80, timeFrom + 1), + historySource: 'fallback', + }; + } + return buildMultiPointResponse( + Array.from({ length: 191 }, (_, index) => ({ + close: 70 + index, + timestamp: timeFrom + 1 + index * intervalSeconds, + })).filter( + (point) => point.timestamp >= timeFrom && point.timestamp <= timeTo, + ), + ); + }, + ); + const { result } = renderHook(() => + useTradingViewNativeKLine({ source: buildMarketSource() }), + ); + + await waitFor(() => expect(result.current.points).toHaveLength(198)); + expect(activeIntervalRequestCount).toBe(3); + }); + it('uses the earliest candle in a capped boundary page as the next cursor', async () => { const intervalSeconds = 60; const initialTimestamp = 1_000_000; @@ -1588,12 +1689,15 @@ describe('TradingViewNative K-line data state machine', () => { const latestIntervalTimestamp = currentTimestamp - 300; mockHistoryRequestCandleCount = 288; mockReadTradingViewNativeActiveInterval.mockReturnValue('1'); + mockHasMoreHistory.mockImplementation( + ({ interval }) => interval.value === '1', + ); mockFetchHistory.mockImplementation( async ({ interval, timeFrom, timeTo }) => { if (interval.value === '1') { return buildResponse(100, currentTimestamp - 60); } - return buildFallbackMultiPointResponse( + return buildMultiPointResponse( [ { close: 110, diff --git a/packages/kit/src/components/TradingView/TradingViewNative/data/useTradingViewNativeKLine.ts b/packages/kit/src/components/TradingView/TradingViewNative/data/useTradingViewNativeKLine.ts index a3479104b236..50b0e24dd868 100644 --- a/packages/kit/src/components/TradingView/TradingViewNative/data/useTradingViewNativeKLine.ts +++ b/packages/kit/src/components/TradingView/TradingViewNative/data/useTradingViewNativeKLine.ts @@ -1612,7 +1612,7 @@ export function useTradingViewNativeKLine({ ? historyPointTypeScopeState.scopes : undefined; const historyProvider = useMemo(() => { - const historyDataSourceScopes = new Map(); + let selectedHistoryDataSource: IHistoryDataSource | undefined; const historyPointTypeScopes = new Map< string, IHistoryPointTypeClassification @@ -1656,28 +1656,37 @@ export function useTradingViewNativeKLine({ return data; } + if ( + data && + selectedHistoryDataSource && + selectedHistoryDataSource !== responseDataSource + ) { + emitTradingViewNativeDebugEvent({ + details: { + ...responseDetails, + reason: 'source-mismatch', + selectedHistorySource: selectedHistoryDataSource, + }, + level: 'warning', + name: 'history.response.dropped', + }); + return { + ...data, + historySource: + selectedHistoryDataSource === 'fallback' + ? 'fallback' + : undefined, + points: [], + total: 0, + }; + } + if (data && data.points.length > 0) { + selectedHistoryDataSource ??= responseDataSource; const scopeKey = getHistoryPointTypeScopeKey( seriesKey, request.interval.value, ); - const selectedDataSource = historyDataSourceScopes.get(scopeKey); - if ( - selectedDataSource && - selectedDataSource !== responseDataSource - ) { - emitTradingViewNativeDebugEvent({ - details: { - ...responseDetails, - reason: 'source-mismatch', - selectedHistorySource: selectedDataSource, - }, - level: 'warning', - name: 'history.response.dropped', - }); - return { ...data, points: [], total: 0 }; - } - historyDataSourceScopes.set(scopeKey, responseDataSource); const currentClassification = historyPointTypeScopes.get(scopeKey); const nextClassification = resolveHistoryPointTypeClassification({ currentClassification, From 909e9aaf4b440ecfe5445f3f0b04ae4a8dc25205 Mon Sep 17 00:00:00 2001 From: limichange Date: Wed, 19 Aug 2026 08:47:28 +0800 Subject: [PATCH 15/15] fix: cap sparse kline recovery requests OK-60116 --- .../data/useTradingViewNativeKLine.test.ts | 72 +++++++++++--- .../data/useTradingViewNativeKLine.ts | 95 ++++++++++++++++--- 2 files changed, 139 insertions(+), 28 deletions(-) diff --git a/packages/kit/src/components/TradingView/TradingViewNative/data/useTradingViewNativeKLine.test.ts b/packages/kit/src/components/TradingView/TradingViewNative/data/useTradingViewNativeKLine.test.ts index fb40636c88ed..5c7100026655 100644 --- a/packages/kit/src/components/TradingView/TradingViewNative/data/useTradingViewNativeKLine.test.ts +++ b/packages/kit/src/components/TradingView/TradingViewNative/data/useTradingViewNativeKLine.test.ts @@ -1024,7 +1024,7 @@ describe('TradingViewNative K-line data state machine', () => { unmount(); }); - it('stops a recovery batch after ten consecutive empty time windows', async () => { + it('stops a recovery batch after twenty-five consecutive empty time windows', async () => { const initialTimestamp = 1_000_000; const boundaryTimestamp = 900_000; mockHistoryBatchSize = 299; @@ -1048,7 +1048,7 @@ describe('TradingViewNative K-line data state machine', () => { { close: 110, timestamp: initialTimestamp + 60 }, ]); } - if (activeIntervalRequestCount === 12) { + if (activeIntervalRequestCount === 27) { return resumedLoadMoreRequest.promise; } return { points: [], total: 0 }; @@ -1057,7 +1057,7 @@ describe('TradingViewNative K-line data state machine', () => { useTradingViewNativeKLine({ source: buildMarketSource() }), ); - await waitFor(() => expect(activeIntervalRequestCount).toBe(11)); + await waitFor(() => expect(activeIntervalRequestCount).toBe(26)); expect(result.current.points).toHaveLength(2); expect(result.current.calendarAvailableTimeRange).toEqual({ from: boundaryTimestamp, @@ -1066,24 +1066,24 @@ describe('TradingViewNative K-line data state machine', () => { .map(([request]) => request) .filter((request) => request.interval.value === '1') .slice(1); - expect(recoveryRequests).toHaveLength(10); + expect(recoveryRequests).toHaveLength(25); recoveryRequests.slice(1).forEach((request, index) => { expect(request.timeTo).toBe(recoveryRequests[index].timeFrom - 1); }); - expect(recoveryRequests.at(-1)?.timeFrom).toBe(998_790); + expect(recoveryRequests.at(-1)?.timeFrom).toBe(996_975); expect(recoveryRequests.at(-1)?.timeFrom).toBeGreaterThan( boundaryTimestamp, ); act(() => result.current.handleVisiblePointRangeChange({ startIndex: 0 })); - await waitFor(() => expect(activeIntervalRequestCount).toBe(12)); + await waitFor(() => expect(activeIntervalRequestCount).toBe(27)); expect( mockFetchHistory.mock.calls.filter( ([request]) => request.interval.value === '1', - )[11]?.[0], + )[26]?.[0], ).toEqual( expect.objectContaining({ - timeTo: 998_789, + timeTo: 996_974, }), ); @@ -1115,7 +1115,7 @@ describe('TradingViewNative K-line data state machine', () => { ); } const recoveryRequestCount = activeIntervalRequestCount - 1; - if (recoveryRequestCount === 10 || recoveryRequestCount === 20) { + if (recoveryRequestCount === 25 || recoveryRequestCount === 50) { return buildResponse(70 + recoveryRequestCount, timeFrom + 1); } return { points: [], total: 0 }; @@ -1125,10 +1125,56 @@ describe('TradingViewNative K-line data state machine', () => { ); await waitFor(() => expect(result.current.points).toHaveLength(198)); - expect(activeIntervalRequestCount).toBe(21); + expect(activeIntervalRequestCount).toBe(51); }); - it('counts an empty load-more window toward the ten-window limit', async () => { + it('resets the one-hundred-request limit for each user load-more interaction', async () => { + const initialTimestamp = 1_000_000; + const boundaryTimestamp = 100_000; + mockHistoryBatchSize = 299; + mockHistoryRequestCandleCount = 2; + mockReadTradingViewNativeActiveInterval.mockReturnValue('1'); + let activeIntervalRequestCount = 0; + let sparseWindowRequestCount = 0; + mockFetchHistory.mockImplementation(async ({ interval, timeFrom }) => { + if (interval.value === '1W') { + return buildResponse(50, boundaryTimestamp - 3600); + } + if (interval.value === '1D') { + return buildResponse(60, boundaryTimestamp); + } + + activeIntervalRequestCount += 1; + if (activeIntervalRequestCount === 1) { + return buildMultiPointResponse([ + { close: 100, timestamp: initialTimestamp }, + { close: 110, timestamp: initialTimestamp + 60 }, + ]); + } + + sparseWindowRequestCount += 1; + return sparseWindowRequestCount % 25 === 0 + ? buildResponse(70 + sparseWindowRequestCount, timeFrom + 1) + : { points: [], total: 0 }; + }); + const { result } = renderHook(() => + useTradingViewNativeKLine({ source: buildMarketSource() }), + ); + + await waitFor(() => expect(mockFetchHistory).toHaveBeenCalledTimes(100), { + timeout: 5000, + }); + expect(activeIntervalRequestCount).toBe(98); + + act(() => result.current.handleVisiblePointRangeChange({ startIndex: 0 })); + + await waitFor(() => expect(mockFetchHistory).toHaveBeenCalledTimes(200), { + timeout: 5000, + }); + expect(activeIntervalRequestCount).toBe(198); + }); + + it('counts an empty load-more window toward the twenty-five-window limit', async () => { const initialTimestamp = 1_000_000; const boundaryTimestamp = 900_000; mockHistoryBatchSize = 299; @@ -1166,13 +1212,13 @@ describe('TradingViewNative K-line data state machine', () => { }), ); - expect(activeIntervalRequestCount).toBe(11); + expect(activeIntervalRequestCount).toBe(26); expect( mockFetchHistory.mock.calls .map(([request]) => request) .filter((request) => request.interval.value === '1') .slice(2), - ).toHaveLength(9); + ).toHaveLength(24); }); it.each([ diff --git a/packages/kit/src/components/TradingView/TradingViewNative/data/useTradingViewNativeKLine.ts b/packages/kit/src/components/TradingView/TradingViewNative/data/useTradingViewNativeKLine.ts index 50b0e24dd868..d92dfcb647ce 100644 --- a/packages/kit/src/components/TradingView/TradingViewNative/data/useTradingViewNativeKLine.ts +++ b/packages/kit/src/components/TradingView/TradingViewNative/data/useTradingViewNativeKLine.ts @@ -63,7 +63,8 @@ const HISTORY_BOUNDARY_PREFETCH_CACHE_TTL = 24 * 60 * 60 * 1000; const HISTORY_BOUNDARY_SEARCH_INTERVAL_VALUE: ITradingViewNativeChartInterval = '1W'; const HISTORY_RETRY_DELAYS = [1000, 3000] as const; -const MAX_SPARSE_HISTORY_CONSECUTIVE_EMPTY_WINDOW_COUNT = 10; +const MAX_SPARSE_HISTORY_CONSECUTIVE_EMPTY_WINDOW_COUNT = 25; +const MAX_SPARSE_HISTORY_REQUEST_ATTEMPT_COUNT = 100; const MAX_VIEWPORT_HISTORY_PAGE_COUNT = 20; const MAX_VIEWPORT_HISTORY_BOUNDARY_SEARCH_COUNT = 32; const MAX_REALTIME_BUFFER_CANDLES = 160; @@ -537,17 +538,46 @@ function getHistoryBoundaryPrefetchPage( return historyBoundaryPrefetchRequests.get(cacheKey)?.promise ?? null; } +interface IHistoryRequestAttemptBudget { + remainingAttemptCount: number; +} + +class HistoryRequestAttemptBudgetExhaustedError extends OneKeyLocalError {} + +function createSparseHistoryRequestAttemptBudget(): IHistoryRequestAttemptBudget { + return { + remainingAttemptCount: MAX_SPARSE_HISTORY_REQUEST_ATTEMPT_COUNT, + }; +} + +function consumeHistoryRequestAttempt( + requestAttemptBudget?: IHistoryRequestAttemptBudget, +) { + if (!requestAttemptBudget) { + return; + } + if (requestAttemptBudget.remainingAttemptCount <= 0) { + throw new HistoryRequestAttemptBudgetExhaustedError( + 'Sparse history request attempt budget exhausted', + ); + } + requestAttemptBudget.remainingAttemptCount -= 1; +} + async function fetchRequiredHistoryPage({ historyProvider, request, + requestAttemptBudget, unavailableMessage, }: { historyProvider: ITradingViewNativeDataProvider; request: ITradingViewNativeHistoryRequest; + requestAttemptBudget?: IHistoryRequestAttemptBudget; unavailableMessage: string; }): Promise { let lastError: unknown; for (let attempt = 0; attempt <= HISTORY_RETRY_DELAYS.length; attempt += 1) { + consumeHistoryRequestAttempt(requestAttemptBudget); try { const data = await historyProvider.fetchHistory(request); if (!data) { @@ -563,6 +593,11 @@ async function fetchRequiredHistoryPage({ if (retryDelay === undefined) { break; } + if (requestAttemptBudget?.remainingAttemptCount === 0) { + throw new HistoryRequestAttemptBudgetExhaustedError( + 'Sparse history request attempt budget exhausted', + ); + } await waitForHistoryRetry(retryDelay, request.signal); if (request.signal.aborted) { throw error; @@ -575,9 +610,11 @@ async function fetchRequiredHistoryPage({ function prefetchHistoryBoundaryPage({ historyProvider, + requestAttemptBudget, seriesKey, }: { historyProvider: ITradingViewNativeDataProvider; + requestAttemptBudget?: IHistoryRequestAttemptBudget; seriesKey: string; }) { const cacheKey = getHistoryBoundaryPrefetchCacheKey(seriesKey); @@ -613,6 +650,7 @@ function prefetchHistoryBoundaryPage({ timeFrom, timeTo: oldestPageTimeTo, }, + requestAttemptBudget, unavailableMessage: 'No weekly candle history response is available for boundary prefetch', }); @@ -683,6 +721,7 @@ function prefetchHistoryBoundaryPage({ timeFrom: dailyTimeFrom, timeTo: dailyTimeTo, }, + requestAttemptBudget, unavailableMessage: 'No daily candle history response is available for boundary refinement', }); @@ -716,7 +755,11 @@ function prefetchHistoryBoundaryPage({ return page; }) .catch((error: unknown) => { - if (!abortController.signal.aborted && !isAbortError(error)) { + if ( + !abortController.signal.aborted && + !isAbortError(error) && + !(error instanceof HistoryRequestAttemptBudgetExhaustedError) + ) { logTradingViewNativeDataError( 'Failed to prefetch native TradingView history boundary', error, @@ -742,6 +785,7 @@ async function recoverOlderHistoryFromBoundary({ initialConsecutiveEmptyWindowCount = 0, interval, onProgress, + requestAttemptBudget, seriesKey, signal, targetPointCount, @@ -751,13 +795,18 @@ async function recoverOlderHistoryFromBoundary({ initialConsecutiveEmptyWindowCount?: number; interval: ITradingViewNativeKLineInterval; onProgress?: (result: IHistoryGapRecoveryResult) => void; + requestAttemptBudget: IHistoryRequestAttemptBudget; seriesKey: string; signal: AbortSignal; targetPointCount: number; timeTo: number; }): Promise { const boundaryPage = await (getHistoryBoundaryPrefetchPage(seriesKey) ?? - prefetchHistoryBoundaryPage({ historyProvider, seriesKey })); + prefetchHistoryBoundaryPage({ + historyProvider, + requestAttemptBudget, + seriesKey, + })); if ( signal.aborted || !boundaryPage || @@ -802,7 +851,8 @@ async function recoverOlderHistoryFromBoundary({ cursorTimeTo >= boundaryTimestamp && points.length < normalizedTargetPointCount && consecutiveEmptyWindowCount < - MAX_SPARSE_HISTORY_CONSECUTIVE_EMPTY_WINDOW_COUNT + MAX_SPARSE_HISTORY_CONSECUTIVE_EMPTY_WINDOW_COUNT && + requestAttemptBudget.remainingAttemptCount > 0 ) { const rangeTimeTo = cursorTimeTo; const rangeTimeFrom = Math.max( @@ -813,17 +863,26 @@ async function recoverOlderHistoryFromBoundary({ }), boundaryTimestamp, ); - const data = await fetchRequiredHistoryPage({ - historyProvider, - request: { - interval, - signal, - timeFrom: rangeTimeFrom, - timeTo: rangeTimeTo, - }, - unavailableMessage: - 'No candle history response is available for sparse history recovery', - }); + let data: ITradingViewNativeHistoryResponse; + try { + data = await fetchRequiredHistoryPage({ + historyProvider, + request: { + interval, + signal, + timeFrom: rangeTimeFrom, + timeTo: rangeTimeTo, + }, + requestAttemptBudget, + unavailableMessage: + 'No candle history response is available for sparse history recovery', + }); + } catch (error) { + if (error instanceof HistoryRequestAttemptBudgetExhaustedError) { + break; + } + throw error; + } if (signal.aborted) { return null; } @@ -3318,6 +3377,7 @@ export function useTradingViewNativeKLine({ pagination.isLoading = true; const loadOlderHistory = async () => { + const requestAttemptBudget = createSparseHistoryRequestAttemptBudget(); try { const data = await fetchRequiredHistoryPage({ historyProvider, @@ -3327,6 +3387,7 @@ export function useTradingViewNativeKLine({ timeFrom, timeTo, }, + requestAttemptBudget, unavailableMessage: 'No older candle history response is available', }); if ( @@ -3415,6 +3476,7 @@ export function useTradingViewNativeKLine({ initialConsecutiveEmptyWindowCount: olderPoints.length ? 0 : 1, interval, onProgress: applyRecoveryProgress, + requestAttemptBudget, seriesKey, signal: abortController.signal, targetPointCount: recoveryTargetPointCount, @@ -3927,6 +3989,7 @@ export function useTradingViewNativeKLine({ }; const fetchHistory = async () => { + const requestAttemptBudget = createSparseHistoryRequestAttemptBudget(); let lastError: unknown; let initialData: ITradingViewNativeHistoryResponse | undefined; let initialPoints: IMarketTokenKLineDataPoint[] | undefined; @@ -3936,6 +3999,7 @@ export function useTradingViewNativeKLine({ attempt += 1 ) { try { + consumeHistoryRequestAttempt(requestAttemptBudget); const data = await historyProvider.fetchHistory({ interval: requestedInterval, signal: abortController.signal, @@ -4103,6 +4167,7 @@ export function useTradingViewNativeKLine({ historyProvider, interval: requestedInterval, onProgress: applyRecoveryProgress, + requestAttemptBudget, seriesKey, signal: abortController.signal, targetPointCount: Math.max(