diff --git a/frontend/src/components/tradeNotification/TradeSignalMiniChart.tsx b/frontend/src/components/tradeNotification/TradeSignalMiniChart.tsx index e34431b..c2a9675 100644 --- a/frontend/src/components/tradeNotification/TradeSignalMiniChart.tsx +++ b/frontend/src/components/tradeNotification/TradeSignalMiniChart.tsx @@ -21,6 +21,7 @@ import { DEFAULT_DISPLAY_TIMEZONE } from '../../utils/timezone'; import { useIndicatorSettings } from '../../hooks/useIndicatorSettings'; import { useSessionCandleOverlayControls } from '../../hooks/useSessionCandleOverlayControls'; import { ensurePaperChartOverlays } from '../../utils/strategyToChartIndicators'; +import { enrichIndicatorConfig } from '../../utils/indicatorRegistry'; import { applyNotificationSignalMarkers, type TradeSignalChartMarker, @@ -68,7 +69,7 @@ const TradeSignalMiniChart: React.FC = ({ signalMarkerRef.current = signalMarker; const baseIndicators = useMemo( - () => ensurePaperChartOverlays(indicators, getParams, getVisualConfig), + () => ensurePaperChartOverlays(indicators, getParams, getVisualConfig).map(enrichIndicatorConfig), [indicators, getParams, getVisualConfig], ); @@ -117,6 +118,7 @@ const TradeSignalMiniChart: React.FC = ({ } syncChartLayout(); applySignalMarkers(); + void mgr?.repairDualLineSeries(); }, [syncChartLayout, applySignalMarkers]); const handleManagerReady = useCallback((mgr: ChartManager) => { @@ -162,7 +164,8 @@ const TradeSignalMiniChart: React.FC = ({ const { isLoadingMore } = useHistoryLoader({ symbol: market, timeframe, - isUpbit: useUpbit && enabled, + /** OBV — 백엔드 history volume=0 봉 prepend 시 본선 왜곡 방지 */ + isUpbit: useUpbit && enabled && !needsDeepObv, managerRef, }); diff --git a/frontend/src/utils/ChartManager.ts b/frontend/src/utils/ChartManager.ts index 472c233..820f570 100644 --- a/frontend/src/utils/ChartManager.ts +++ b/frontend/src/utils/ChartManager.ts @@ -217,6 +217,19 @@ function detachBbBandFill(entry: IndicatorEntry): void { /** OBV·CCI·RSI·TRIX — 본선+신호선 이중 plot (누락 시리즈 보정 대상) */ const DUAL_LINE_INDICATOR_TYPES = new Set(['OBV', 'CCI', 'RSI', 'TRIX']); +const DUAL_LINE_PLOT_ORDER = ['plot0', 'plot1', 'plot2', 'plot3']; + +function sortDualLinePlotDefs(plotDefs: PlotDef[]): PlotDef[] { + return [...plotDefs].sort( + (a, b) => DUAL_LINE_PLOT_ORDER.indexOf(a.id) - DUAL_LINE_PLOT_ORDER.indexOf(b.id), + ); +} + +/** OBV 본선(plot0) — DB plotVisibility·투명색으로 숨김 방지 */ +function isDualLinePlotForcedVisible(type: string, plotId: string): boolean { + return type === 'OBV' && plotId === 'plot0'; +} + export class ChartManager { private chart: IChartApi; private container: HTMLElement; @@ -741,7 +754,7 @@ export class ChartManager { for (const plotDef of plotDefs) { if (existingIds.has(plotDef.id)) continue; - if (config.plotVisibility?.[plotDef.id] === false) continue; + if (config.plotVisibility?.[plotDef.id] === false && !isDualLinePlotForcedVisible(config.type, plotDef.id)) continue; if (plotDef.type === 'histogram') continue; const plotData = result.plots[plotDef.id] as PlotData | undefined; @@ -750,9 +763,10 @@ export class ChartManager { 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); + && (isDualLinePlotForcedVisible(config.type, plotDef.id) + || (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); @@ -851,7 +865,10 @@ export class ChartManager { return; } - const plotDefs = config.plots ?? def?.plots ?? []; + const plotDefsRaw = config.plots ?? def?.plots ?? []; + const plotDefs = DUAL_LINE_INDICATOR_TYPES.has(config.type) + ? sortDualLinePlotDefs(plotDefsRaw) + : plotDefsRaw; for (const plotDef of plotDefs) { const plotData = result.plots[plotDef.id] as PlotData | undefined; @@ -870,15 +887,21 @@ export class ChartManager { 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); + && (isDualLinePlotForcedVisible(config.type, plotDef.id) + || (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 fallbackColor = config.type === 'OBV' && plotDef.id === 'plot0' + ? (regPlot?.color ?? '#2196F3') + : (regPlot?.color ?? plotDef.color ?? '#2962FF'); series = this.chart.addSeries(LineSeries, { - color: resolvePlotLineColor(plotDef.color, regPlot?.color ?? plotDef.color ?? '#2962FF'), - lineWidth: Math.max(1, plotDef.lineWidth ?? 1) as 1 | 2 | 3 | 4, + 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 ?? 1)) as 1 | 2 | 3 | 4, lineStyle: plotSeriesLineStyle(plotDef.lineStyle), priceLineVisible: false, lastValueVisible: showPriceLabel, @@ -956,6 +979,9 @@ export class ChartManager { return; } this.indicators.set(config.id, entry); + if (DUAL_LINE_INDICATOR_TYPES.has(config.type)) { + await this._ensureDualLinePlotSeriesForEntry(entry); + } this.applyIndicatorStyle(config); if (config.type === 'BollingerBands' && !indicatorHidden) { attachBbBandFill(entry, config, this._shouldShowBbBandFillForConfig(config)); @@ -1011,6 +1037,15 @@ export class ChartManager { this._notifyPaneLayout(); } + /** OBV·RSI 등 본선(plot0) 시리즈 누락 시 재생성 (미니 차트·스타일 갱신 후) */ + async repairDualLineSeries(): Promise { + for (const entry of this.indicators.values()) { + if (!DUAL_LINE_INDICATOR_TYPES.has(entry.type)) continue; + await this._ensureDualLinePlotSeriesForEntry(entry); + if (entry.config) this.applyIndicatorStyle(entry.config); + } + } + /** 여러 지표 추가 후 pane 레이아웃을 한 번만 갱신 */ async addIndicatorsBatch(configs: IndicatorConfig[]): Promise { for (const config of configs) { @@ -2617,6 +2652,7 @@ export class ChartManager { /** custom 미적용 시 지표 설정 plotVisibility, 적용 시 custom 선택만 반영 */ private _resolveSeriesVisible(config: IndicatorConfig, plotId: string | undefined): boolean { if (!plotId || !this._isIndicatorEffectivelyVisible(config)) return false; + if (isDualLinePlotForcedVisible(config.type, plotId)) return true; if (!this._isCustomOverlayApplied()) { return config.plotVisibility?.[plotId] !== false; } @@ -2847,8 +2883,13 @@ 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'] = Math.max(1, plot.lineWidth ?? regPlot?.lineWidth ?? 1); + const fallbackColor = entry.type === 'OBV' && pid === 'plot0' + ? (regPlot?.color ?? '#2196F3') + : (regPlot?.color ?? plot.color ?? '#aaa'); + opts['color'] = resolvePlotLineColor(plot.color, fallbackColor); + opts['lineWidth'] = entry.type === 'OBV' && pid === 'plot0' + ? Math.max(2, plot.lineWidth ?? regPlot?.lineWidth ?? 2) + : Math.max(1, plot.lineWidth ?? regPlot?.lineWidth ?? 1); opts['lineStyle'] = plotSeriesLineStyle(plot.lineStyle); let plotAllows = true; if (entry.type === 'IchimokuCloud') { diff --git a/frontend/src/utils/indicatorRegistry.ts b/frontend/src/utils/indicatorRegistry.ts index e80bb56..01db767 100644 --- a/frontend/src/utils/indicatorRegistry.ts +++ b/frontend/src/utils/indicatorRegistry.ts @@ -474,12 +474,22 @@ function normalizeSignalLineIndicatorPlots( const reg = registryPlots.find(r => r.id === p.id); if (!reg) return p; const normalized = normalizeDualLinePlotDef(p, reg); - // OBV 본선 — DB 저장색(투명·신호선과 동일) 무시, registry 기본색 유지 + // OBV 본선 — DB 저장색(투명·신호선과 동일) 무시, registry 기본색·굵기 유지 if (p.id === 'plot0') { - return { ...normalized, color: reg.color }; + return { + ...normalized, + type: 'line' as const, + color: reg.color, + lineWidth: Math.max(2, normalized.lineWidth ?? reg.lineWidth ?? 2), + }; } return normalized; }); + // plot0(본선) → plot1(신호선) 순서 보장 + nextPlots.sort((a, b) => { + const order = ['plot0', 'plot1']; + return order.indexOf(a.id) - order.indexOf(b.id); + }); return { plots: nextPlots, plotVisibility: { ...plotVisibility, plot0: true, plot1: true },