From 47e287c0f6404ce829bd3dea9a55d4124fb45330 Mon Sep 17 00:00:00 2001 From: Macbook Date: Tue, 9 Jun 2026 22:40:46 +0900 Subject: [PATCH] =?UTF-8?q?=EC=B6=94=EA=B0=80=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../TradeSignalMiniChart.tsx | 35 +++- frontend/src/utils/ChartManager.ts | 168 ++++++++++++------ 2 files changed, 141 insertions(+), 62 deletions(-) diff --git a/frontend/src/components/tradeNotification/TradeSignalMiniChart.tsx b/frontend/src/components/tradeNotification/TradeSignalMiniChart.tsx index c4ad025..c8880dd 100644 --- a/frontend/src/components/tradeNotification/TradeSignalMiniChart.tsx +++ b/frontend/src/components/tradeNotification/TradeSignalMiniChart.tsx @@ -103,6 +103,26 @@ const TradeSignalMiniChart: React.FC = ({ applyNotificationSignalMarkers(mgr, signalMarkerRef.current, false); }, []); + /** OBV plot0(본선) 누락 시 refreshIndicators 로 강제 재생성 */ + const ensureObvDualLine = useCallback(async (attempt = 0) => { + const mgr = managerRef.current; + if (!mgr?.hasMainSeries() || !needsDeepObv) return; + const obvCfg = chartIndicators.find(i => i.type === 'OBV'); + if (!obvCfg) return; + + await mgr.repairDualLineSeries(); + const plotIds = mgr.getIndicatorSeriesPlotIds(obvCfg.id); + const needsBoth = plotIds.length < 2 || !plotIds.includes('plot0'); + if (needsBoth && attempt < 4) { + await mgr.refreshIndicators([obvCfg]); + await mgr.repairDualLineSeries(); + const after = mgr.getIndicatorSeriesPlotIds(obvCfg.id); + if ((after.length < 2 || !after.includes('plot0')) && attempt < 3) { + setTimeout(() => void ensureObvDualLine(attempt + 1), 500 * (attempt + 1)); + } + } + }, [needsDeepObv, chartIndicators]); + const syncChartLayout = useCallback(() => { const wrap = canvasWrapRef.current; const mgr = managerRef.current; @@ -125,21 +145,23 @@ const TradeSignalMiniChart: React.FC = ({ syncChartLayout(); applySignalMarkers(); if (!mgr) return; + void ensureObvDualLine(); [0, 100, 500, 1200].forEach(delay => { setTimeout(() => { void managerRef.current?.repairDualLineSeries(); + void ensureObvDualLine(); }, delay); }); - }, [syncChartLayout, applySignalMarkers]); + }, [syncChartLayout, applySignalMarkers, ensureObvDualLine]); const handleManagerReady = useCallback((mgr: ChartManager) => { managerRef.current = mgr; syncToManager(mgr); applySignalMarkers(); if (needsDeepObv) { - void mgr.repairDualLineSeries(); + void mgr.repairDualLineSeries().then(() => ensureObvDualLine()); } - }, [syncToManager, applySignalMarkers, needsDeepObv]); + }, [syncToManager, applySignalMarkers, needsDeepObv, ensureObvDualLine]); const applyRealtimeBar = useCallback((bar: OHLCVBar, append: boolean) => { if (barsMarketRef.current !== marketRef.current) return; @@ -199,10 +221,13 @@ const TradeSignalMiniChart: React.FC = ({ const mgr = managerRef.current; if (!mgr?.hasMainSeries()) return; const timers = [200, 800, 1600].map(delay => - setTimeout(() => void mgr.repairDualLineSeries(), delay), + setTimeout(() => { + void mgr.repairDualLineSeries(); + void ensureObvDualLine(); + }, delay), ); return () => timers.forEach(clearTimeout); - }, [enabled, chartDataReady, chartIndicators, market, timeframe, settingsRevision]); + }, [enabled, chartDataReady, chartIndicators, market, timeframe, settingsRevision, ensureObvDualLine]); useLayoutEffect(() => { chartLiveReadyRef.current = false; diff --git a/frontend/src/utils/ChartManager.ts b/frontend/src/utils/ChartManager.ts index ee23d46..b79fa99 100644 --- a/frontend/src/utils/ChartManager.ts +++ b/frontend/src/utils/ChartManager.ts @@ -748,18 +748,72 @@ export class ChartManager { return loadGen !== this._dataGeneration; } - /** 이중 plot 지표에서 누락된 plotId 목록 (series 개수만으로는 plot0 누락 감지 불가) */ - private _dualLineMissingPlotIds( - entry: IndicatorEntry, + /** 이중 plot 지표에서 표시해야 할 plotId 목록 */ + private _expectedDualLinePlotIds( config: IndicatorConfig, def: ReturnType | undefined, ): string[] { const plotDefs = config.plots ?? def?.plots ?? []; - const existing = new Set(entry.seriesMeta.map(m => m.plotId)); return plotDefs .filter(p => p.type !== 'histogram' && config.plotVisibility?.[p.id] !== false) - .map(p => p.id) - .filter(id => !existing.has(id)); + .map(p => p.id); + } + + /** seriesMeta·seriesList 불일치 또는 plot0 누락 */ + private _dualLineSeriesOutOfSync( + entry: IndicatorEntry, + config: IndicatorConfig, + def: ReturnType | undefined, + ): boolean { + const expected = this._expectedDualLinePlotIds(config, def); + if (expected.length === 0) return false; + if (entry.seriesList.length !== entry.seriesMeta.length) return true; + if (entry.seriesMeta.length !== expected.length) return true; + const existing = new Set(entry.seriesMeta.map(m => m.plotId)); + return expected.some(id => !existing.has(id)); + } + + /** OBV·RSI 등 — plot0→plot1 순서로 시리즈를 한 번에 생성 (append 누락·순서 어긋남 방지) */ + private _addDualLinePlotSeries( + seriesList: ISeriesApi[], + seriesMeta: IndicatorEntry['seriesMeta'], + config: IndicatorConfig, + result: { plots: Record }, + pane: number, + indicatorHidden: boolean, + def: ReturnType, + ): void { + const plotDefs = sortDualLinePlotDefs(config.plots ?? def?.plots ?? []) + .filter(p => p.type !== 'histogram' && config.plotVisibility?.[p.id] !== false); + const indicatorPriceFormat = priceFormatForIndicatorPane(pane); + + for (const plotDef of plotDefs) { + const plotData = result.plots[plotDef.id] as PlotData | undefined; + if (!plotData?.length) continue; + const filtered = plotData.filter(p => p.value !== null && !isNaN(p.value)); + if (filtered.length === 0) continue; + + const isPlotVisible = !indicatorHidden + && (this._isCustomOverlayApplied() && (config.type === 'SMA' || config.type === 'BollingerBands' || config.type === 'IchimokuCloud') + ? isCustomPlotSelected(this._getAppliedCustomOverlaySelection(), config.type, plotDef.id) + : config.plotVisibility?.[plotDef.id] !== false); + const showPriceLabel = this.shouldShowSeriesPriceLabel(config, true, pane); + const showSeriesTitle = pane < 2 && showPriceLabel; + const regPlot = def?.plots?.find(p => p.id === plotDef.id); + const series = this.chart.addSeries(LineSeries, { + color: resolvePlotLineColor(plotDef.color, regPlot?.color ?? plotDef.color ?? '#2962FF'), + lineWidth: Math.max(1, plotDef.lineWidth ?? regPlot?.lineWidth ?? 1) as 1 | 2 | 3 | 4, + lineStyle: plotSeriesLineStyle(plotDef.lineStyle), + priceLineVisible: false, + lastValueVisible: showPriceLabel, + title: showSeriesTitle ? this.seriesTitleForPlot(config, plotDef) : '', + visible: isPlotVisible, + ...(indicatorPriceFormat ? { priceFormat: indicatorPriceFormat } : {}), + }, pane); + series.setData(filtered.map(p => ({ time: p.time as Time, value: p.value }))); + seriesList.push(series); + seriesMeta.push({ plotId: plotDef.id, isHistogram: false, color: plotDef.color }); + } } private _appendMissingPlotSeriesToLists( @@ -831,7 +885,7 @@ export class ChartManager { try { const result = await calculateIndicator(config.type, this.rawBars.slice(), config.params); - this._appendMissingPlotSeriesToLists( + this._addDualLinePlotSeries( entry.seriesList, entry.seriesMeta, config, @@ -861,8 +915,7 @@ export class ChartManager { if (!def) return; if (this.rawBars.length === 0) return; - const missing = this._dualLineMissingPlotIds(entry, config, def); - if (missing.length > 0) { + if (this._dualLineSeriesOutOfSync(entry, config, def)) { await this._rebuildDualLinePlotSeriesForEntry(entry, config, def); return; } @@ -921,52 +974,9 @@ export class ChartManager { } const plotDefsRaw = config.plots ?? def?.plots ?? []; - const plotDefs = DUAL_LINE_INDICATOR_TYPES.has(config.type) - ? sortPlotDefsForSeriesAdd(config.type, plotDefsRaw) - : plotDefsRaw; - - for (const plotDef of plotDefs) { - const plotData = result.plots[plotDef.id] as PlotData | undefined; - if (!plotData || plotData.length === 0) continue; - const filtered = plotData.filter(p => p.value !== null && !isNaN(p.value)); - if (filtered.length === 0) continue; - - let series: ISeriesApi; - const indicatorPriceFormat = priceFormatForIndicatorPane(pane); - if (plotDef.type === 'histogram') { - series = this.chart.addSeries(HistogramSeries, { - color: plotDef.color, - ...(indicatorPriceFormat ? { priceFormat: indicatorPriceFormat } : {}), - }, pane); - series.setData(mapHistogramSeriesData(filtered, plotDef)); - seriesMeta.push({ plotId: plotDef.id, isHistogram: true, color: plotDef.color }); - } else { - const isPlotVisible = !indicatorHidden - && (this._isCustomOverlayApplied() && (config.type === 'SMA' || config.type === 'BollingerBands' || config.type === 'IchimokuCloud') - ? isCustomPlotSelected(this._getAppliedCustomOverlaySelection(), config.type, plotDef.id) - : config.plotVisibility?.[plotDef.id] !== false); - const showPriceLabel = this.shouldShowSeriesPriceLabel(config, true, pane); - const showSeriesTitle = pane < 2 && showPriceLabel; - const regPlot = def?.plots?.find(p => p.id === plotDef.id); - series = this.chart.addSeries(LineSeries, { - color: resolvePlotLineColor(plotDef.color, regPlot?.color ?? plotDef.color ?? '#2962FF'), - lineWidth: Math.max(1, plotDef.lineWidth ?? regPlot?.lineWidth ?? 1) as 1 | 2 | 3 | 4, - lineStyle: plotSeriesLineStyle(plotDef.lineStyle), - priceLineVisible: false, - lastValueVisible: showPriceLabel, - title: showSeriesTitle ? this.seriesTitleForPlot(config, plotDef) : '', - visible: isPlotVisible, - ...(indicatorPriceFormat ? { priceFormat: indicatorPriceFormat } : {}), - }, pane); - series.setData(filtered.map(p => ({ time: p.time as Time, value: p.value }))); - seriesMeta.push({ plotId: plotDef.id, isHistogram: false, color: plotDef.color }); - } - - seriesList.push(series); - } if (DUAL_LINE_INDICATOR_TYPES.has(config.type)) { - this._appendMissingPlotSeriesToLists( + this._addDualLinePlotSeries( seriesList, seriesMeta, config, @@ -975,6 +985,46 @@ export class ChartManager { indicatorHidden, def, ); + } else { + for (const plotDef of plotDefsRaw) { + const plotData = result.plots[plotDef.id] as PlotData | undefined; + if (!plotData || plotData.length === 0) continue; + const filtered = plotData.filter(p => p.value !== null && !isNaN(p.value)); + if (filtered.length === 0) continue; + + let series: ISeriesApi; + const indicatorPriceFormat = priceFormatForIndicatorPane(pane); + if (plotDef.type === 'histogram') { + series = this.chart.addSeries(HistogramSeries, { + color: plotDef.color, + ...(indicatorPriceFormat ? { priceFormat: indicatorPriceFormat } : {}), + }, pane); + series.setData(mapHistogramSeriesData(filtered, plotDef)); + seriesMeta.push({ plotId: plotDef.id, isHistogram: true, color: plotDef.color }); + } else { + const isPlotVisible = !indicatorHidden + && (this._isCustomOverlayApplied() && (config.type === 'SMA' || config.type === 'BollingerBands' || config.type === 'IchimokuCloud') + ? isCustomPlotSelected(this._getAppliedCustomOverlaySelection(), config.type, plotDef.id) + : config.plotVisibility?.[plotDef.id] !== false); + const showPriceLabel = this.shouldShowSeriesPriceLabel(config, true, pane); + const showSeriesTitle = pane < 2 && showPriceLabel; + const regPlot = def?.plots?.find(p => p.id === plotDef.id); + series = this.chart.addSeries(LineSeries, { + color: resolvePlotLineColor(plotDef.color, regPlot?.color ?? plotDef.color ?? '#2962FF'), + lineWidth: Math.max(1, plotDef.lineWidth ?? regPlot?.lineWidth ?? 1) as 1 | 2 | 3 | 4, + lineStyle: plotSeriesLineStyle(plotDef.lineStyle), + priceLineVisible: false, + lastValueVisible: showPriceLabel, + title: showSeriesTitle ? this.seriesTitleForPlot(config, plotDef) : '', + visible: isPlotVisible, + ...(indicatorPriceFormat ? { priceFormat: indicatorPriceFormat } : {}), + }, pane); + series.setData(filtered.map(p => ({ time: p.time as Time, value: p.value }))); + seriesMeta.push({ plotId: plotDef.id, isHistogram: false, color: plotDef.color }); + } + + seriesList.push(series); + } } if (DUAL_LINE_INDICATOR_TYPES.has(config.type) && !indicatorHidden && seriesList.length > 0) { @@ -1105,8 +1155,7 @@ export class ChartManager { const def = getIndicatorDef(entry.type); if (!def) continue; - const missing = this._dualLineMissingPlotIds(entry, config, def); - if (missing.length > 0) { + if (this._dualLineSeriesOutOfSync(entry, config, def)) { await this._rebuildDualLinePlotSeriesForEntry(entry, config, def); } else if (entry.seriesList.length > 0 && this.rawBars.length > 0) { try { @@ -1923,7 +1972,7 @@ export class ChartManager { if (DUAL_LINE_INDICATOR_TYPES.has(entry.type)) { const config = enrichIndicatorConfig(entry.config); const def = getIndicatorDef(entry.type); - if (this._dualLineMissingPlotIds(entry, config, def).length > 0) { + if (this._dualLineSeriesOutOfSync(entry, config, def)) { await this._ensureDualLinePlotSeriesForEntry(entry); } } @@ -2155,7 +2204,7 @@ export class ChartManager { if (DUAL_LINE_INDICATOR_TYPES.has(entry.type)) { const config = enrichIndicatorConfig(entry.config); const def = getIndicatorDef(entry.type); - if (this._dualLineMissingPlotIds(entry, config, def).length > 0) { + if (this._dualLineSeriesOutOfSync(entry, config, def)) { await this._ensureDualLinePlotSeriesForEntry(entry); } } @@ -3857,6 +3906,11 @@ export class ChartManager { return ids; } + /** 지표에 붙은 plotId 목록 (OBV plot0/plot1 누락 진단용) */ + getIndicatorSeriesPlotIds(id: string): string[] { + return this.indicators.get(id)?.seriesMeta.map(m => m.plotId) ?? []; + } + /** 현재 로드된 rawBars 개수 반환 (데이터 로드 여부 확인용) */ getRawBarsLength(): number { return this.rawBars.length;