최종이길

This commit is contained in:
Macbook
2026-06-09 15:23:52 +09:00
parent bc478dd51e
commit 645c54f991
+38 -80
View File
@@ -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·투명색으로 숨김 방지 */ /** OBV 본선(plot0) — DB plotVisibility·투명색으로 숨김 방지 */
function isDualLinePlotForcedVisible(type: string, plotId: string): boolean { function isDualLinePlotForcedVisible(type: string, plotId: string): boolean {
return type === 'OBV' && plotId === 'plot0'; return type === 'OBV' && plotId === 'plot0';
@@ -738,63 +744,23 @@ export class ChartManager {
return loadGen !== this._dataGeneration; return loadGen !== this._dataGeneration;
} }
/** 이중 plot 지표 — addIndicator 루프에서 누락된 plot 시리즈 추가 */ /** OBV — RSI/CCI와 동일한 공유 price scale, 이전 autoscale 오버라이드 제거 */
/** private _resetObvSharedScale(entry: IndicatorEntry): void {
* OBV pane: plot0(본선)=하반부, plot1(신호선)=상반부로 분리. if (entry.type !== 'OBV') return;
* 각 series의 autoscaleInfoProvider에서 priceRange를 오프셋 처리하여 for (const s of entry.seriesList) {
* 독립 scale에서도 항상 pane의 서로 다른 절반에 표시되도록 보장.
*
* 원리: plot0는 [pMin, pMax + pSpan] 범위를 사용 → 실제 데이터가 하반부에 위치
* plot1는 [pMin - pSpan, pMax] 범위를 사용 → 실제 데이터가 상반부에 위치
*/
private _applyObvTightScale(
seriesList: ISeriesApi<SeriesType>[],
plots: Record<string, PlotData>,
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;
try { try {
if (plotId === 'plot1') {
// 신호선: 독립 scale, 상반부 — priceRange 를 하방으로 pSpan 확장
// [pMin - pSpan, pMax] 에서 실제 데이터 [pMin, pMax] 가 상위 50% 차지
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
(s as ISeriesApi<any>).applyOptions({ (s as ISeriesApi<any>).applyOptions({ autoscaleInfoProvider: undefined });
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<any>).applyOptions({
autoscaleInfoProvider: () => ({
priceRange: { minValue: pMin, maxValue: pMax + pSpan },
margins: { above: 0, below: 0.04 },
}),
});
}
} catch { /* ok */ } } 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( private _appendMissingPlotSeriesToLists(
@@ -806,7 +772,7 @@ export class ChartManager {
indicatorHidden: boolean, indicatorHidden: boolean,
def: ReturnType<typeof getIndicatorDef>, def: ReturnType<typeof getIndicatorDef>,
): void { ): 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 existingIds = new Set(seriesMeta.map(m => m.plotId));
const indicatorPriceFormat = priceFormatForIndicatorPane(pane); const indicatorPriceFormat = priceFormatForIndicatorPane(pane);
@@ -828,9 +794,14 @@ export class ChartManager {
const showPriceLabel = this.shouldShowSeriesPriceLabel(config, true, pane); const showPriceLabel = this.shouldShowSeriesPriceLabel(config, true, pane);
const showSeriesTitle = pane < 2 && showPriceLabel; const showSeriesTitle = pane < 2 && showPriceLabel;
const regPlot = def?.plots?.find(p => p.id === plotDef.id); 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, { const series = this.chart.addSeries(LineSeries, {
color: resolvePlotLineColor(plotDef.color, regPlot?.color ?? plotDef.color ?? '#2962FF'), color: resolvePlotLineColor(plotDef.color, fallbackColor),
lineWidth: Math.max(1, plotDef.lineWidth ?? 1) as 1 | 2 | 3 | 4, 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), lineStyle: plotSeriesLineStyle(plotDef.lineStyle),
priceLineVisible: false, priceLineVisible: false,
lastValueVisible: showPriceLabel, lastValueVisible: showPriceLabel,
@@ -871,7 +842,7 @@ export class ChartManager {
def, def,
); );
if (config.type === 'OBV' && !indicatorHidden && entry.seriesList.length > 0) { if (config.type === 'OBV' && !indicatorHidden && entry.seriesList.length > 0) {
this._applyObvTightScale(entry.seriesList, result.plots as Record<string, PlotData>, entry.seriesMeta); this._resetObvSharedScale(entry);
} }
entry.config = config; entry.config = config;
if (entry.seriesList.length > 0) { if (entry.seriesList.length > 0) {
@@ -928,7 +899,7 @@ export class ChartManager {
const plotDefsRaw = config.plots ?? def?.plots ?? []; const plotDefsRaw = config.plots ?? def?.plots ?? [];
const plotDefs = DUAL_LINE_INDICATOR_TYPES.has(config.type) const plotDefs = DUAL_LINE_INDICATOR_TYPES.has(config.type)
? sortDualLinePlotDefs(plotDefsRaw) ? sortPlotDefsForSeriesAdd(config.type, plotDefsRaw)
: plotDefsRaw; : plotDefsRaw;
for (const plotDef of plotDefs) { for (const plotDef of plotDefs) {
@@ -958,11 +929,6 @@ export class ChartManager {
const fallbackColor = config.type === 'OBV' && plotDef.id === 'plot0' const fallbackColor = config.type === 'OBV' && plotDef.id === 'plot0'
? (regPlot?.color ?? '#2196F3') ? (regPlot?.color ?? '#2196F3')
: (regPlot?.color ?? plotDef.color ?? '#2962FF'); : (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, { series = this.chart.addSeries(LineSeries, {
color: resolvePlotLineColor(plotDef.color, fallbackColor), color: resolvePlotLineColor(plotDef.color, fallbackColor),
lineWidth: (config.type === 'OBV' && plotDef.id === 'plot0' lineWidth: (config.type === 'OBV' && plotDef.id === 'plot0'
@@ -974,7 +940,6 @@ export class ChartManager {
title: showSeriesTitle ? this.seriesTitleForPlot(config, plotDef) : '', title: showSeriesTitle ? this.seriesTitleForPlot(config, plotDef) : '',
visible: isPlotVisible, visible: isPlotVisible,
...(indicatorPriceFormat ? { priceFormat: indicatorPriceFormat } : {}), ...(indicatorPriceFormat ? { priceFormat: indicatorPriceFormat } : {}),
...(seriesPriceScaleId ? { priceScaleId: seriesPriceScaleId } : {}),
}, pane); }, pane);
series.setData(filtered.map(p => ({ time: p.time as Time, value: p.value }))); series.setData(filtered.map(p => ({ time: p.time as Time, value: p.value })));
seriesMeta.push({ plotId: plotDef.id, isHistogram: false, color: plotDef.color }); 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) { if (config.type === 'OBV' && !indicatorHidden && seriesList.length > 0) {
this._applyObvTightScale(seriesList, result.plots as Record<string, PlotData>, seriesMeta); const obvEntry: IndicatorEntry = {
id: config.id, type: config.type, seriesList, seriesMeta,
paneIndex: pane, alertLines: [], hlineRefs: [], config,
};
this._resetObvSharedScale(obvEntry);
} }
// hlines(과열선/중앙선/침체선)는 첫 번째 시리즈에만 한 번 생성 (중복 방지) // hlines(과열선/중앙선/침체선)는 첫 번째 시리즈에만 한 번 생성 (중복 방지)
@@ -1115,12 +1083,8 @@ export class ChartManager {
if (!DUAL_LINE_INDICATOR_TYPES.has(entry.type)) continue; if (!DUAL_LINE_INDICATOR_TYPES.has(entry.type)) continue;
await this._ensureDualLinePlotSeriesForEntry(entry); await this._ensureDualLinePlotSeriesForEntry(entry);
if (entry.config) this.applyIndicatorStyle(entry.config); if (entry.config) this.applyIndicatorStyle(entry.config);
// OBV: 분리 스케일 재적용 (스타일 갱신 후 scaleMargins 덮어써질 수 있음) if (entry.type === 'OBV' && entry.seriesList.length > 0) {
if (entry.type === 'OBV' && entry.seriesList.length > 0 && this.rawBars.length > 0) { this._resetObvSharedScale(entry);
try {
const result = await calculateIndicator('OBV', this.rawBars.slice(), entry.config?.params ?? {});
this._applyObvTightScale(entry.seriesList, result.plots as Record<string, PlotData>, entry.seriesMeta);
} catch { /* ok */ }
} }
} }
} }
@@ -3034,14 +2998,8 @@ export class ChartManager {
} }
} }
// OBV: 스타일 갱신 후 autoscaleInfoProvider 기반 상하 분리 재적용 if (entry.type === 'OBV' && entry.seriesList.length > 0) {
if (entry.type === 'OBV' && entry.seriesList.length > 0 && this.rawBars.length > 0) { this._resetObvSharedScale(entry);
(async () => {
try {
const result = await calculateIndicator('OBV', this.rawBars.slice(), entry.config?.params ?? {});
this._applyObvTightScale(entry.seriesList, result.plots as Record<string, PlotData>, entry.seriesMeta);
} catch { /* ok */ }
})();
} }
if (entry.type === 'BollingerBands') { if (entry.type === 'BollingerBands') {