diff --git a/frontend/src/utils/ChartManager.ts b/frontend/src/utils/ChartManager.ts index 648531c..45e6794 100644 --- a/frontend/src/utils/ChartManager.ts +++ b/frontend/src/utils/ChartManager.ts @@ -225,6 +225,12 @@ function sortDualLinePlotDefs(plotDefs: PlotDef[]): PlotDef[] { ); } +/** 이중 plot 지표 시리즈 추가 순서 — OBV는 plot1→plot0 순으로 본선이 위에 그려지게 */ +function sortPlotDefsForSeriesAdd(type: string, plotDefs: PlotDef[]): PlotDef[] { + const sorted = sortDualLinePlotDefs(plotDefs); + return type === 'OBV' ? [...sorted].reverse() : sorted; +} + /** OBV 본선(plot0) — DB plotVisibility·투명색으로 숨김 방지 */ function isDualLinePlotForcedVisible(type: string, plotId: string): boolean { return type === 'OBV' && plotId === 'plot0'; @@ -738,63 +744,23 @@ export class ChartManager { return loadGen !== this._dataGeneration; } - /** 이중 plot 지표 — addIndicator 루프에서 누락된 plot 시리즈 추가 */ - /** - * OBV pane: plot0(본선)=하반부, plot1(신호선)=상반부로 분리. - * 각 series의 autoscaleInfoProvider에서 priceRange를 오프셋 처리하여 - * 독립 scale에서도 항상 pane의 서로 다른 절반에 표시되도록 보장. - * - * 원리: plot0는 [pMin, pMax + pSpan] 범위를 사용 → 실제 데이터가 하반부에 위치 - * plot1는 [pMin - pSpan, pMax] 범위를 사용 → 실제 데이터가 상반부에 위치 - */ - private _applyObvTightScale( - seriesList: ISeriesApi[], - plots: Record, - seriesMeta?: IndicatorEntry['seriesMeta'], - ): void { - const allPts: number[] = []; - for (const key of ['plot0', 'plot1']) { - const pts = (plots[key] ?? []).filter(p => p.value != null && isFinite(p.value as number)); - allPts.push(...pts.slice(-60).map(p => p.value as number)); - } - if (allPts.length === 0) return; - - const minV = Math.min(...allPts); - const maxV = Math.max(...allPts); - const span = Math.max(1, maxV - minV); - const pad = span * 0.12; - const pMin = minV - pad; - const pMax = maxV + pad; - const pSpan = pMax - pMin; - - seriesList.forEach((s, i) => { - const plotId = seriesMeta?.[i]?.plotId; + /** OBV — RSI/CCI와 동일한 공유 price scale, 이전 autoscale 오버라이드 제거 */ + private _resetObvSharedScale(entry: IndicatorEntry): void { + if (entry.type !== 'OBV') return; + for (const s of entry.seriesList) { try { - if (plotId === 'plot1') { - // 신호선: 독립 scale, 상반부 — priceRange 를 하방으로 pSpan 확장 - // [pMin - pSpan, pMax] 에서 실제 데이터 [pMin, pMax] 가 상위 50% 차지 - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (s as ISeriesApi).applyOptions({ - autoscaleInfoProvider: () => ({ - priceRange: { minValue: pMin - pSpan, maxValue: pMax }, - margins: { above: 0.04, below: 0 }, - }), - }); - // 신호선 scale axis 숨김 - try { s.priceScale().applyOptions({ visible: false }); } catch { /* ok */ } - } else { - // OBV 본선: 하반부 — priceRange 를 상방으로 pSpan 확장 - // [pMin, pMax + pSpan] 에서 실제 데이터 [pMin, pMax] 가 하위 50% 차지 - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (s as ISeriesApi).applyOptions({ - autoscaleInfoProvider: () => ({ - priceRange: { minValue: pMin, maxValue: pMax + pSpan }, - margins: { above: 0, below: 0.04 }, - }), - }); - } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (s as ISeriesApi).applyOptions({ autoscaleInfoProvider: undefined }); } catch { /* ok */ } - }); + } + const anchor = entry.seriesList[0]; + if (!anchor) return; + try { + anchor.priceScale().applyOptions({ + visible: true, + scaleMargins: { top: 0.1, bottom: 0.1 }, + }); + } catch { /* ok */ } } private _appendMissingPlotSeriesToLists( @@ -806,7 +772,7 @@ export class ChartManager { indicatorHidden: boolean, def: ReturnType, ): void { - const plotDefs = config.plots ?? def?.plots ?? []; + const plotDefs = sortPlotDefsForSeriesAdd(config.type, config.plots ?? def?.plots ?? []); const existingIds = new Set(seriesMeta.map(m => m.plotId)); const indicatorPriceFormat = priceFormatForIndicatorPane(pane); @@ -828,9 +794,14 @@ 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 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, @@ -871,7 +842,7 @@ export class ChartManager { def, ); if (config.type === 'OBV' && !indicatorHidden && entry.seriesList.length > 0) { - this._applyObvTightScale(entry.seriesList, result.plots as Record, entry.seriesMeta); + this._resetObvSharedScale(entry); } entry.config = config; if (entry.seriesList.length > 0) { @@ -928,7 +899,7 @@ export class ChartManager { const plotDefsRaw = config.plots ?? def?.plots ?? []; const plotDefs = DUAL_LINE_INDICATOR_TYPES.has(config.type) - ? sortDualLinePlotDefs(plotDefsRaw) + ? sortPlotDefsForSeriesAdd(config.type, plotDefsRaw) : plotDefsRaw; for (const plotDef of plotDefs) { @@ -958,11 +929,6 @@ export class ChartManager { const fallbackColor = config.type === 'OBV' && plotDef.id === 'plot0' ? (regPlot?.color ?? '#2196F3') : (regPlot?.color ?? plotDef.color ?? '#2962FF'); - // OBV: plot1(신호선)은 별도 priceScaleId → 상반부 / plot0(본선)은 하반부 - const obvSignalScaleId = `obv-sig-${config.id}`; - const seriesPriceScaleId = (config.type === 'OBV' && plotDef.id === 'plot1') - ? obvSignalScaleId - : undefined; series = this.chart.addSeries(LineSeries, { color: resolvePlotLineColor(plotDef.color, fallbackColor), lineWidth: (config.type === 'OBV' && plotDef.id === 'plot0' @@ -974,7 +940,6 @@ export class ChartManager { title: showSeriesTitle ? this.seriesTitleForPlot(config, plotDef) : '', visible: isPlotVisible, ...(indicatorPriceFormat ? { priceFormat: indicatorPriceFormat } : {}), - ...(seriesPriceScaleId ? { priceScaleId: seriesPriceScaleId } : {}), }, pane); series.setData(filtered.map(p => ({ time: p.time as Time, value: p.value }))); seriesMeta.push({ plotId: plotDef.id, isHistogram: false, color: plotDef.color }); @@ -995,9 +960,12 @@ export class ChartManager { ); } - // OBV: plot0=하반부, plot1=상반부로 분리 → 언제나 두 선이 시각적으로 구분됨 if (config.type === 'OBV' && !indicatorHidden && seriesList.length > 0) { - this._applyObvTightScale(seriesList, result.plots as Record, seriesMeta); + const obvEntry: IndicatorEntry = { + id: config.id, type: config.type, seriesList, seriesMeta, + paneIndex: pane, alertLines: [], hlineRefs: [], config, + }; + this._resetObvSharedScale(obvEntry); } // hlines(과열선/중앙선/침체선)는 첫 번째 시리즈에만 한 번 생성 (중복 방지) @@ -1115,12 +1083,8 @@ export class ChartManager { if (!DUAL_LINE_INDICATOR_TYPES.has(entry.type)) continue; await this._ensureDualLinePlotSeriesForEntry(entry); if (entry.config) this.applyIndicatorStyle(entry.config); - // OBV: 분리 스케일 재적용 (스타일 갱신 후 scaleMargins 덮어써질 수 있음) - if (entry.type === 'OBV' && entry.seriesList.length > 0 && this.rawBars.length > 0) { - try { - const result = await calculateIndicator('OBV', this.rawBars.slice(), entry.config?.params ?? {}); - this._applyObvTightScale(entry.seriesList, result.plots as Record, entry.seriesMeta); - } catch { /* ok */ } + if (entry.type === 'OBV' && entry.seriesList.length > 0) { + this._resetObvSharedScale(entry); } } } @@ -3034,14 +2998,8 @@ export class ChartManager { } } - // OBV: 스타일 갱신 후 autoscaleInfoProvider 기반 상하 분리 재적용 - if (entry.type === 'OBV' && entry.seriesList.length > 0 && this.rawBars.length > 0) { - (async () => { - try { - const result = await calculateIndicator('OBV', this.rawBars.slice(), entry.config?.params ?? {}); - this._applyObvTightScale(entry.seriesList, result.plots as Record, entry.seriesMeta); - } catch { /* ok */ } - })(); + if (entry.type === 'OBV' && entry.seriesList.length > 0) { + this._resetObvSharedScale(entry); } if (entry.type === 'BollingerBands') {