diff --git a/frontend/src/utils/ChartManager.ts b/frontend/src/utils/ChartManager.ts index 60a26ad..e55f5b8 100644 --- a/frontend/src/utils/ChartManager.ts +++ b/frontend/src/utils/ChartManager.ts @@ -740,37 +740,49 @@ export class ChartManager { /** 이중 plot 지표 — addIndicator 루프에서 누락된 plot 시리즈 추가 */ /** - * OBV pane y축 스케일을 최근 N봉 실제 값 기준으로 강제 고정. - * 과거에 OBV가 큰 값을 기록했어도 현재 구간이 y축 하단에 압축되지 않도록 방지. + * OBV pane: plot0(본선)=하반부, plot1(신호선)=상반부로 scale 분리. + * 두 선이 언제나 pane의 서로 다른 절반에 표시되어 항상 시각적으로 구분됨. */ private _applyObvTightScale( seriesList: ISeriesApi[], plots: Record, - recentCount = 60, + seriesMeta?: IndicatorEntry['seriesMeta'], ): void { - const allRecent: number[] = []; + seriesList.forEach((s, i) => { + const plotId = seriesMeta?.[i]?.plotId; + try { + const ps = s.priceScale(); + if (plotId === 'plot1') { + // 신호선: 상반부 (하단 55% 여백) + ps.applyOptions({ scaleMargins: { top: 0.03, bottom: 0.55 }, visible: false }); + } else { + // 본선(plot0): 하반부 (상단 55% 여백) + ps.applyOptions({ scaleMargins: { top: 0.55, bottom: 0.03 } }); + } + } catch { /* ok */ } + }); + // plot0에 전체 값 범위 기준 autoscaleInfoProvider (과거 극값에 의한 압축 방지) + const allPts: number[] = []; for (const key of ['plot0', 'plot1']) { const pts = (plots[key] ?? []).filter(p => p.value != null && isFinite(p.value as number)); - const slice = pts.slice(-recentCount).map(p => p.value as number); - allRecent.push(...slice); + allPts.push(...pts.slice(-60).map(p => p.value as number)); } - if (allRecent.length === 0) return; - const minV = Math.min(...allRecent); - const maxV = Math.max(...allRecent); - const span = Math.max(1, maxV - minV); - const pad = span * 0.3; - const tMin = minV - pad; - const tMax = maxV + pad; - for (const s of seriesList) { - try { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (s as ISeriesApi).applyOptions({ - autoscaleInfoProvider: () => ({ - priceRange: { minValue: tMin, maxValue: tMax }, - margins: { above: 0, below: 0 }, - }), - }); - } catch { /* ok */ } + if (allPts.length > 0) { + const minV = Math.min(...allPts); + const maxV = Math.max(...allPts); + const span = Math.max(1, maxV - minV); + const pad = span * 0.15; + seriesList.forEach(s => { + try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (s as ISeriesApi).applyOptions({ + autoscaleInfoProvider: () => ({ + priceRange: { minValue: minV - pad, maxValue: maxV + pad }, + margins: { above: 0, below: 0 }, + }), + }); + } catch { /* ok */ } + }); } } @@ -848,7 +860,7 @@ export class ChartManager { def, ); if (config.type === 'OBV' && !indicatorHidden && entry.seriesList.length > 0) { - this._applyObvTightScale(entry.seriesList, result.plots as Record); + this._applyObvTightScale(entry.seriesList, result.plots as Record, entry.seriesMeta); } entry.config = config; if (entry.seriesList.length > 0) { @@ -935,6 +947,11 @@ 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' @@ -946,6 +963,7 @@ 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 }); @@ -966,9 +984,9 @@ export class ChartManager { ); } - // OBV: 최근 50봉 값 기준으로 y축 고정 → 과거 큰 값에 의한 스케일 압축 방지 + // OBV: plot0=하반부, plot1=상반부로 분리 → 언제나 두 선이 시각적으로 구분됨 if (config.type === 'OBV' && !indicatorHidden && seriesList.length > 0) { - this._applyObvTightScale(seriesList, result.plots as Record); + this._applyObvTightScale(seriesList, result.plots as Record, seriesMeta); } // hlines(과열선/중앙선/침체선)는 첫 번째 시리즈에만 한 번 생성 (중복 방지) @@ -1086,11 +1104,11 @@ export class ChartManager { if (!DUAL_LINE_INDICATOR_TYPES.has(entry.type)) continue; await this._ensureDualLinePlotSeriesForEntry(entry); if (entry.config) this.applyIndicatorStyle(entry.config); - // OBV: tight scale 재적용 (스타일 갱신 후 autoscaleInfoProvider 덮어써질 수 있음) + // 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); + this._applyObvTightScale(entry.seriesList, result.plots as Record, entry.seriesMeta); } catch { /* ok */ } } } @@ -3005,6 +3023,21 @@ export class ChartManager { } } + // OBV: 스타일 갱신 후 pane 분리 scaleMargins 재적용 + if (entry.type === 'OBV' && entry.seriesList.length > 0) { + entry.seriesList.forEach((s, i) => { + const pid = entry.seriesMeta[i]?.plotId; + try { + const ps = s.priceScale(); + if (pid === 'plot1') { + ps.applyOptions({ scaleMargins: { top: 0.03, bottom: 0.55 }, visible: false }); + } else { + ps.applyOptions({ scaleMargins: { top: 0.55, bottom: 0.03 } }); + } + } catch { /* ok */ } + }); + } + if (entry.type === 'BollingerBands') { detachBbBandFill(entry); if (this._shouldShowBbBandFillForConfig(config)) {