diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 8d14d59..87a71d2 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -614,6 +614,8 @@ function App() { const managerRef = useRef(null); /** 차트 매니저 준비 전 도착한 STOMP 틱 (준비 후 1회 적용) */ const pendingRealtimeBarRef = useRef(null); + /** reloadAll setData 완료 전 — 이전 종목 rawBars에 실시간 틱 혼입 방지 */ + const chartLiveReadyRef = useRef(false); const useUpbit = isUpbitMarket(symbol); // ── 백테스팅 ─────────────────────────────────────────────────────────────── @@ -940,6 +942,10 @@ function App() { // 틱 수신: 동일 캔들 업데이트 → React state 변경 없이 차트만 직접 갱신 const handleTickUpdate = useCallback((bar: OHLCVBar) => { if (barsMarketRef.current !== chartContextRef.current.symbol) return; + if (!chartLiveReadyRef.current) { + pendingRealtimeBarRef.current = bar; + return; + } if (!managerRef.current) { pendingRealtimeBarRef.current = bar; flashWsDot(); @@ -952,6 +958,10 @@ function App() { // 새 캔들: bars state를 변경하지 않고 차트에 바만 추가 + 인디케이터 last point 갱신 const handleNewCandle = useCallback((newBar: OHLCVBar) => { if (barsMarketRef.current !== chartContextRef.current.symbol) return; + if (!chartLiveReadyRef.current) { + pendingRealtimeBarRef.current = newBar; + return; + } if (!managerRef.current) { pendingRealtimeBarRef.current = newBar; flashWsDot(); @@ -961,6 +971,15 @@ function App() { flashWsDot(); }, []); + const handleCandlesReady = useCallback(() => { + chartLiveReadyRef.current = true; + const pending = pendingRealtimeBarRef.current; + const mgr = managerRef.current; + if (!pending || !mgr || barsMarketRef.current !== chartContextRef.current.symbol) return; + pendingRealtimeBarRef.current = null; + mgr.updateBar(pending); + }, []); + const chartRealtimeSource = appDefaults.chartRealtimeSource ?? 'BACKEND_STOMP'; const { bars: upbitBars, barsMarket: upbitBarsMarket, latestBar, wsStatus, isLoading, error } = useChartRealtimeData( symbol, @@ -978,8 +997,9 @@ function App() { const barsMarket = useUpbit ? upbitBarsMarket : symbol; barsMarketRef.current = barsMarket; - /** 종목·타임프레임 전환 시 이전 틱 버퍼 폐기 (paint 전에 실행) */ + /** 종목·타임프레임 전환 시 이전 틱 버퍼·실시간 허용 플래그 초기화 (paint 전에 실행) */ useLayoutEffect(() => { + chartLiveReadyRef.current = false; pendingRealtimeBarRef.current = null; }, [symbol, timeframe]); @@ -1970,10 +1990,11 @@ function App() { onCrosshair={setLegend} onTradeOrderRequest={applyTradeFill} onDataLoaded={() => syncBacktestMarkersRef.current()} + onCandlesReady={handleCandlesReady} onManagerReady={mgr => { managerRef.current = mgr; const pending = pendingRealtimeBarRef.current; - if (pending && barsMarketRef.current === symbol && bars.length > 0) { + if (pending && chartLiveReadyRef.current && barsMarketRef.current === symbol && bars.length > 0) { pendingRealtimeBarRef.current = null; const last = bars[bars.length - 1]; if (last && pending.time === last.time) mgr.updateBar(pending); diff --git a/frontend/src/components/ChartSlot.tsx b/frontend/src/components/ChartSlot.tsx index 6417b68..e2260fc 100644 --- a/frontend/src/components/ChartSlot.tsx +++ b/frontend/src/components/ChartSlot.tsx @@ -383,6 +383,7 @@ const ChartSlot = forwardRef(function ChartSlot const reloadTriggeredRef = useRef(false); // 재마운트는 최대 1회 useLayoutEffect(() => { reloadTriggeredRef.current = false; + chartLiveReadyRef.current = false; pendingRealtimeBarRef.current = null; }, [symbol, timeframe]); useEffect(() => { @@ -429,9 +430,23 @@ const ChartSlot = forwardRef(function ChartSlot const pendingRealtimeBarRef = useRef(null); const barsMarketRef = useRef(null); + const chartLiveReadyRef = useRef(false); + + const handleCandlesReady = useCallback(() => { + chartLiveReadyRef.current = true; + const pending = pendingRealtimeBarRef.current; + const mgr = managerRef.current; + if (!pending || !mgr || barsMarketRef.current !== symbolRef.current) return; + pendingRealtimeBarRef.current = null; + mgr.updateBar(pending); + }, []); const handleTickUpdate = useCallback((bar: OHLCVBar) => { if (barsMarketRef.current !== symbolRef.current) return; + if (!chartLiveReadyRef.current) { + pendingRealtimeBarRef.current = bar; + return; + } if (!managerRef.current) { pendingRealtimeBarRef.current = bar; return; @@ -440,6 +455,10 @@ const ChartSlot = forwardRef(function ChartSlot }, []); const handleNewCandle = useCallback((bar: OHLCVBar) => { if (barsMarketRef.current !== symbolRef.current) return; + if (!chartLiveReadyRef.current) { + pendingRealtimeBarRef.current = bar; + return; + } if (!managerRef.current) { pendingRealtimeBarRef.current = bar; return; @@ -653,7 +672,7 @@ const ChartSlot = forwardRef(function ChartSlot onManagerReady={mgr => { managerRef.current = mgr; const pending = pendingRealtimeBarRef.current; - if (pending && barsMarketRef.current === symbolRef.current && bars.length > 0) { + if (pending && chartLiveReadyRef.current && barsMarketRef.current === symbolRef.current && bars.length > 0) { pendingRealtimeBarRef.current = null; const last = bars[bars.length - 1]; if (last && pending.time === last.time) mgr.updateBar(pending); @@ -682,6 +701,7 @@ const ChartSlot = forwardRef(function ChartSlot } }); }} + onCandlesReady={handleCandlesReady} onDataLoaded={() => { // reloadAll(setData + setInitialVisibleRange) 완료 후 // 첫 마운트 시 pending 상태였던 sync range 를 재적용한다 diff --git a/frontend/src/components/TradingChart.tsx b/frontend/src/components/TradingChart.tsx index 2935e0c..44c7fb9 100644 --- a/frontend/src/components/TradingChart.tsx +++ b/frontend/src/components/TradingChart.tsx @@ -5,10 +5,10 @@ import { ChartManager } from '../utils/ChartManager'; import { setIndicatorChartContext } from '../utils/indicatorRegistry'; import { DISPLAY_COUNT } from '../hooks/useUpbitData'; -/** Upbit 실시간 — 초기 히스토리가 이 개수 미만이면 setData 하지 않음 (종목 전환 직후 1~2봉만 로드되는 깨짐 방지) */ +/** Upbit 실시간 — 초기 히스토리가 이 개수 미만이면 setData 하지 않음 (종목 전환 직후 sparse 봉 → 캔들 폭 비정상) */ const MIN_BARS_FOR_FULL_RELOAD = Math.min(50, Math.floor(DISPLAY_COUNT / 4)); -/** 종목 전환 직후 stale/partial 데이터 차단 — fetch 완료(barsMarket 확정) 후 sparse history도 렌더 허용 */ +/** 종목 전환 직후 stale/partial 데이터 차단 — fetch 완료(barsMarket 확정) + 충분한 히스토리만 렌더 */ function canApplyChartBars( barData: OHLCVBar[], barsMarket: string | null | undefined, @@ -17,9 +17,8 @@ function canApplyChartBars( if (barData.length === 0) return false; if (barsMarket === undefined) return true; if (barsMarket == null || barsMarket !== market) return false; - if (!market.startsWith('KRW-')) return true; - if (barData.length >= MIN_BARS_FOR_FULL_RELOAD) return true; - return barData.length >= 2; + if (!market.startsWith('KRW-')) return barData.length >= 2; + return barData.length >= MIN_BARS_FOR_FULL_RELOAD; } import DrawingCanvas, { hitTestDrawing } from './DrawingCanvas'; import PaneLegend, { type PaneLegendProps } from './PaneLegend'; @@ -74,6 +73,8 @@ interface TradingChartProps { * ChartSlot 에서 멀티차트 첫 마운트 시 sync range 를 재적용하는 데 사용. */ onDataLoaded?: () => void; + /** setData 직후 캔들·볼륨 반영 완료 — 실시간 틱 허용 시점 (지표 로드 전) */ + onCandlesReady?: () => void; /** 보조지표 pane 순서 변경 (드래그 핸들): fromId 를 insertBeforeId 앞으로 이동 (null = 맨 뒤) */ onReorderIndicators?: (fromId: string, insertBeforeId: string | null) => void; /** 보조지표 pane 병합 */ @@ -109,6 +110,7 @@ const TradingChart: React.FC = ({ magnifierEnabled = false, onMagnifierClose, onDataLoaded, + onCandlesReady, onReorderIndicators, onMergeIndicators, onSplitIndicatorPane, @@ -385,13 +387,13 @@ const TradingChart: React.FC = ({ do { reloadCoalesceRef.current = false; const gen = reloadGenRef.current; - const barData = barsRef.current.length > 0 ? barsRef.current : newBars; - if (barData.length === 0) break; + if (newBars.length === 0) break; reloadSafetyTimers.current.forEach(clearTimeout); reloadSafetyTimers.current = []; - barsRef.current = barData; + barsRef.current = newBars; + const barData = newBars; mgr.setData(barData, ct, th); if (gen !== reloadGenRef.current) { @@ -402,6 +404,7 @@ const TradingChart: React.FC = ({ mgr.setLogScale(ls); mgr.setDisplayTimezone(displayTimezone, timeframe); commitCandleLayout(mgr); + onCandlesReady?.(); let stale = false; for (const ind of inds) { @@ -489,7 +492,7 @@ const TradingChart: React.FC = ({ queueFullReload(); } } - }, [applyPaneLayout, commitCandleLayout, onDataLoaded, displayTimezone, timeframe, market, queueFullReload, queueIndicatorRecovery]); + }, [applyPaneLayout, commitCandleLayout, onCandlesReady, onDataLoaded, displayTimezone, timeframe, market, queueFullReload, queueIndicatorRecovery]); useEffect(() => { reloadAllRef.current = reloadAll; @@ -822,6 +825,7 @@ const TradingChart: React.FC = ({ reloadCoalesceRef.current = false; reloadSafetyTimers.current.forEach(clearTimeout); reloadSafetyTimers.current = []; + managerRef.current?.clearForSymbolChange(); managerRef.current?.cancelPendingIndicatorUpdates(); }, [market, timeframe]); diff --git a/frontend/src/utils/ChartManager.ts b/frontend/src/utils/ChartManager.ts index efb9517..9d7d0ab 100644 --- a/frontend/src/utils/ChartManager.ts +++ b/frontend/src/utils/ChartManager.ts @@ -271,6 +271,29 @@ export class ChartManager { } // ─── Data ─────────────────────────────────────────────────────────────── + + /** 종목·타임프레임 전환 직후 — 이전 시리즈·rawBars 제거 (실시간 틱 혼입·캔들 폭 깨짐 방지) */ + clearForSymbolChange(): void { + this.cancelPendingIndicatorUpdates(); + this._dataGeneration += 1; + + for (const entry of this.indicators.values()) { + if (entry.cloudPlugin) { + for (const s of entry.seriesList) { try { s.detachPrimitive(entry.cloudPlugin); } catch { /* ok */ } } + } + detachBbBandFill(entry); + for (const s of entry.seriesList) { try { this.chart.removeSeries(s); } catch { /* ok */ } } + } + this.indicators.clear(); + this.patternMarkers = []; + this._removeExtraSubPanes(); + + this._disposeMainMarkersPlugin(); + if (this.mainSeries) { try { this.chart.removeSeries(this.mainSeries); } catch { /* ok */ } this.mainSeries = null; } + if (this.volumeSeries) { try { this.chart.removeSeries(this.volumeSeries); } catch { /* ok */ } this.volumeSeries = null; } + this.rawBars = []; + } + setData(bars: OHLCVBar[], chartType: ChartType, theme: Theme): void { // 전체 재로드 전: 진행 중인 지표 갱신 타이머·플래그를 초기화해 // 이전 rawBars 기반의 stale 계산이 새 지표에 적용되는 것을 방지한다.