diff --git a/frontend/src/components/tradeNotification/TradeSignalMiniChart.tsx b/frontend/src/components/tradeNotification/TradeSignalMiniChart.tsx index 6721c4a..c3dd670 100644 --- a/frontend/src/components/tradeNotification/TradeSignalMiniChart.tsx +++ b/frontend/src/components/tradeNotification/TradeSignalMiniChart.tsx @@ -11,7 +11,9 @@ import type { } from '../../types'; import { isUpbitMarket } from '../../utils/upbitApi'; import { useChartRealtimeData } from '../../hooks/useChartRealtimeData'; +import type { ChartRealtimeSource } from '../../hooks/useChartRealtimeData'; import { useHistoryLoader } from '../../hooks/useHistoryLoader'; +import { useAppSettings } from '../../hooks/useAppSettings'; import type { ChartManager } from '../../utils/ChartManager'; import { pinCandleWatch } from '../../utils/backendApi'; import { timeframeToCandleType } from '../../utils/chartCandleType'; @@ -51,6 +53,8 @@ const TradeSignalMiniChart: React.FC = ({ signalMarker, }) => { const { getParams, getVisualConfig } = useIndicatorSettings(); + const { defaults: appDefaults } = useAppSettings(); + const chartRealtimeSource = (appDefaults.chartRealtimeSource ?? 'BACKEND_STOMP') as ChartRealtimeSource; const managerRef = useRef(null); const canvasWrapRef = useRef(null); const [chartReloadTick, setChartReloadTick] = useState(0); @@ -147,9 +151,9 @@ const TradeSignalMiniChart: React.FC = ({ onTickUpdate: handleTickUpdate, onNewCandle: handleNewCandle, enabled: enabled && useUpbit, - source: 'BACKEND_STOMP' as const, + source: chartRealtimeSource, deepObvHistory: needsDeepObv, - }), [handleTickUpdate, handleNewCandle, enabled, useUpbit, needsDeepObv]), + }), [handleTickUpdate, handleNewCandle, enabled, useUpbit, needsDeepObv, chartRealtimeSource]), ); const { isLoadingMore } = useHistoryLoader({ diff --git a/frontend/src/utils/ChartManager.ts b/frontend/src/utils/ChartManager.ts index 3582931..472c233 100644 --- a/frontend/src/utils/ChartManager.ts +++ b/frontend/src/utils/ChartManager.ts @@ -214,6 +214,9 @@ function detachBbBandFill(entry: IndicatorEntry): void { entry.bbFillPrimitive = undefined; } +/** OBV·CCI·RSI·TRIX — 본선+신호선 이중 plot (누락 시리즈 보정 대상) */ +const DUAL_LINE_INDICATOR_TYPES = new Set(['OBV', 'CCI', 'RSI', 'TRIX']); + export class ChartManager { private chart: IChartApi; private container: HTMLElement; @@ -722,6 +725,89 @@ export class ChartManager { return loadGen !== this._dataGeneration; } + /** 이중 plot 지표 — addIndicator 루프에서 누락된 plot 시리즈 추가 */ + private _appendMissingPlotSeriesToLists( + seriesList: ISeriesApi[], + seriesMeta: IndicatorEntry['seriesMeta'], + config: IndicatorConfig, + result: { plots: Record }, + pane: number, + indicatorHidden: boolean, + def: ReturnType, + ): void { + const plotDefs = config.plots ?? def?.plots ?? []; + const existingIds = new Set(seriesMeta.map(m => m.plotId)); + const indicatorPriceFormat = priceFormatForIndicatorPane(pane); + + for (const plotDef of plotDefs) { + if (existingIds.has(plotDef.id)) continue; + if (config.plotVisibility?.[plotDef.id] === false) continue; + if (plotDef.type === 'histogram') continue; + + 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 ?? 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 }); + } + } + + /** 스타일 갱신·초기 추가 후 OBV 등 본선(plot0) 시리즈 누락 보정 */ + private async _ensureDualLinePlotSeriesForEntry(entry: IndicatorEntry): Promise { + if (!DUAL_LINE_INDICATOR_TYPES.has(entry.type)) return; + const config = enrichIndicatorConfig(entry.config!); + const def = getIndicatorDef(config.type); + if (!def) return; + const plotDefs = config.plots ?? def.plots ?? []; + const expectedCount = plotDefs.filter( + p => p.type !== 'histogram' && config.plotVisibility?.[p.id] !== false, + ).length; + if (entry.seriesList.length >= expectedCount) return; + if (this.rawBars.length === 0) return; + + const pane = entry.paneIndex ?? this._readSeriesPaneIndex(entry.seriesList[0]) ?? 2; + const indicatorHidden = !this._isIndicatorEffectivelyVisible(config); + try { + const result = await calculateIndicator(config.type, this.rawBars.slice(), config.params); + this._appendMissingPlotSeriesToLists( + entry.seriesList, + entry.seriesMeta, + config, + result, + pane, + indicatorHidden, + def, + ); + entry.config = config; + if (entry.seriesList.length > 0) { + const actualPane = this._readSeriesPaneIndex(entry.seriesList[0]); + if (actualPane >= 2) entry.paneIndex = actualPane; + } + } catch (e) { + console.error(`[Indicator] ${config.type} dual-line repair error:`, e); + } + } + // ─── Indicators ───────────────────────────────────────────────────────── async addIndicator( config: IndicatorConfig, @@ -807,6 +893,18 @@ export class ChartManager { seriesList.push(series); } + if (DUAL_LINE_INDICATOR_TYPES.has(config.type)) { + this._appendMissingPlotSeriesToLists( + seriesList, + seriesMeta, + config, + result, + pane, + indicatorHidden, + def, + ); + } + // hlines(과열선/중앙선/침체선)는 첫 번째 시리즈에만 한 번 생성 (중복 방지) const hlineRefs: IPriceLine[] = []; let fillPrimitive: IndicatorFillPrimitive | undefined; @@ -2849,6 +2947,10 @@ export class ChartManager { void this._repaintHistogramSeries(config.id, config); } + if (DUAL_LINE_INDICATOR_TYPES.has(config.type)) { + void this._ensureDualLinePlotSeriesForEntry(entry); + } + if (this._candleOnlyLayout && this._isSubPaneIndicator(entry)) { this._hideSubPaneIndicator(entry); } diff --git a/frontend/src/utils/customIndicators.ts b/frontend/src/utils/customIndicators.ts index 78b1c4f..099f143 100644 --- a/frontend/src/utils/customIndicators.ts +++ b/frontend/src/utils/customIndicators.ts @@ -345,7 +345,7 @@ export function calcOBVWithMA( obv.push(0); continue; } - const vol = bars[i].volume; + const vol = Number(bars[i].volume) || 0; if (bars[i].close > bars[i - 1].close) acc += vol; else if (bars[i].close < bars[i - 1].close) acc -= vol; obv.push(acc); diff --git a/frontend/src/utils/strategyToChartIndicators.ts b/frontend/src/utils/strategyToChartIndicators.ts index 6df925d..bfbca51 100644 --- a/frontend/src/utils/strategyToChartIndicators.ts +++ b/frontend/src/utils/strategyToChartIndicators.ts @@ -12,6 +12,7 @@ import { normalizeObvParams, type HLineDef, } from './indicatorRegistry'; +import { initializeIndicatorConfigForEditor } from './indicatorSettingsEditor'; import { buildMainIndicatorConfig } from './indicatorMainConfig'; import { normalizeSmaConfig, smaPeriodKey } from './smaConfig'; import { normalizeIchimokuConfig } from './ichimokuConfig'; @@ -227,7 +228,11 @@ function buildOneIndicator( cfg = { ...cfg, hlines: applyTargetHlines(cfg.hlines ?? [], ref.targetValue) }; } - return enrichIndicatorConfig(cfg); + return initializeIndicatorConfigForEditor( + cfg, + getParams, + (type, defaultPlots, defaultHlines) => getVisual(type, defaultPlots, defaultHlines ?? []), + ); } /** 전략에 포함된 보조지표를 차트용 IndicatorConfig 로 변환 (중복 type 제거) */