diff --git a/frontend/src/components/tradeNotification/TradeSignalMiniChart.tsx b/frontend/src/components/tradeNotification/TradeSignalMiniChart.tsx index 4e91ecb..001932a 100644 --- a/frontend/src/components/tradeNotification/TradeSignalMiniChart.tsx +++ b/frontend/src/components/tradeNotification/TradeSignalMiniChart.tsx @@ -84,8 +84,8 @@ const TradeSignalMiniChart: React.FC = ({ [baseIndicators], ); - /** OBV 누적값 — Upbit REST 직접 (STOMP 백엔드 volume=0 이면 본선 미표시) */ - const chartRealtimeSource: ChartRealtimeSource = needsDeepObv ? 'UPBIT_DIRECT' : appChartSource; + /** 메인 차트와 동일 소스 — STOMP(+ volume 없으면 Upbit 폴백). deepObvHistory 로 장기 봉 확보 */ + const chartRealtimeSource: ChartRealtimeSource = appChartSource; const { indicators: chartIndicators, diff --git a/frontend/src/utils/ChartManager.ts b/frontend/src/utils/ChartManager.ts index cacb52a..ffe7898 100644 --- a/frontend/src/utils/ChartManager.ts +++ b/frontend/src/utils/ChartManager.ts @@ -214,8 +214,8 @@ function detachBbBandFill(entry: IndicatorEntry): void { entry.bbFillPrimitive = undefined; } -/** OBV·CCI·RSI·TRIX — 본선+신호선 이중 plot (누락 시리즈 보정 대상) */ -const DUAL_LINE_INDICATOR_TYPES = new Set(['OBV', 'CCI', 'RSI', 'TRIX']); +/** CCI·RSI·TRIX — 본선+신호선 이중 plot (누락 시리즈 보정 대상). OBV는 별도 경로. */ +const DUAL_LINE_INDICATOR_TYPES = new Set(['CCI', 'RSI', 'TRIX']); const DUAL_LINE_PLOT_ORDER = ['plot0', 'plot1', 'plot2', 'plot3']; @@ -240,11 +240,6 @@ function sortPlotDefsForSeriesAdd(type: string, plotDefs: PlotDef[]): PlotDef[] }); } -/** OBV 신호선 — pane 내 독립 price scale (본선과 값이 가까워도 겹치지 않게) */ -function obvSignalPriceScaleId(indicatorId: string): string { - return `obv-sig-${indicatorId}`; -} - export class ChartManager { private chart: IChartApi; private container: HTMLElement; @@ -779,28 +774,6 @@ export class ChartManager { return expected.some(id => !existing.has(id)); } - /** OBV — 본선(하단)·신호선(상단) 독립 price scale margin */ - private _configureObvDualLinePriceScales( - seriesList: ISeriesApi[], - seriesMeta: IndicatorEntry['seriesMeta'], - ): void { - for (let i = 0; i < seriesList.length; i++) { - const meta = seriesMeta[i]; - if (!meta) continue; - try { - const ps = seriesList[i].priceScale(); - if (meta.plotId === 'plot0') { - ps.applyOptions({ scaleMargins: { top: 0.52, bottom: 0.05 } }); - } else if (meta.plotId === 'plot1') { - ps.applyOptions({ - visible: false, - scaleMargins: { top: 0.05, bottom: 0.52 }, - }); - } - } catch { /* ok */ } - } - } - private _dualLinePlotDefsForRender( config: IndicatorConfig, def: ReturnType, @@ -810,7 +783,59 @@ export class ChartManager { .filter(p => p.type !== 'histogram' && enriched.plotVisibility?.[p.id] !== false); } - /** OBV·RSI 등 — plot0→plot1 순서로 시리즈를 한 번에 생성 (append 누락·순서 어긋남 방지) */ + /** OBV — plot0·plot1 항상 쌍으로 생성 (메인 차트와 동일, DUAL_LINE repair 경로 회피) */ + private _addObvIndicatorSeries( + seriesList: ISeriesApi[], + seriesMeta: IndicatorEntry['seriesMeta'], + config: IndicatorConfig, + result: { plots: Record }, + pane: number, + indicatorHidden: boolean, + def: ReturnType, + ): void { + const enriched = enrichIndicatorConfig(config); + const plotById = Object.fromEntries( + sortPlotDefsForSeriesAdd('OBV', enriched.plots ?? def?.plots ?? []).map(p => [p.id, p]), + ); + const indicatorPriceFormat = priceFormatForIndicatorPane(pane); + + for (const plotId of ['plot0', 'plot1'] as const) { + if (enriched.plotVisibility?.[plotId] === false) continue; + const plotDef = plotById[plotId] ?? def?.plots?.find(p => p.id === plotId); + if (!plotDef || plotDef.type === 'histogram') continue; + + const plotData = result.plots[plotId] 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; + const showPriceLabel = this.shouldShowSeriesPriceLabel(enriched, true, pane); + const showSeriesTitle = pane < 2 && showPriceLabel; + const regPlot = def?.plots?.find(p => p.id === plotId); + const fallbackColor = plotId === 'plot0' + ? (regPlot?.color ?? '#2196F3') + : (regPlot?.color ?? plotDef.color ?? '#2962FF'); + + const series = this.chart.addSeries(LineSeries, { + color: resolvePlotLineColor(plotDef.color, fallbackColor), + lineWidth: (plotId === 'plot0' + ? Math.max(2, plotDef.lineWidth ?? regPlot?.lineWidth ?? 2) + : 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(enriched, 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, isHistogram: false, color: plotDef.color }); + } + } + + /** RSI·CCI·TRIX — plot0→plot1 순서로 시리즈를 한 번에 생성 */ private _addDualLinePlotSeries( seriesList: ISeriesApi[], seriesMeta: IndicatorEntry['seriesMeta'], @@ -836,33 +861,20 @@ export class ChartManager { const showPriceLabel = this.shouldShowSeriesPriceLabel(config, true, pane); const showSeriesTitle = pane < 2 && showPriceLabel; const regPlot = def?.plots?.find(p => p.id === plotDef.id); - const fallbackColor = config.type === 'OBV' && plotDef.id === 'plot0' - ? (regPlot?.color ?? '#2196F3') - : (regPlot?.color ?? plotDef.color ?? '#2962FF'); - const obvSignalScale = config.type === 'OBV' && plotDef.id === 'plot1' - ? obvSignalPriceScaleId(config.id) - : undefined; const series = this.chart.addSeries(LineSeries, { - color: resolvePlotLineColor(plotDef.color, fallbackColor), - lineWidth: (config.type === 'OBV' && plotDef.id === 'plot0' - ? Math.max(2, plotDef.lineWidth ?? regPlot?.lineWidth ?? 2) - : Math.max(1, plotDef.lineWidth ?? regPlot?.lineWidth ?? 1)) as 1 | 2 | 3 | 4, + 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 } : {}), - ...(obvSignalScale ? { priceScaleId: obvSignalScale } : {}), }, 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 }); } - - if (config.type === 'OBV' && seriesList.length > 0) { - this._configureObvDualLinePriceScales(seriesList, seriesMeta); - } } private _appendMissingPlotSeriesToLists( @@ -894,33 +906,20 @@ export class ChartManager { const showPriceLabel = this.shouldShowSeriesPriceLabel(config, true, pane); const showSeriesTitle = pane < 2 && showPriceLabel; const regPlot = def?.plots?.find(p => p.id === plotDef.id); - const fallbackColor = config.type === 'OBV' && plotDef.id === 'plot0' - ? (regPlot?.color ?? '#2196F3') - : (regPlot?.color ?? plotDef.color ?? '#2962FF'); - const obvSignalScale = config.type === 'OBV' && plotDef.id === 'plot1' - ? obvSignalPriceScaleId(config.id) - : undefined; const series = this.chart.addSeries(LineSeries, { - color: resolvePlotLineColor(plotDef.color, fallbackColor), - lineWidth: (config.type === 'OBV' && plotDef.id === 'plot0' - ? Math.max(2, plotDef.lineWidth ?? regPlot?.lineWidth ?? 2) - : Math.max(1, plotDef.lineWidth ?? regPlot?.lineWidth ?? 1)) as 1 | 2 | 3 | 4, + 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 } : {}), - ...(obvSignalScale ? { priceScaleId: obvSignalScale } : {}), }, 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 }); } - - if (config.type === 'OBV' && seriesList.length > 0) { - this._configureObvDualLinePriceScales(seriesList, seriesMeta); - } } /** plot0·plot1 시리즈 전부 제거 후 DB 설정 스타일로 재생성 */ @@ -1036,7 +1035,17 @@ export class ChartManager { const plotDefsRaw = config.plots ?? def?.plots ?? []; - if (DUAL_LINE_INDICATOR_TYPES.has(config.type)) { + if (config.type === 'OBV') { + this._addObvIndicatorSeries( + seriesList, + seriesMeta, + config, + result, + pane, + indicatorHidden, + def, + ); + } else if (DUAL_LINE_INDICATOR_TYPES.has(config.type)) { this._addDualLinePlotSeries( seriesList, seriesMeta, @@ -1147,7 +1156,11 @@ export class ChartManager { return; } this.indicators.set(config.id, entry); - if (DUAL_LINE_INDICATOR_TYPES.has(config.type)) { + if (config.type === 'OBV') { + if (entry.seriesList.length > 0 && !indicatorHidden) { + this._setIndicatorPlotsFullData(entry, result.plots as Record, config); + } + } else if (DUAL_LINE_INDICATOR_TYPES.has(config.type)) { await this._ensureDualLinePlotSeriesForEntry(entry); if (entry.seriesList.length > 0 && !indicatorHidden) { this._setIndicatorPlotsFullData(entry, result.plots as Record, config); @@ -1211,7 +1224,44 @@ export class ChartManager { /** OBV·RSI 등 본선(plot0) 시리즈 누락 시 재생성 + 전체 plot setData + 스타일(DB) 반영 */ async repairDualLineSeries(): Promise { for (const entry of this.indicators.values()) { - if (!DUAL_LINE_INDICATOR_TYPES.has(entry.type) || !entry.config) continue; + if (!entry.config) continue; + + if (entry.type === 'OBV') { + const config = enrichIndicatorConfig(entry.config); + const def = getIndicatorDef('OBV'); + const missingPlot = !entry.seriesMeta.some(m => m.plotId === 'plot0') + || !entry.seriesMeta.some(m => m.plotId === 'plot1'); + if (missingPlot && def && this.rawBars.length > 0) { + const pane = entry.paneIndex ?? this._readSeriesPaneIndex(entry.seriesList[0]) ?? 2; + const hidden = !this._isIndicatorEffectivelyVisible(config); + for (const s of entry.seriesList) { + try { this.chart.removeSeries(s); } catch { /* ok */ } + } + entry.seriesList = []; + entry.seriesMeta = []; + try { + const result = await calculateIndicator('OBV', this.rawBars.slice(), config.params ?? {}); + this._addObvIndicatorSeries( + entry.seriesList, entry.seriesMeta, config, result, pane, hidden, def, + ); + if (entry.seriesList.length > 0) { + this._setIndicatorPlotsFullData(entry, result.plots as Record, config); + } + entry.config = config; + } catch (e) { + console.error('[Indicator] OBV repair error:', e); + } + } else if (entry.seriesList.length > 0 && this.rawBars.length > 0) { + try { + const result = await calculateIndicator('OBV', this.rawBars.slice(), config.params ?? {}); + this._setIndicatorPlotsFullData(entry, result.plots as Record, config); + } catch { /* ok */ } + } + this.applyIndicatorStyle(config, { skipDualLineRefresh: true }); + continue; + } + + if (!DUAL_LINE_INDICATOR_TYPES.has(entry.type)) continue; const config = enrichIndicatorConfig(entry.config); const def = getIndicatorDef(entry.type); if (!def) continue; @@ -3089,9 +3139,7 @@ export class ChartManager { if (!entry.seriesMeta[i]?.isHistogram) { const regPlot = def?.plots?.find(p => p.id === pid); opts['color'] = resolvePlotLineColor(plot.color, regPlot?.color ?? plot.color ?? '#aaa'); - opts['lineWidth'] = config.type === 'OBV' && pid === 'plot0' - ? Math.max(2, plot.lineWidth ?? regPlot?.lineWidth ?? 2) - : Math.max(1, plot.lineWidth ?? regPlot?.lineWidth ?? 1); + opts['lineWidth'] = Math.max(1, plot.lineWidth ?? regPlot?.lineWidth ?? 1); opts['lineStyle'] = plotSeriesLineStyle(plot.lineStyle); let plotAllows = true; if (entry.type === 'IchimokuCloud') { @@ -3109,10 +3157,6 @@ export class ChartManager { series.applyOptions(opts as any); }); - if (config.type === 'OBV' && entry.seriesList.length > 0) { - this._configureObvDualLinePriceScales(entry.seriesList, entry.seriesMeta); - } - // ── hlines(과열선/중앙선/침체선) 동기화 ───────────────────────────────── if (entry.seriesList.length > 0) { const firstSeries = entry.seriesList[0]; diff --git a/frontend/src/utils/indicatorRegistry.ts b/frontend/src/utils/indicatorRegistry.ts index 4be8cb8..abeedde 100644 --- a/frontend/src/utils/indicatorRegistry.ts +++ b/frontend/src/utils/indicatorRegistry.ts @@ -445,7 +445,7 @@ export function mergePlotDefs(saved: PlotDef[] | undefined, defaults: PlotDef[]) /** OBV·CCI·RSI·TRIX — 이중 plot 병합·표시 보장 */ const SIGNAL_LINE_INDICATOR_TYPES = new Set(['OBV', 'CCI', 'RSI', 'TRIX']); -function normalizeDualLinePlotDef(saved: PlotDef, reg: PlotDef): PlotDef { +function normalizeDualLinePlotDef(saved: PlotDef, reg: PlotDef, defaultLineStyle?: HLineStyle): PlotDef { return { ...reg, ...saved, @@ -453,7 +453,7 @@ function normalizeDualLinePlotDef(saved: PlotDef, reg: PlotDef): PlotDef { title: reg.title, type: 'line', lineWidth: Math.max(1, saved.lineWidth ?? reg.lineWidth ?? 1), - lineStyle: saved.lineStyle ?? reg.lineStyle ?? 'solid', + lineStyle: saved.lineStyle ?? defaultLineStyle ?? reg.lineStyle ?? 'solid', color: resolvePlotLineColor(saved.color, reg.color), }; } @@ -474,7 +474,8 @@ function normalizeSignalLineIndicatorPlots( const nextPlots = merged.map(p => { const reg = registryPlots.find(r => r.id === p.id); if (!reg) return p; - return normalizeDualLinePlotDef(p, reg); + const defaultLineStyle = p.id === 'plot1' ? 'solid' as const : undefined; + return normalizeDualLinePlotDef(p, reg, defaultLineStyle); }); nextPlots.sort((a, b) => { const order = ['plot0', 'plot1'];