diff --git a/frontend/src/components/TradingChart.tsx b/frontend/src/components/TradingChart.tsx index cc3ccf8..92fa550 100644 --- a/frontend/src/components/TradingChart.tsx +++ b/frontend/src/components/TradingChart.tsx @@ -1989,7 +1989,10 @@ function barsKey(bars: OHLCVBar[], market = ''): string { if (bars.length === 0) return ''; const last = bars[bars.length - 1]; // last.close 제외 — 틱마다 full reload 방지 (실시간은 updateBar/appendBar 경로) - return `${market}:${bars.length}:${bars[0].time}:${last.time}`; + // volume 합계 — STOMP volume=0 → Upbit 폴백 후 OBV 등 지표 재계산 유도 + const volSum = bars.reduce((s, b) => s + (Number(b.volume) || 0), 0); + const volFlag = volSum > 0 ? 'v' : '0'; + return `${market}:${bars.length}:${bars[0].time}:${last.time}:${volFlag}`; } /** diff --git a/frontend/src/components/tradeNotification/TradeSignalMiniChart.tsx b/frontend/src/components/tradeNotification/TradeSignalMiniChart.tsx index c3dd670..e34431b 100644 --- a/frontend/src/components/tradeNotification/TradeSignalMiniChart.tsx +++ b/frontend/src/components/tradeNotification/TradeSignalMiniChart.tsx @@ -54,7 +54,7 @@ const TradeSignalMiniChart: React.FC = ({ }) => { const { getParams, getVisualConfig } = useIndicatorSettings(); const { defaults: appDefaults } = useAppSettings(); - const chartRealtimeSource = (appDefaults.chartRealtimeSource ?? 'BACKEND_STOMP') as ChartRealtimeSource; + const appChartSource = (appDefaults.chartRealtimeSource ?? 'BACKEND_STOMP') as ChartRealtimeSource; const managerRef = useRef(null); const canvasWrapRef = useRef(null); const [chartReloadTick, setChartReloadTick] = useState(0); @@ -77,6 +77,9 @@ const TradeSignalMiniChart: React.FC = ({ [baseIndicators], ); + /** OBV 누적값 — Upbit REST 직접 (STOMP 백엔드 volume=0 이면 본선 미표시) */ + const chartRealtimeSource: ChartRealtimeSource = needsDeepObv ? 'UPBIT_DIRECT' : appChartSource; + const { indicators: chartIndicators, candleOverlayToggles, @@ -166,6 +169,14 @@ const TradeSignalMiniChart: React.FC = ({ barsMarketRef.current = barsMarket; const barsReady = bars.length >= 2 && barsMarket === market; + const obvBarsReady = useMemo(() => { + if (!needsDeepObv) return true; + if (bars.length < 2) return false; + return bars.some(b => (Number(b.volume) || 0) > 0); + }, [needsDeepObv, bars]); + + const chartDataReady = barsReady && obvBarsReady; + useLayoutEffect(() => { chartLiveReadyRef.current = false; pendingBarRef.current = null; @@ -191,7 +202,7 @@ const TradeSignalMiniChart: React.FC = ({ if (!canvasWrapRef.current) return; if (managerRef.current?.hasMainSeries()) { syncChartLayout(); - } else if (!chartReloadTriggeredRef.current && delay >= 600 && barsReady) { + } else if (!chartReloadTriggeredRef.current && delay >= 600 && chartDataReady) { chartReloadTriggeredRef.current = true; setChartReloadTick(t => t + 1); } @@ -204,15 +215,15 @@ const TradeSignalMiniChart: React.FC = ({ timers.forEach(clearTimeout); ro.disconnect(); }; - }, [market, timeframe, enabled, barsReady, syncChartLayout, fillHeight]); + }, [market, timeframe, enabled, chartDataReady, syncChartLayout, fillHeight]); useEffect(() => { - if (!enabled || !barsReady) return; + if (!enabled || !chartDataReady) return; if (!managerRef.current?.hasMainSeries() && !chartReloadTriggeredRef.current) { chartReloadTriggeredRef.current = true; setChartReloadTick(t => t + 1); } - }, [enabled, barsReady]); + }, [enabled, chartDataReady]); useEffect(() => { if (!enabled) return; @@ -246,13 +257,17 @@ const TradeSignalMiniChart: React.FC = ({ ].filter(Boolean).join(' ')} > {isLoading &&
로딩…
} + {needsDeepObv && !isLoading && !obvBarsReady && ( +
OBV 거래량 데이터 로딩…
+ )} {isLoadingMore && (
)} + {chartDataReady && ( i.id).join(',')}`} + key={`${market}-${timeframe}-${chartReloadTick}-${chartRealtimeSource}-${needsDeepObv ? 'obv' : 'std'}-${chartIndicators.map(i => i.id).join(',')}`} chartVisible bars={bars} barsMarket={barsMarket} @@ -283,6 +298,7 @@ const TradeSignalMiniChart: React.FC = ({ onToggleCandleOverlay={handleToggleCandleOverlay} crosshairInfoVisible={false} /> + )}
); }; diff --git a/frontend/src/hooks/useStompChartData.ts b/frontend/src/hooks/useStompChartData.ts index 2cf73e1..245b86a 100644 --- a/frontend/src/hooks/useStompChartData.ts +++ b/frontend/src/hooks/useStompChartData.ts @@ -15,6 +15,10 @@ import { fetchUpbitCandlesCached } from '../utils/requestCache'; /** 초기 렌더 최소 봉 수 — 미만이면 업비트 REST 폴백 */ const MIN_INITIAL_BARS = Math.min(50, Math.floor(DISPLAY_COUNT / 4)); +function barsHaveVolume(bars: OHLCVBar[]): boolean { + return bars.some(b => (Number(b.volume) || 0) > 0); +} + /** 백엔드 BarBuilder 는 틱마다 1m 만 STOMP 발행 — 차트 분봉은 클라이언트에서 집계 */ const STOMP_TICK_CANDLE_TYPE = '1m'; @@ -106,29 +110,45 @@ async function fetchInitialBarsWithRetry( throw lastErr ?? new Error('history fetch failed'); } -/** 백엔드 history → 부족·실패 시 업비트 REST 폴백 (종목별 Ta4j 미적재 대응) */ +/** 백엔드 history → 부족·거래량 0·실패 시 업비트 REST 폴백 */ async function loadInitialBars( market: string, timeframe: Timeframe, count: number, marketChanged: boolean, + preferUpbit = false, ): Promise { const candleType = timeframeToCandleType(timeframe); + const upbitCount = Math.min(count, OBV_DEEP_HISTORY); + + if (preferUpbit) { + try { + const upbit = await fetchUpbitCandlesCached(market, timeframe, upbitCount, marketChanged); + if (upbit.length >= MIN_INITIAL_BARS && barsHaveVolume(upbit)) return upbit; + } catch { + /* Upbit 실패 시 backend 시도 */ + } + } + let bars: OHLCVBar[] = []; try { bars = await fetchInitialBarsWithRetry(market, candleType, count); } catch { bars = []; } - if (bars.length < MIN_INITIAL_BARS) { + + const needsUpbitFallback = bars.length < MIN_INITIAL_BARS || !barsHaveVolume(bars); + if (needsUpbitFallback) { try { const fallback = await fetchUpbitCandlesCached( market, timeframe, - Math.min(count, 200), + upbitCount, marketChanged, ); - if (fallback.length > bars.length) bars = fallback; + if (fallback.length > bars.length || (barsHaveVolume(fallback) && !barsHaveVolume(bars))) { + bars = fallback; + } } catch { /* 폴백 실패 시 backend 결과 유지 */ } @@ -182,7 +202,7 @@ export function useStompChartData( const count = opts.deepObvHistory ? OBV_DEEP_HISTORY : DISPLAY_COUNT + WARMUP_COUNT; - loadInitialBars(market, timeframe, count, marketChanged) + loadInitialBars(market, timeframe, count, marketChanged, opts.deepObvHistory === true) .then(newBars => { if (cancelled) return; barsRef.current = newBars; diff --git a/frontend/src/utils/indicatorRegistry.ts b/frontend/src/utils/indicatorRegistry.ts index 89f3f9b..e80bb56 100644 --- a/frontend/src/utils/indicatorRegistry.ts +++ b/frontend/src/utils/indicatorRegistry.ts @@ -472,7 +472,13 @@ function normalizeSignalLineIndicatorPlots( if (type === 'OBV') { const nextPlots = merged.map(p => { const reg = registryPlots.find(r => r.id === p.id); - return reg ? normalizeDualLinePlotDef(p, reg) : p; + if (!reg) return p; + const normalized = normalizeDualLinePlotDef(p, reg); + // OBV 본선 — DB 저장색(투명·신호선과 동일) 무시, registry 기본색 유지 + if (p.id === 'plot0') { + return { ...normalized, color: reg.color }; + } + return normalized; }); return { plots: nextPlots,