From 0cfe7fc84c56ae91d3dcd6d44d85c809a7a4b278 Mon Sep 17 00:00:00 2001 From: Macbook Date: Mon, 25 May 2026 04:18:30 +0900 Subject: [PATCH] =?UTF-8?q?=EC=8B=A4=EC=8B=9C=EA=B0=84=20=EC=B0=A8?= =?UTF-8?q?=ED=8A=B8=20=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/components/TradingChart.tsx | 31 ++++++----- frontend/src/hooks/useStompChartData.ts | 69 +++++++++++++++++++++--- frontend/src/utils/ChartManager.ts | 23 +++++--- 3 files changed, 96 insertions(+), 27 deletions(-) diff --git a/frontend/src/components/TradingChart.tsx b/frontend/src/components/TradingChart.tsx index 44c7fb9..43d74df 100644 --- a/frontend/src/components/TradingChart.tsx +++ b/frontend/src/components/TradingChart.tsx @@ -8,17 +8,18 @@ import { DISPLAY_COUNT } from '../hooks/useUpbitData'; /** Upbit 실시간 — 초기 히스토리가 이 개수 미만이면 setData 하지 않음 (종목 전환 직후 sparse 봉 → 캔들 폭 비정상) */ const MIN_BARS_FOR_FULL_RELOAD = Math.min(50, Math.floor(DISPLAY_COUNT / 4)); -/** 종목 전환 직후 stale/partial 데이터 차단 — fetch 완료(barsMarket 확정) + 충분한 히스토리만 렌더 */ +/** 종목 전환 직후 stale/partial 데이터 차단 — barsMarket 확정 후 렌더 허용 */ function canApplyChartBars( barData: OHLCVBar[], barsMarket: string | null | undefined, market: string, ): boolean { - if (barData.length === 0) return false; + if (barData.length < 2) return false; if (barsMarket === undefined) return true; if (barsMarket == null || barsMarket !== market) return false; - if (!market.startsWith('KRW-')) return barData.length >= 2; - return barData.length >= MIN_BARS_FOR_FULL_RELOAD; + if (!market.startsWith('KRW-')) return true; + if (barData.length >= MIN_BARS_FOR_FULL_RELOAD) return true; + return true; } import DrawingCanvas, { hitTestDrawing } from './DrawingCanvas'; import PaneLegend, { type PaneLegendProps } from './PaneLegend'; @@ -172,6 +173,7 @@ const TradingChart: React.FC = ({ const prevSortedPKRef = useRef(''); // 순서 무관 paramKey (reorder 감지용) const prevMarket = useRef(market); const prevMarketTf = useRef(timeframe); + const lastAppliedMarketRef = useRef(''); const prevChartType = useRef(chartType); const prevTheme = useRef(theme); const prevLogScale = useRef(logScale); @@ -381,19 +383,19 @@ const TradingChart: React.FC = ({ return; } + barsRef.current = newBars; reloadInFlightRef.current = true; try { do { reloadCoalesceRef.current = false; const gen = reloadGenRef.current; - if (newBars.length === 0) break; + const barData = barsRef.current.length >= 2 ? barsRef.current : newBars; + if (barData.length < 2) break; reloadSafetyTimers.current.forEach(clearTimeout); reloadSafetyTimers.current = []; - - barsRef.current = newBars; - const barData = newBars; + barsRef.current = barData; mgr.setData(barData, ct, th); if (gen !== reloadGenRef.current) { @@ -405,6 +407,7 @@ const TradingChart: React.FC = ({ mgr.setDisplayTimezone(displayTimezone, timeframe); commitCandleLayout(mgr); onCandlesReady?.(); + lastAppliedMarketRef.current = market; let stale = false; for (const ind of inds) { @@ -449,6 +452,7 @@ const TradingChart: React.FC = ({ prevBarsKey.current = barsKey(barData, market); prevIndKey.current = indKey(inds); prevSortedPKRef.current = sortedParamKey(inds); + lastAppliedMarketRef.current = market; await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(() => { applyPaneLayout(mgr); @@ -818,6 +822,7 @@ const TradingChart: React.FC = ({ prevMarketTf.current = timeframe; prevBarsKey.current = ''; prevIndKey.current = ''; + lastAppliedMarketRef.current = ''; barsRef.current = []; reloadRetryRef.current = 0; reloadGenRef.current += 1; @@ -832,11 +837,11 @@ const TradingChart: React.FC = ({ // ── 데이터/인디케이터 동기화 ───────────────────────────────────────────── useEffect(() => { const mgr = managerRef.current; - if (!mgr || !chartMgr || bars.length === 0) return; + if (!mgr || !chartMgr || bars.length < 2) return; if (!canApplyChartBars(bars, barsMarket, market)) return; - // manager 준비 전 bars 가 먼저 도착한 경우(로그인 직후 등) 메인 시리즈 누락 방지 - if (!mgr.hasMainSeries()) { + // manager 준비 전·종목 미적용 시 reloadAll 강제 + if (!mgr.hasMainSeries() || lastAppliedMarketRef.current !== market) { void reloadAll(mgr, bars, chartType, theme, logScale, indicators); return; } @@ -942,7 +947,7 @@ const TradingChart: React.FC = ({ // 데이터는 준비됐으나 메인 시리즈가 없는 빈 차트 자동 복구 (종목 전환·레이아웃 전환 직후) useEffect(() => { const mgr = managerRef.current; - if (!mgr || !chartMgr || bars.length === 0) return; + if (!mgr || !chartMgr || bars.length < 2) return; if (!canApplyChartBars(bars, barsMarket, market)) return; if (mgr.hasMainSeries()) return; @@ -969,7 +974,7 @@ const TradingChart: React.FC = ({ // 종목 전환 직후 reloadAll 이 중단되어 캔들만 남은 경우 자동 복구 useEffect(() => { const mgr = managerRef.current; - if (!mgr || !chartMgr || bars.length === 0) return; + if (!mgr || !chartMgr || bars.length < 2) return; if (!canApplyChartBars(bars, barsMarket, market)) return; const expected = countExpectedVisibleIndicators(indicators); diff --git a/frontend/src/hooks/useStompChartData.ts b/frontend/src/hooks/useStompChartData.ts index a2596be..f739bbd 100644 --- a/frontend/src/hooks/useStompChartData.ts +++ b/frontend/src/hooks/useStompChartData.ts @@ -10,6 +10,10 @@ import { getCandleStart, TF_SECONDS } from '../utils/upbitApi'; import { DISPLAY_COUNT, WARMUP_COUNT } from './useUpbitData'; import type { WsStatus } from './useUpbitData'; import { API_BASE } from '../utils/backendApi'; +import { fetchUpbitCandlesCached } from '../utils/requestCache'; + +/** 초기 렌더 최소 봉 수 — 미만이면 업비트 REST 폴백 */ +const MIN_INITIAL_BARS = Math.min(50, Math.floor(DISPLAY_COUNT / 4)); /** 백엔드 BarBuilder 는 틱마다 1m 만 STOMP 발행 — 차트 분봉은 클라이언트에서 집계 */ const STOMP_TICK_CANDLE_TYPE = '1m'; @@ -100,6 +104,36 @@ async function fetchInitialBarsWithRetry( throw lastErr ?? new Error('history fetch failed'); } +/** 백엔드 history → 부족·실패 시 업비트 REST 폴백 (종목별 Ta4j 미적재 대응) */ +async function loadInitialBars( + market: string, + timeframe: Timeframe, + count: number, + marketChanged: boolean, +): Promise { + const candleType = timeframeToCandleType(timeframe); + let bars: OHLCVBar[] = []; + try { + bars = await fetchInitialBarsWithRetry(market, candleType, count); + } catch { + bars = []; + } + if (bars.length < MIN_INITIAL_BARS) { + try { + const fallback = await fetchUpbitCandlesCached( + market, + timeframe, + Math.min(count, 200), + marketChanged, + ); + if (fallback.length > bars.length) bars = fallback; + } catch { + /* 폴백 실패 시 backend 결과 유지 */ + } + } + return bars; +} + export function useStompChartData( market: string, timeframe: Timeframe, @@ -122,11 +156,15 @@ export function useStompChartData( const last1mTimeRef = useRef(0); /** 초기 히스토리 로드 완료 전에는 차트 직접 갱신 콜백을 호출하지 않음 */ const historyReadyRef = useRef(false); + /** 백엔드 history 비어 있을 때 STOMP 누적 → React bars 동기화 */ + const bootstrapFromStompRef = useRef(false); + const prevMarketRef = useRef(''); useEffect(() => { if (opts.enabled === false) return; let cancelled = false; historyReadyRef.current = false; + bootstrapFromStompRef.current = false; setIsLoading(true); setError(null); setBars([]); @@ -136,16 +174,26 @@ export function useStompChartData( last1mTimeRef.current = 0; lastTimeRef.current = 0; + const marketChanged = prevMarketRef.current !== '' && prevMarketRef.current !== market; + prevMarketRef.current = market; + const count = DISPLAY_COUNT + WARMUP_COUNT; - fetchInitialBarsWithRetry(market, candleType, count) + loadInitialBars(market, timeframe, count, marketChanged) .then(newBars => { if (cancelled) return; barsRef.current = newBars; historyReadyRef.current = true; setBarsMarket(market); - setBars(newBars); - setLatestBar(newBars[newBars.length - 1] ?? null); - lastTimeRef.current = newBars[newBars.length - 1]?.time ?? 0; + if (newBars.length >= 2) { + bootstrapFromStompRef.current = false; + setBars(newBars); + setLatestBar(newBars[newBars.length - 1] ?? null); + lastTimeRef.current = newBars[newBars.length - 1]?.time ?? 0; + } else { + bootstrapFromStompRef.current = true; + setBars([]); + setLatestBar(null); + } setIsLoading(false); }) .catch(err => { @@ -154,7 +202,15 @@ export function useStompChartData( setIsLoading(false); }); return () => { cancelled = true; }; - }, [market, candleType, opts.enabled]); + }, [market, candleType, timeframe, opts.enabled]); + + const syncBarsToReactIfNeeded = useCallback((marketId: string) => { + if (!bootstrapFromStompRef.current) return; + if (barsRef.current.length < 2) return; + bootstrapFromStompRef.current = false; + setBarsMarket(marketId); + setBars([...barsRef.current]); + }, []); const applyCandle = useCallback((data: StompCandlePayload, displayTf: Timeframe) => { if (!historyReadyRef.current) return; @@ -193,6 +249,7 @@ export function useStompChartData( if (notifyChart) optsRef.current.onTickUpdate(patched); } lastTimeRef.current = bar.time; + syncBarsToReactIfNeeded(market); }; const lastChart = barsRef.current[barsRef.current.length - 1]; @@ -256,7 +313,7 @@ export function useStompChartData( last1mTimeRef.current = oneMin.time; last1mVolRef.current = oneMin.volume; } - }, []); + }, [market, syncBarsToReactIfNeeded]); useEffect(() => { if (opts.enabled === false) { diff --git a/frontend/src/utils/ChartManager.ts b/frontend/src/utils/ChartManager.ts index 9d7d0ab..139e3f9 100644 --- a/frontend/src/utils/ChartManager.ts +++ b/frontend/src/utils/ChartManager.ts @@ -356,16 +356,19 @@ export class ChartManager { this._reapplyAllPatternMarkers(); - // 이전 timeScale visible range를 완전히 리셋 후 fitContent - // setVisibleRange → 이전 zoom 상태를 무시하고 새 데이터 범위로 명시 초기화 + // 이전 timeScale visible range를 완전히 리셋 const ts = this.chart.timeScale(); if (bars.length >= 2) { - ts.setVisibleRange({ - from: bars[0].time as Time, - to: bars[bars.length - 1].time as Time, - }); + if (bars.length < 20) { + ts.setVisibleLogicalRange({ from: bars.length - 200, to: bars.length + 5 }); + } else { + ts.setVisibleRange({ + from: bars[0].time as Time, + to: bars[bars.length - 1].time as Time, + }); + ts.fitContent(); + } } - ts.fitContent(); } private _createMainSeries(chartType: ChartType, t: ThemeTokens): ISeriesApi { @@ -2389,7 +2392,11 @@ export class ChartManager { const ts = this.chart.timeScale(); if (bars.length <= displayCount && extendFutureBars === 0) { - ts.fitContent(); + if (bars.length < 20) { + ts.setVisibleLogicalRange({ from: bars.length - displayCount, to: bars.length + 5 }); + } else { + ts.fitContent(); + } return; }