diff --git a/frontend/src/utils/ChartManager.ts b/frontend/src/utils/ChartManager.ts index 0166917..3f0f7ad 100644 --- a/frontend/src/utils/ChartManager.ts +++ b/frontend/src/utils/ChartManager.ts @@ -59,8 +59,10 @@ const INDICATOR_PRICE_FORMAT = { formatter: formatIndicatorAxisPrice, }; -function priceFormatForIndicatorPane(paneIndex: number) { - return paneIndex >= 2 ? INDICATOR_PRICE_FORMAT : undefined; +/** 서브 pane 보조지표 — 볼륨 숨김(pane 1)에서도 지표 전용 포맷 사용 */ +function priceFormatForIndicatorSeries(def: ReturnType | undefined) { + if (!def || def.overlay || def.returnsMarkers) return undefined; + return INDICATOR_PRICE_FORMAT; } import { getIchimokuPlotTitle, @@ -162,6 +164,10 @@ interface IndicatorEntry { bbFillPrimitive?: BollingerBandFillPrimitive; config: IndicatorConfig; cloudPlugin?: IchimokuCloudPlugin; // 일목균형표 구름 플러그인 + /** pane 'right' 축 오염 방지용 전용 priceScaleId (OBV 제외 서브 pane 지표) */ + isolatedScaleId?: string; + /** isolatedScale autoscaleInfoProvider 초기 min/max */ + plotAutoscaleBounds?: { min: number; max: number }; } interface AlertEntry { price: number; label: string; line: IPriceLine; } @@ -181,9 +187,11 @@ function seriesByPlotId( return i >= 0 ? entry.seriesList[i] : undefined; } -/** hline·음영 프리미티브 부착 대상 — histogram+line 지표(MACD 등)는 line 시리즈에 부착 */ +/** hline·음영 프리미티브 부착 — histogram 이 있으면 0선과 동일 스케일·기준선에 부착 */ function hlineAnchorSeries(entry: Pick): ISeriesApi | undefined { if (entry.seriesList.length === 0) return undefined; + const histIdx = entry.seriesMeta.findIndex(m => m.isHistogram); + if (histIdx >= 0) return entry.seriesList[histIdx]; const lineIdx = entry.seriesMeta.findIndex(m => !m.isHistogram); return entry.seriesList[lineIdx >= 0 ? lineIdx : 0]; } @@ -654,7 +662,7 @@ export class ChartManager { } catch { /* ok */ } } for (const entry of this.indicators.values()) { - const fmt = priceFormatForIndicatorPane(entry.paneIndex ?? 0); + const fmt = priceFormatForIndicatorSeries(getIndicatorDef(entry.type)); if (!fmt) continue; for (const series of entry.seriesList) { try { @@ -858,7 +866,7 @@ export class ChartManager { const plotById = Object.fromEntries( sortPlotDefsForSeriesAdd('OBV', enriched.plots ?? def?.plots ?? []).map(p => [p.id, p]), ); - const indicatorPriceFormat = priceFormatForIndicatorPane(pane); + const indicatorPriceFormat = priceFormatForIndicatorSeries(def); // 본선(plot0)·신호선(plot1) 을 같은 가격 범위로 고정. // 재렌더·pane 재배치 과정에서 스케일이 오염되면 본선이 납작하게 눌릴 수 있어, @@ -969,9 +977,12 @@ export class ChartManager { pane: number, indicatorHidden: boolean, def: ReturnType, + scaleOpts: ReturnType = null, ): void { const plotDefs = this._dualLinePlotDefsForRender(config, def); - const indicatorPriceFormat = priceFormatForIndicatorPane(pane); + const indicatorPriceFormat = priceFormatForIndicatorSeries(def); + const scaleExtra = scaleOpts ?? {}; + let targetPane = pane; for (const plotDef of plotDefs) { const plotData = result.plots[plotDef.id] as PlotData | undefined; @@ -994,8 +1005,13 @@ export class ChartManager { lastValueVisible: showPriceLabel, title: showSeriesTitle ? this.seriesTitleForPlot(config, plotDef) : '', visible: isPlotVisible, + ...scaleExtra, ...(indicatorPriceFormat ? { priceFormat: indicatorPriceFormat } : {}), - }, pane); + }, targetPane); + if (seriesList.length === 0) { + const actual = this._readSeriesPaneIndex(series); + if (actual >= 0) targetPane = actual; + } 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 }); @@ -1013,7 +1029,7 @@ export class ChartManager { ): void { const plotDefs = this._dualLinePlotDefsForRender(config, def); const existingIds = new Set(seriesMeta.map(m => m.plotId)); - const indicatorPriceFormat = priceFormatForIndicatorPane(pane); + const indicatorPriceFormat = priceFormatForIndicatorSeries(def); for (const plotDef of plotDefs) { if (existingIds.has(plotDef.id)) continue; @@ -1159,6 +1175,9 @@ export class ChartManager { } const plotDefsRaw = config.plots ?? def?.plots ?? []; + const plotRange = this._computePlotsValueRange(result.plots as Record); + const scaleOpts = this._isolatedScaleSeriesOptions(config, plotRange); + const indicatorPriceFormat = priceFormatForIndicatorSeries(def); if (config.type === 'OBV') { this._addObvIndicatorSeries( @@ -1179,8 +1198,10 @@ export class ChartManager { pane, indicatorHidden, def, + scaleOpts, ); } else { + let targetPane = pane; for (const plotDef of plotDefsRaw) { const plotData = result.plots[plotDef.id] as PlotData | undefined; if (!plotData || plotData.length === 0) continue; @@ -1188,12 +1209,14 @@ export class ChartManager { if (filtered.length === 0) continue; let series: ISeriesApi; - const indicatorPriceFormat = priceFormatForIndicatorPane(pane); + const scaleExtra = scaleOpts ?? {}; if (plotDef.type === 'histogram') { series = this.chart.addSeries(HistogramSeries, { color: plotDef.color, + base: 0, + ...scaleExtra, ...(indicatorPriceFormat ? { priceFormat: indicatorPriceFormat } : {}), - }, pane); + }, targetPane); series.setData(mapHistogramSeriesData(filtered, plotDef)); seriesMeta.push({ plotId: plotDef.id, isHistogram: true, color: plotDef.color }); } else { @@ -1212,12 +1235,17 @@ export class ChartManager { lastValueVisible: showPriceLabel, title: showSeriesTitle ? this.seriesTitleForPlot(config, plotDef) : '', visible: isPlotVisible, + ...scaleExtra, ...(indicatorPriceFormat ? { priceFormat: indicatorPriceFormat } : {}), - }, pane); + }, targetPane); series.setData(filtered.map(p => ({ time: p.time as Time, value: p.value }))); seriesMeta.push({ plotId: plotDef.id, isHistogram: false, color: plotDef.color }); } + if (seriesList.length === 0) { + const actual = this._readSeriesPaneIndex(series); + if (actual >= 0) targetPane = actual; + } seriesList.push(series); } } @@ -1275,9 +1303,15 @@ export class ChartManager { const entry: IndicatorEntry = { id: config.id, type: config.type, seriesList, seriesMeta, paneIndex: pane, alertLines: [], hlineRefs, fillPrimitive, config, + isolatedScaleId: scaleOpts?.priceScaleId, + plotAutoscaleBounds: plotRange ?? undefined, }; const actualPane = this._readSeriesPaneIndex(seriesList[0]); - if (actualPane >= 2) entry.paneIndex = actualPane; + const minSub = this._minIndicatorSubPane(); + if (actualPane >= minSub) entry.paneIndex = actualPane; + if (entry.isolatedScaleId != null && entry.paneIndex != null && entry.paneIndex >= minSub) { + this._applyIsolatedScalePaneOptions(entry.isolatedScaleId, entry.paneIndex); + } if (this._indicatorLoadStale(dataGenAtStart) || this.indicators.has(config.id)) { this._discardIndicatorSeries(seriesList, { fillPrimitive }); return; @@ -2145,6 +2179,86 @@ export class ChartManager { } } + private _indicatorScaleId(configId: string): string { + return `ind-${configId}`; + } + + /** plot 데이터 min/max — 전용 가격축 autoscale 용 */ + private _computePlotsValueRange( + plots: Record, + plotIds?: string[], + ): { min: number; max: number } | null { + let lo = Infinity; + let hi = -Infinity; + const ids = plotIds ?? Object.keys(plots); + for (const id of ids) { + for (const p of plots[id] ?? []) { + if (Number.isFinite(p.value)) { + lo = Math.min(lo, p.value); + hi = Math.max(hi, p.value); + } + } + } + if (!Number.isFinite(lo) || !Number.isFinite(hi)) return null; + return { min: lo, max: hi }; + } + + /** OBV 제외 서브 pane 지표 — pane 공유 'right' 축 오염(종가·타 지표 값 혼입) 방지 */ + private _usesIsolatedPriceScale(type: string): boolean { + if (type === 'OBV') return false; + const def = getIndicatorDef(type); + return !!(def && !def.overlay && !def.returnsMarkers); + } + + private _makeIsolatedAutoscaleProvider(lo: number, hi: number) { + return (baseImpl: () => { priceRange: { minValue: number; maxValue: number } } | null) => { + let minV = lo; + let maxV = hi; + const base = baseImpl?.(); + if (base?.priceRange) { + minV = Math.min(minV, base.priceRange.minValue); + maxV = Math.max(maxV, base.priceRange.maxValue); + } + minV = Math.min(minV, 0); + maxV = Math.max(maxV, 0); + const pad = (maxV - minV) * 0.08 || Math.abs(maxV) * 0.08 || 1; + return { priceRange: { minValue: minV - pad, maxValue: maxV + pad } }; + }; + } + + private _isolatedScaleSeriesOptions( + config: IndicatorConfig, + plotRange: { min: number; max: number } | null, + ): { + priceScaleId: string; + autoscaleInfoProvider?: ReturnType; + } | null { + if (!this._usesIsolatedPriceScale(config.type)) return null; + const scaleId = this._indicatorScaleId(config.id); + if (!plotRange || plotRange.max <= plotRange.min) { + return { priceScaleId: scaleId }; + } + let { min, max } = plotRange; + const def = getIndicatorDef(config.type); + if (def?.hlines?.some(h => h.price === 0)) { + min = Math.min(min, 0); + max = Math.max(max, 0); + } + return { + priceScaleId: scaleId, + autoscaleInfoProvider: this._makeIsolatedAutoscaleProvider(min, max), + }; + } + + private _applyIsolatedScalePaneOptions(scaleId: string, pane: number): void { + try { + this.chart.priceScale(scaleId, pane).applyOptions({ + visible: true, + scaleMargins: { top: 0.12, bottom: 0.12 }, + }); + } catch { /* ok */ } + } + /** 볼륨 pane(1)이 실제로 표시되는지 여부 */ private _volumePaneShown(): boolean { return this._volumePaneEnabled && this._volumeVisible && !this._candleOnlyLayout; @@ -2269,7 +2383,7 @@ export class ChartManager { if (!moved && entry.paneIndex === target) continue; entry.paneIndex = target; - // 통일된 pane 에서 OBV 전용 축 옵션 재확정 (이동 중 손실 방지) + // 통일된 pane 에서 OBV·전용축 지표 옵션 재확정 (이동 중 손실 방지) if (entry.type === 'OBV' && entry.config) { try { this.chart.priceScale(`obv-${entry.config.id}`, target).applyOptions({ @@ -2277,6 +2391,8 @@ export class ChartManager { scaleMargins: { top: 0.12, bottom: 0.12 }, }); } catch { /* ok */ } + } else if (entry.isolatedScaleId) { + this._applyIsolatedScalePaneOptions(entry.isolatedScaleId, target); } } } @@ -3494,7 +3610,7 @@ export class ChartManager { return; } const paneIdx = entry.paneIndex ?? 0; - const indicatorPriceFormat = priceFormatForIndicatorPane(paneIdx); + const indicatorPriceFormat = priceFormatForIndicatorSeries(def); // eslint-disable-next-line @typescript-eslint/no-explicit-any const opts: Record = { diff --git a/frontend/src/utils/indicatorRegistry.ts b/frontend/src/utils/indicatorRegistry.ts index 89410eb..6abb766 100644 --- a/frontend/src/utils/indicatorRegistry.ts +++ b/frontend/src/utils/indicatorRegistry.ts @@ -306,6 +306,10 @@ export function resolveHLineDrawPrice( ): number { const def = getIndicatorDef(indicatorType); const defaults = def?.hlines ?? []; + // MACD·AO 등 registry hline 이 0 하나뿐이면 어떤 저장값이든 0으로 그린다 + if (defaults.length === 1 && defaults[0].price === 0) { + return 0; + } const defPrices = defaults.map(d => d.price); const prices = allHlines.map(h => h.price); const lbl = hl.label ?? getHLineLabel(hl.price, prices);