Files
goldenChart/frontend/src/utils/ChartManager.ts
T

5139 lines
200 KiB
TypeScript

import {
createChart,
CandlestickSeries,
BarSeries,
LineSeries,
AreaSeries,
BaselineSeries,
HistogramSeries,
createSeriesMarkers,
type IChartApi,
type ISeriesApi,
type SeriesType,
type MouseEventParams,
type Time,
type TickMarkFormatter,
type LogicalRange,
type IPriceLine,
type ISeriesMarkersPluginApi,
ColorType,
LineStyle,
CrosshairMode,
TickMarkType,
} from 'lightweight-charts';
import { IndicatorFillPrimitive } from './IndicatorFillPrimitive';
import { BollingerBandFillPrimitive } from './BollingerBandFillPrimitive';
import { resolveBbBandBackground } from './bollingerConfig';
import type { OHLCVBar, ChartType, Theme, IndicatorConfig, Timeframe } from '../types';
import { formatChartAxisPrice, formatIndicatorAxisPrice } from './dataGenerator';
import { formatUpbitKrwPrice } from './safeFormat';
import { TRADE_BUY_COLOR, TRADE_SELL_COLOR } from './tradeSignalColors';
import {
toTradeSignalLabelItem,
type TradeSignalLabelItem,
type TradeSignalMarkerWithPrice,
} from './tradeSignalLabels';
import { computePriceExtremeChartMarkers } from './priceExtremeChartEvents';
import {
DEFAULT_CHART_TIME_FORMAT,
formatLwcTimeWithPattern,
formatLwcTickMarkWithPattern,
normalizeChartTimeFormat,
} from './chartTimeFormat';
import { DEFAULT_DISPLAY_TIMEZONE, getTimezoneOffsetSec } from './timezone';
import { getPaneHostId, sortIndicatorsForPaneLoad } from './indicatorPaneMerge';
import { calculateIndicator, enrichIndicatorConfig, getIndicatorDef, getHLineLabel, type PlotData, type MarkerData } from './indicatorRegistry';
import { filterObvPlotDataForChart } from './customIndicators';
import { IchimokuCloudPlugin, type IchimokuCloudPoint } from './IchimokuCloudPlugin';
const MAIN_PRICE_FORMAT = {
type: 'custom' as const,
minMove: 1,
formatter: formatChartAxisPrice,
};
const INDICATOR_PRICE_FORMAT = {
type: 'custom' as const,
minMove: 0.01,
formatter: formatIndicatorAxisPrice,
};
function priceFormatForIndicatorPane(paneIndex: number) {
return paneIndex >= 2 ? INDICATOR_PRICE_FORMAT : undefined;
}
import {
getIchimokuPlotTitle,
isIchimokuCloudVisible,
resolveIchimokuCloudColors,
resolveIchimokuDisplacements,
} from './ichimokuConfig';
import {
type ChartOverlayToggleKey,
type ChartOverlayVisibility,
DEFAULT_CHART_OVERLAY_VISIBILITY,
chartOverlayKeyForType,
isMovingAverageOverlayType,
} from './chartOverlayVisibility';
import {
type ChartCustomOverlaySelection,
type ChartCustomOverlaySlotId,
createDefaultChartCustomOverlaySelection,
isCustomIchimokuCloudVisible,
isCustomOverlayPlotControlledType,
isCustomPlotSelected,
} from './chartCustomOverlay';
import type { PlotDef, HLineStyle } from './indicatorRegistry';
import { mapHistogramSeriesData, histogramBarColor, resolvePlotLineColor } from './plotColorUtils';
import {
applyPaneSeparatorToChartOptions,
cloneChartPaneSeparatorOptions,
resolveChartPaneSeparatorOptions,
type ChartPaneSeparatorOptions,
} from '../types/chartPaneSeparator';
function plotSeriesLineStyle(style?: HLineStyle): number {
if (style === 'dashed') return LineStyle.Dashed;
if (style === 'dotted') return LineStyle.Dotted;
return LineStyle.Solid;
}
import { calcAutoFib } from './calculations';
interface ThemeTokens {
bgColor: string;
textColor: string;
gridColor: string;
borderColor: string;
crosshairColor: string;
upColor: string;
downColor: string;
}
// 다크: 도쿄 나이트 스타일 (THEME_SETTINGS.md §2.2 / §3 darkChartColors)
const DARK: ThemeTokens = {
bgColor: '#1a1b26',
textColor: '#c0caf5',
gridColor: 'rgba(122, 162, 247, 0.10)',
borderColor: 'rgba(122, 162, 247, 0.15)',
crosshairColor: 'rgba(122, 162, 247, 0.60)',
upColor: '#ff6b6b', // 한국 관례: 상승=빨간색
downColor: '#4dabf7', // 한국 관례: 하락=파란색
};
// 라이트 (THEME_SETTINGS.md §2.1 / §3 lightChartColors)
const LIGHT: ThemeTokens = {
bgColor: '#ffffff',
textColor: '#212121',
gridColor: '#f0f0f0',
borderColor: 'rgba(0, 0, 0, 0.08)',
crosshairColor: 'rgba(33, 150, 243, 0.50)',
upColor: '#c62828', // 한국 관례: 상승=빨간색
downColor: '#1565c0', // 한국 관례: 하락=파란색
};
// 블루: 딥 네이비 (THEME_SETTINGS.md §2.3 / §3 blueChartColors)
const BLUE: ThemeTokens = {
bgColor: '#0c1929',
textColor: '#e8f4ff',
gridColor: 'rgba(94, 181, 255, 0.10)',
borderColor: 'rgba(94, 181, 255, 0.15)',
crosshairColor: 'rgba(94, 181, 255, 0.60)',
upColor: '#ff5555', // 한국 관례: 상승=빨간색
downColor: '#55aaff', // 한국 관례: 하락=파란색
};
interface IndicatorEntry {
id: string;
type: string;
seriesList: ISeriesApi<SeriesType>[];
/**
* seriesList 순서와 1:1 대응
* - plotId: 해당 시리즈가 매핑된 plots 객체의 키 (예: 'plot0', 'plot1')
* → _applyLastPointsToSeries 에서 인덱스 대신 plotId 로 정확히 조회
* - isHistogram: histogram 시리즈 여부 (색상 재계산용)
* - color: 기본 색상
*/
seriesMeta: Array<{ plotId: string; isHistogram: boolean; color: string }>;
paneIndex?: number;
alertLines: IPriceLine[];
/** 수평 기준선(과매수/과매도/0선)의 IPriceLine 참조 — 첫 번째 시리즈에만 생성 */
hlineRefs: IPriceLine[];
/** 과매수/과매도 음영 채움 프리미티브 */
fillPrimitive?: import('./IndicatorFillPrimitive').IndicatorFillPrimitive;
/** 볼린저밴드 어퍼~로우어 영역 채움 */
bbFillPrimitive?: BollingerBandFillPrimitive;
config: IndicatorConfig;
cloudPlugin?: IchimokuCloudPlugin; // 일목균형표 구름 플러그인
}
interface AlertEntry { price: number; label: string; line: IPriceLine; }
interface FibLevel { price: number; label: string; line: IPriceLine; }
function getTheme(theme: Theme): ThemeTokens {
if (theme === 'blue') return BLUE;
if (theme === 'light') return LIGHT;
return DARK;
}
function seriesByPlotId(
entry: IndicatorEntry,
plotId: string,
): ISeriesApi<SeriesType> | undefined {
const i = entry.seriesMeta.findIndex(m => m.plotId === plotId);
return i >= 0 ? entry.seriesList[i] : undefined;
}
function shouldShowBbBandFill(config: IndicatorConfig): boolean {
const bg = resolveBbBandBackground(config.bandBackground);
if (!bg.visible) return false;
if (config.hidden === true) return false;
if (config.plotVisibility?.plot1 === false || config.plotVisibility?.plot2 === false) return false;
return true;
}
function attachBbBandFill(
entry: IndicatorEntry,
config: IndicatorConfig,
showFill?: boolean,
): void {
const show = showFill ?? shouldShowBbBandFill(config);
if (!show) {
detachBbBandFill(entry);
return;
}
const basis = seriesByPlotId(entry, 'plot0');
const upper = seriesByPlotId(entry, 'plot1');
const lower = seriesByPlotId(entry, 'plot2');
if (!basis || !upper || !lower) return;
const bg = resolveBbBandBackground(config.bandBackground);
if (entry.bbFillPrimitive) {
entry.bbFillPrimitive.updateBackground(bg);
entry.bbFillPrimitive.requestRefresh();
return;
}
entry.bbFillPrimitive = new BollingerBandFillPrimitive(upper, lower, bg);
basis.attachPrimitive(entry.bbFillPrimitive);
}
function detachBbBandFill(entry: IndicatorEntry): void {
if (!entry.bbFillPrimitive) return;
const basis = seriesByPlotId(entry, 'plot0');
if (basis) {
try { basis.detachPrimitive(entry.bbFillPrimitive); } catch { /* ok */ }
}
entry.bbFillPrimitive = undefined;
}
/** CCI·RSI·TRIX — 본선+신호선 이중 plot (누락 시리즈 보정 대상). OBV는 별도 경로. */
const DUAL_LINE_INDICATOR_TYPES = new Set(['CCI', 'RSI', 'TRIX']);
const DUAL_LINE_PLOT_ORDER = ['plot0', 'plot1', 'plot2', 'plot3'];
function sortDualLinePlotDefs(plotDefs: PlotDef[]): PlotDef[] {
return [...plotDefs].sort(
(a, b) => DUAL_LINE_PLOT_ORDER.indexOf(a.id) - DUAL_LINE_PLOT_ORDER.indexOf(b.id),
);
}
/**
* OBV 등 이중 plot — 본선(plot0) 먼저·신호(plot1) 나중 (신호 점선이 본선 위).
*/
function sortPlotDefsForSeriesAdd(type: string, plotDefs: PlotDef[]): PlotDef[] {
return sortDualLinePlotDefs(plotDefs);
}
export class ChartManager {
private chart: IChartApi;
private container: HTMLElement;
private mainSeries: ISeriesApi<SeriesType> | null = null;
private volumeSeries: ISeriesApi<'Histogram'> | null = null;
private indicators = new Map<string, IndicatorEntry>();
private alertEntries: AlertEntry[] = [];
private fibLines: FibLevel[] = [];
private drawingTool = 'cursor';
private drawingPoints: { time: Time; price: number }[] = [];
private drawingLines: IPriceLine[] = [];
private _clickHandler: ((p: MouseEventParams<Time>) => void) | null = null;
private rawBars: OHLCVBar[] = [];
/** setData 호출 세대 — addIndicator 중 stale rawBars 적용 방지 */
private _dataGeneration = 0;
/** 진행 중인 보조지표 스로틀 갱신 취소 (종목 전환·재로드 시) */
cancelPendingIndicatorUpdates(): void {
if (this._indUpdateTimer) {
clearTimeout(this._indUpdateTimer);
this._indUpdateTimer = null;
}
this._pendingIndUpdate = false;
this._indRunning = false;
}
private currentTheme: Theme = 'dark';
private currentChartType: ChartType = 'candlestick';
private displayTimezone = DEFAULT_DISPLAY_TIMEZONE;
private displayTimeframe: Timeframe = '1D';
private chartTimeFormat = DEFAULT_CHART_TIME_FORMAT;
private mainMarkersPlugin: ISeriesMarkersPluginApi<Time> | null = null;
private patternMarkers: Array<{ id: string; markers: MarkerData[] }> = [];
/** 백테스팅 매수/매도 시그널 마커 */
private backtestMarkers: TradeSignalMarkerWithPrice[] = [];
/** 실시간 전략 체크 마커 */
private liveStrategyMarkers: TradeSignalMarkerWithPrice[] = [];
/** 9·20일 신고가·신저가 마커 */
private priceExtremeMarkers: TradeSignalMarkerWithPrice[] = [];
private _priceExtremeLabelsVisible = false;
private _tradeSignalLabelListeners = new Set<() => void>();
private compareSeriesMap = new Map<string, ISeriesApi<SeriesType>>();
/** 캔들 pane 오버레이 그룹 표시 (config.hidden 과 별도 — in-place 토글용) */
private _chartOverlayVisibility: ChartOverlayVisibility = {
...DEFAULT_CHART_OVERLAY_VISIBILITY,
};
private _customOverlaySlots: Record<
ChartCustomOverlaySlotId,
{ active: boolean; selection: ChartCustomOverlaySelection }
> = {
custom: {
active: false,
selection: createDefaultChartCustomOverlaySelection(),
},
custom1: {
active: false,
selection: createDefaultChartCustomOverlaySelection(),
},
};
private _appliedCustomOverlaySlot(): ChartCustomOverlaySlotId | null {
if (this._customOverlaySlots.custom1.active) return 'custom1';
if (this._customOverlaySlots.custom.active) return 'custom';
return null;
}
private _isCustomOverlayApplied(): boolean {
return this._appliedCustomOverlaySlot() !== null;
}
private _getAppliedCustomOverlaySelection(): ChartCustomOverlaySelection {
const slot = this._appliedCustomOverlaySlot();
return slot
? this._customOverlaySlots[slot].selection
: createDefaultChartCustomOverlaySelection();
}
// ── 인디케이터 실시간 갱신 스로틀 ─────────────────────────────────────────
private _indUpdateTimer: ReturnType<typeof setTimeout> | null = null;
private _pendingIndUpdate = false;
/** true 이면 _updateIndicatorLastPoints 가 이미 실행 중 → 중복 실행 방지 */
private _indRunning = false;
private static readonly IND_THROTTLE_MS = 300;
// ── 시리즈 클릭 감지 (단일/더블) ─────────────────────────────────────────
private _lastClickTime = 0;
private _lastClickEntryId = '';
private _clickSingleTimer: ReturnType<typeof setTimeout> | null = null;
// ── 크로스헤어 hover 추적 (오버레이 지표 클릭 감지용) ─────────────────────
/** 현재 크로스헤어가 위에 있는 시리즈의 entryId ('__main__' | indicatorId | null) */
private _crosshairEntryId: string | null = null;
/** 캔들 pane(0~1) 오버레이 지표 — 우측 가격축 라벨·설명 */
private _candleAreaPriceLabelsEnabled = true;
/** 하단 보조지표 pane(2+) — 우측 가격축 라벨·설명 */
private _indicatorAreaPriceLabelsEnabled = true;
/** 차트 하단 거래량 pane 표시 */
private _volumeVisible = true;
/** false — 거래량 시리즈·pane 1 미생성 (가상매매 카드 등) */
private _volumePaneEnabled = true;
/** pane 간 구분선 (설정 화면) */
private _paneSeparatorOptions: ChartPaneSeparatorOptions =
resolveChartPaneSeparatorOptions(null, 'dark');
/** applyPaneLayout / resetPaneHeights 에서 측정한 마지막 가용 높이(px) */
private _lastLayoutAvailableHeight?: number;
/** 알림 목록 미니 차트 등 고정 높이 — pane 최소 px·비율 완화 */
private _compactPaneLayout = false;
/** 캔들+거래량 그룹 vs 보조 pane 그룹 stretch 비율 (투자관리·백테스트 분석 차트) */
private _paneAreaRatio: { candle: number; aux: number } | null = null;
/** 보조지표 전용 패널 — 캔들 pane 최소화·선형 지표만 표시 (시그널 상세 등) */
private _auxIndicatorOnlyLayout = false;
constructor(
container: HTMLElement,
theme: Theme,
options?: { autoSize?: boolean; compactPaneLayout?: boolean; volumePaneEnabled?: boolean },
) {
this.container = container;
this._compactPaneLayout = options?.compactPaneLayout ?? false;
this._volumePaneEnabled = options?.volumePaneEnabled ?? true;
if (!this._volumePaneEnabled) this._volumeVisible = false;
const autoSize = options?.autoSize ?? !this._compactPaneLayout;
const t = getTheme(theme);
this.chart = createChart(container, {
/* autoSize: true → LWC가 내부적으로 ResizeObserver를 사용해 컨테이너 크기 변경을 자동으로 처리.
멀티 레이아웃에서 초기 렌더 시 컨테이너가 0 크기일 때도 차트가 정상 표시됨.
compact(미니) 차트는 autoSize:false + syncLayout() 으로 피드백 루프 방지. */
autoSize,
layout: {
background: { type: ColorType.Solid, color: t.bgColor },
textColor: t.textColor,
fontSize: 12,
panes: { enableResize: false, separatorColor: 'transparent', separatorHoverColor: 'transparent' },
},
grid: {
vertLines: { color: t.gridColor },
horzLines: { color: t.gridColor },
},
crosshair: {
mode: CrosshairMode.Normal, /* 기본: 마우스 위치 자유 이동 (자석모드 OFF) */
vertLine: {
color: t.crosshairColor,
labelVisible: true,
labelBackgroundColor: '#50535E',
},
horzLine: { color: t.crosshairColor, labelBackgroundColor: '#50535E' },
},
timeScale: {
borderColor: t.borderColor,
timeVisible: true,
secondsVisible: false,
rightOffset: 12,
fixLeftEdge: false,
fixRightEdge: false,
lockVisibleTimeRangeOnResize: false,
},
rightPriceScale: { borderColor: t.borderColor },
// pressedMouseMove: TradingChart 에서 커스텀 패닝 처리 (드로잉·클릭과 충돌 방지)
// horzTouchDrag: 보조 pane 터치 드래그가 메인 timeScale 과 어긋나는 현상 방지 — TradingChart 커스텀 패닝만 사용
handleScroll: { mouseWheel: true, pressedMouseMove: false, horzTouchDrag: false, vertTouchDrag: false },
handleScale: { axisPressedMouseMove: true, axisDoubleClickReset: true, mouseWheel: true, pinch: true },
});
this._applyDisplayTimezoneOptions();
this._paneSeparatorOptions = resolveChartPaneSeparatorOptions(null, theme);
this.chart.applyOptions(applyPaneSeparatorToChartOptions(this._paneSeparatorOptions));
// 크로스헤어 이동 시 hover 중인 시리즈 추적 (오버레이 클릭 감지에 사용)
this.chart.subscribeCrosshairMove((params: MouseEventParams<Time>) => {
if (params.hoveredSeries) {
const hovered = params.hoveredSeries as ISeriesApi<SeriesType>;
for (const [id, entry] of this.indicators) {
if (entry.seriesList.some(s => s === hovered)) {
this._crosshairEntryId = id;
return;
}
}
// mainSeries 또는 volumeSeries 위라면 '__main__'
this._crosshairEntryId = '__main__';
} else {
this._crosshairEntryId = null;
}
});
}
// ─── Data ───────────────────────────────────────────────────────────────
/** 종목·타임프레임 전환 직후 — 이전 시리즈·rawBars 제거 (실시간 틱 혼입·캔들 폭 깨짐 방지) */
clearForSymbolChange(): void {
this.cancelPendingIndicatorUpdates();
this._dataGeneration += 1;
for (const entry of this.indicators.values()) {
if (entry.cloudPlugin) {
try { this.mainSeries?.detachPrimitive(entry.cloudPlugin); } catch { /* ok */ }
for (const s of entry.seriesList) { try { s.detachPrimitive(entry.cloudPlugin); } catch { /* ok */ } }
}
detachBbBandFill(entry);
for (const s of entry.seriesList) { try { this.chart.removeSeries(s); } catch { /* ok */ } }
}
this.indicators.clear();
this.patternMarkers = [];
this._removeExtraSubPanes();
this._disposeMainMarkersPlugin();
if (this.mainSeries) { try { this.chart.removeSeries(this.mainSeries); } catch { /* ok */ } this.mainSeries = null; }
if (this.volumeSeries) { try { this.chart.removeSeries(this.volumeSeries); } catch { /* ok */ } this.volumeSeries = null; }
this.rawBars = [];
}
setData(bars: OHLCVBar[], chartType: ChartType, theme: Theme): void {
// 전체 재로드 전: 진행 중인 지표 갱신 타이머·플래그를 초기화해
// 이전 rawBars 기반의 stale 계산이 새 지표에 적용되는 것을 방지한다.
this.cancelPendingIndicatorUpdates();
this._dataGeneration += 1;
this.rawBars = bars;
this.currentTheme = theme;
this.currentChartType = chartType;
const t = getTheme(theme);
// 기존 보조지표 시리즈를 모두 제거 (새 데이터로 재계산하기 위해)
for (const entry of this.indicators.values()) {
if (entry.cloudPlugin) {
try { this.mainSeries?.detachPrimitive(entry.cloudPlugin); } catch { /* ok */ }
for (const s of entry.seriesList) { try { s.detachPrimitive(entry.cloudPlugin); } catch { /* ok */ } }
}
detachBbBandFill(entry);
for (const s of entry.seriesList) { try { this.chart.removeSeries(s); } catch { /* ok */ } }
}
this.indicators.clear();
this.patternMarkers = [];
this._removeExtraSubPanes();
this._disposeMainMarkersPlugin();
if (this.mainSeries) { try { this.chart.removeSeries(this.mainSeries); } catch { /* ok */ } this.mainSeries = null; }
if (this.volumeSeries) { try { this.chart.removeSeries(this.volumeSeries); } catch { /* ok */ } this.volumeSeries = null; }
this.mainSeries = this._createMainSeries(chartType, t);
const mainData = bars.map(b => {
if (chartType === 'line' || chartType === 'area' || chartType === 'baseline')
return { time: b.time as Time, value: b.close };
return { time: b.time as Time, open: b.open, high: b.high, low: b.low, close: b.close };
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(this.mainSeries as ISeriesApi<any>).setData(mainData);
const panesInit = this.chart.panes();
if (this._volumePaneEnabled) {
// Volume sub-pane (pane index 1)
this.volumeSeries = this.chart.addSeries(HistogramSeries, {
priceFormat: { type: 'volume' },
priceScaleId: 'volume',
}, 1);
// 초기 volume pane 크기: resetPaneHeights() 호출 전 임시 설정
if (panesInit[0]) panesInit[0].setStretchFactor(0.88);
if (panesInit[1]) panesInit[1].setStretchFactor(0.12);
this.volumeSeries.setData(bars.map(b => ({
time: b.time as Time,
value: b.volume,
color: b.close >= b.open ? `${t.upColor}88` : `${t.downColor}88`,
})));
if (!this._volumeVisible) {
try {
this.volumeSeries.applyOptions({ visible: false } as Parameters<ISeriesApi<SeriesType>['applyOptions']>[0]);
} catch { /* ok */ }
}
} else if (panesInit[0] && !this._auxIndicatorOnlyLayout) {
panesInit[0].setStretchFactor(1);
}
this._refreshPriceExtremeMarkers();
// 종목 전환·초기 로드 — bar 수가 적을 때 음수 logical from 은 캔들이 우측으로 몰림
if (this._auxIndicatorOnlyLayout && bars.length <= 21) {
try { this.chart.timeScale().fitContent(); } catch { /* ok */ }
} else {
this._applyDefaultVisibleRange(200, 0);
}
if (this._auxIndicatorOnlyLayout) {
this.syncChartOverlayVisibility();
}
// setData 시 마커 플러그인만 dispose 되고 backtestMarkers 배열은 유지됨 — 재적용
if (this.backtestMarkers.length > 0 || this.liveStrategyMarkers.length > 0) {
this._reapplyAllPatternMarkers();
}
}
/**
* 표시용 visible range 설정.
* bar < displayCount 이면 fitContent (음수 logical index 사용 금지).
*/
private _applyDefaultVisibleRange(displayCount: number, extendFutureBars = 0): void {
const bars = this.rawBars;
if (!bars || bars.length < 2) return;
const ts = this.chart.timeScale();
if (bars.length <= displayCount) {
if (extendFutureBars > 0) {
let to: number = bars[bars.length - 1].time as number;
to += extendFutureBars * this._barIntervalSecs();
ts.setVisibleRange({
from: bars[0].time as Time,
to: to as Time,
});
} else {
ts.fitContent();
}
this._scheduleNotifyViewport();
return;
}
const startIdx = Math.max(0, bars.length - displayCount);
let to: number = bars[bars.length - 1].time as number;
if (extendFutureBars > 0) {
to += extendFutureBars * this._barIntervalSecs();
}
ts.setVisibleRange({
from: bars[startIdx].time as Time,
to: to as Time,
});
this._scheduleNotifyViewport();
}
private _createMainSeries(chartType: ChartType, t: ThemeTokens): ISeriesApi<SeriesType> {
switch (chartType) {
case 'candlestick':
return this.chart.addSeries(CandlestickSeries, {
upColor: t.upColor, downColor: t.downColor,
borderUpColor: t.upColor, borderDownColor: t.downColor,
wickUpColor: t.upColor, wickDownColor: t.downColor,
priceFormat: MAIN_PRICE_FORMAT,
});
case 'bar':
return this.chart.addSeries(BarSeries, { upColor: t.upColor, downColor: t.downColor, priceFormat: MAIN_PRICE_FORMAT });
case 'line':
return this.chart.addSeries(LineSeries, { color: '#2962FF', lineWidth: 2, crosshairMarkerVisible: true, priceFormat: MAIN_PRICE_FORMAT });
case 'area':
return this.chart.addSeries(AreaSeries, {
lineColor: '#2962FF', topColor: '#2962FF44', bottomColor: '#2962FF00', lineWidth: 2,
priceFormat: MAIN_PRICE_FORMAT,
});
case 'baseline':
return this.chart.addSeries(BaselineSeries, {
baseValue: { type: 'price', price: 0 },
topLineColor: '#26a69a', topFillColor1: '#26a69a44', topFillColor2: '#26a69a00',
bottomLineColor: '#ef5350', bottomFillColor1: '#ef535000', bottomFillColor2: '#ef535044',
priceFormat: MAIN_PRICE_FORMAT,
});
default:
return this.chart.addSeries(CandlestickSeries, {
upColor: t.upColor, downColor: t.downColor,
borderUpColor: t.upColor, borderDownColor: t.downColor,
wickUpColor: t.upColor, wickDownColor: t.downColor,
priceFormat: MAIN_PRICE_FORMAT,
});
}
}
// ─── Display timezone ───────────────────────────────────────────────────
private _tickMarkFormatter(): TickMarkFormatter {
const tf = this.displayTimeframe;
const tz = this.displayTimezone;
const fmt = this.chartTimeFormat;
return (time, tickMarkType) =>
formatLwcTickMarkWithPattern(time as Time, tickMarkType, tf, tz, fmt);
}
private _applyDisplayTimezoneOptions(): void {
const tf = this.displayTimeframe;
const tz = this.displayTimezone;
const fmt = this.chartTimeFormat;
const timeFormatter = (time: Time) =>
formatLwcTimeWithPattern(
time as number | { year: number; month: number; day: number },
tf,
tz,
fmt,
);
this.chart.applyOptions({
localization: { timeFormatter },
timeScale: { tickMarkFormatter: this._tickMarkFormatter() },
});
this._applyAllSeriesPriceFormats();
this._notifyPaneLayout();
}
/** pane별 priceFormat — 전역 priceFormatter 는 보조지표 라벨 포맷을 덮어씀 */
private _applyAllSeriesPriceFormats(): void {
if (this.mainSeries) {
try {
this.mainSeries.applyOptions({ priceFormat: MAIN_PRICE_FORMAT } as Parameters<ISeriesApi<SeriesType>['applyOptions']>[0]);
} catch { /* ok */ }
}
for (const entry of this.indicators.values()) {
const fmt = priceFormatForIndicatorPane(entry.paneIndex ?? 0);
if (!fmt) continue;
for (const series of entry.seriesList) {
try {
series.applyOptions({ priceFormat: fmt } as Parameters<ISeriesApi<SeriesType>['applyOptions']>[0]);
} catch { /* ok */ }
}
}
}
setDisplayTimezone(tz: string, timeframe?: Timeframe, chartTimeFormat?: string): void {
this.displayTimezone = tz || DEFAULT_DISPLAY_TIMEZONE;
if (timeframe) this.displayTimeframe = timeframe;
if (chartTimeFormat != null) {
this.chartTimeFormat = normalizeChartTimeFormat(chartTimeFormat);
}
this._applyDisplayTimezoneOptions();
}
/** 시간축·크로스헤어·캔들 pane 시간 라벨 포맷만 갱신 */
setChartTimeFormat(chartTimeFormat: string): void {
this.chartTimeFormat = normalizeChartTimeFormat(chartTimeFormat);
this._applyDisplayTimezoneOptions();
}
// ─── Theme ──────────────────────────────────────────────────────────────
setTheme(theme: Theme): void {
this.currentTheme = theme;
const t = getTheme(theme);
this.chart.applyOptions({
layout: {
background: { type: ColorType.Solid, color: t.bgColor },
textColor: t.textColor,
panes: applyPaneSeparatorToChartOptions(this._paneSeparatorOptions).layout.panes,
},
grid: { vertLines: { color: t.gridColor }, horzLines: { color: t.gridColor } },
crosshair: {
vertLine: {
color: t.crosshairColor,
labelVisible: true,
labelBackgroundColor: '#50535E',
},
horzLine: { color: t.crosshairColor, labelBackgroundColor: '#50535E' },
},
timeScale: { borderColor: t.borderColor },
rightPriceScale: { borderColor: t.borderColor },
});
this._applyMainSeriesTheme(t);
this._applyVolumeSeriesTheme(t);
}
getContainer(): HTMLElement {
return this.container;
}
getPaneSeparatorOptions(): ChartPaneSeparatorOptions {
return this._paneSeparatorOptions;
}
setPaneSeparatorOptions(opts: ChartPaneSeparatorOptions): void {
this._paneSeparatorOptions = cloneChartPaneSeparatorOptions(opts);
this.chart.applyOptions(applyPaneSeparatorToChartOptions(this._paneSeparatorOptions));
this._notifyPaneLayout();
}
/** pane 구분선 오버레이 강제 재그리기 (설정 변경·탭 복귀) */
refreshPaneSeparatorOverlay(): void {
this._notifyPaneLayout();
}
private _applyMainSeriesTheme(t: ThemeTokens): void {
if (!this.mainSeries) return;
switch (this.currentChartType) {
case 'candlestick':
this.mainSeries.applyOptions({
upColor: t.upColor,
downColor: t.downColor,
borderUpColor: t.upColor,
borderDownColor: t.downColor,
wickUpColor: t.upColor,
wickDownColor: t.downColor,
});
break;
case 'bar':
this.mainSeries.applyOptions({ upColor: t.upColor, downColor: t.downColor });
break;
default:
break;
}
}
private _applyVolumeSeriesTheme(t: ThemeTokens): void {
if (!this.volumeSeries || this.rawBars.length === 0) return;
this.volumeSeries.setData(this.rawBars.map(b => ({
time: b.time as Time,
value: b.volume,
color: b.close >= b.open ? `${t.upColor}88` : `${t.downColor}88`,
})));
}
private _discardIndicatorSeries(
seriesList: ISeriesApi<SeriesType>[],
options?: { cloudPlugin?: IchimokuCloudPlugin; fillPrimitive?: IndicatorFillPrimitive },
): void {
if (options?.cloudPlugin) {
for (const s of seriesList) {
try { s.detachPrimitive(options.cloudPlugin); } catch { /* ok */ }
}
}
if (options?.fillPrimitive && seriesList[0]) {
try { seriesList[0].detachPrimitive(options.fillPrimitive); } catch { /* ok */ }
}
for (const s of seriesList) {
try { this.chart.removeSeries(s); } catch { /* ok */ }
}
}
private _indicatorLoadStale(loadGen: number): boolean {
return loadGen !== this._dataGeneration;
}
/** 이중 plot 지표에서 표시해야 할 plotId 목록 */
private _expectedDualLinePlotIds(
config: IndicatorConfig,
def: ReturnType<typeof getIndicatorDef> | undefined,
): string[] {
const enriched = enrichIndicatorConfig(config);
const plotDefs = enriched.plots ?? def?.plots ?? [];
return plotDefs
.filter(p => p.type !== 'histogram' && enriched.plotVisibility?.[p.id] !== false)
.map(p => p.id);
}
/** seriesMeta·seriesList 불일치 또는 plot0 누락 */
private _dualLineSeriesOutOfSync(
entry: IndicatorEntry,
config: IndicatorConfig,
def: ReturnType<typeof getIndicatorDef> | undefined,
): boolean {
const expected = this._expectedDualLinePlotIds(config, def);
if (expected.length === 0) return false;
if (entry.seriesList.length !== entry.seriesMeta.length) return true;
if (entry.seriesMeta.length !== expected.length) return true;
const existing = new Set(entry.seriesMeta.map(m => m.plotId));
return expected.some(id => !existing.has(id));
}
private _dualLinePlotDefsForRender(
config: IndicatorConfig,
def: ReturnType<typeof getIndicatorDef>,
): PlotDef[] {
const enriched = enrichIndicatorConfig(config);
return sortPlotDefsForSeriesAdd(config.type, enriched.plots ?? def?.plots ?? [])
.filter(p => p.type !== 'histogram' && enriched.plotVisibility?.[p.id] !== false);
}
/**
* OBV 시리즈 추가 순서 — plot0(본선) 먼저·plot1(신호) 나중.
* 신호선은 기본 점선 → 겹쳐도 파란 본선+주황 신호가 동시에 보임.
*/
private _obvSeriesAddOrder(): readonly ['plot0', 'plot1'] {
return ['plot0', 'plot1'];
}
/** 레전드·크로스헤어 — plot0→plot1 순서 (seriesList z-order 와 무관) */
private _plotOrderForEntry(entry: IndicatorEntry): string[] {
const config = entry.config;
const def = getIndicatorDef(entry.type);
const enriched = config ? enrichIndicatorConfig(config) : null;
const plots = enriched?.plots ?? def?.plots ?? [];
return plots
.filter(p => enriched?.plotVisibility?.[p.id] !== false)
.map(p => p.id);
}
private _indicatorValuesInPlotOrder(
entry: IndicatorEntry,
params: MouseEventParams<Time>,
): number[] {
return this._plotOrderForEntry(entry).map(plotId => {
const idx = entry.seriesMeta.findIndex(m => m.plotId === plotId);
if (idx < 0) return NaN;
const series = entry.seriesList[idx];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const d = params.seriesData.get(series) as any;
if (!d) return NaN;
return typeof d.value === 'number' ? d.value : NaN;
});
}
/** OBV — plot0·plot1 항상 쌍으로 생성 (메인 차트와 동일, DUAL_LINE repair 경로 회피) */
private _addObvIndicatorSeries(
seriesList: ISeriesApi<SeriesType>[],
seriesMeta: IndicatorEntry['seriesMeta'],
config: IndicatorConfig,
result: { plots: Record<string, PlotData> },
pane: number,
indicatorHidden: boolean,
def: ReturnType<typeof getIndicatorDef>,
): void {
const enriched = enrichIndicatorConfig(config);
const plotById = Object.fromEntries(
sortPlotDefsForSeriesAdd('OBV', enriched.plots ?? def?.plots ?? []).map(p => [p.id, p]),
);
const indicatorPriceFormat = priceFormatForIndicatorPane(pane);
// 본선(plot0)·신호선(plot1) 을 같은 가격 범위로 고정.
// 재렌더·pane 재배치 과정에서 스케일이 오염되면 본선이 납작하게 눌릴 수 있어,
// OBV 데이터(plot0+plot1) 의 실제 min/max 로 autoscale 을 강제한다.
let obvMin = Infinity, obvMax = -Infinity;
for (const id of ['plot0', 'plot1'] as const) {
for (const p of (result.plots[id] ?? []) as PlotData) {
if (Number.isFinite(p.value)) {
obvMin = Math.min(obvMin, p.value);
obvMax = Math.max(obvMax, p.value);
}
}
}
// 본선·신호선을 같은 전용 가격축에 격리 → 같은 pane 의 다른(오염된) 'right' 축이나
// 고아 시리즈의 거대값에 휩쓸려 본선이 납작하게 눌리는 것을 차단한다.
const obvScaleId = `obv-${config.id}`;
const obvAutoscaleProvider = (Number.isFinite(obvMin) && Number.isFinite(obvMax) && obvMax > obvMin)
? (baseImpl: () => { priceRange: { minValue: number; maxValue: number } } | null) => {
// plot0·plot1 둘 다 동일 범위(union)를 쓰도록 해 실제 값 차이가 세로로 분리돼 보인다.
// 실시간 봉 추가로 값이 커지면 base(현재 시리즈 범위)와 union 해서 추적.
let lo = obvMin, hi = obvMax;
const base = baseImpl?.();
if (base?.priceRange) {
lo = Math.min(lo, base.priceRange.minValue);
hi = Math.max(hi, base.priceRange.maxValue);
}
const pad = (hi - lo) * 0.08 || Math.abs(hi) * 0.08 || 1;
return { priceRange: { minValue: lo - pad, maxValue: hi + pad } };
}
: undefined;
// lightweight-charts 는 존재하지 않는 pane 을 한 번에 하나씩만 만들기 때문에,
// 볼륨 숨김 차트(pane 0 만 존재)에서 paneIndex=2 로 두 시리즈를 추가하면
// 첫 시리즈는 pane 1, 두 번째는 pane 2 로 갈라진다. 첫 시리즈가 실제로 배치된
// pane 을 읽어 이후 시리즈를 같은 pane 에 강제 → plot0/plot1 분리(축 분리) 차단.
let targetPane = pane;
for (const plotId of this._obvSeriesAddOrder()) {
const plotDef = plotById[plotId] ?? def?.plots?.find(p => p.id === plotId);
if (!plotDef || plotDef.type === 'histogram') continue;
const plotData = result.plots[plotId] as PlotData | undefined;
if (!plotData?.length) continue;
const filtered = filterObvPlotDataForChart(
plotData,
result.plots as Record<string, PlotData>,
);
if (filtered.length === 0) continue;
const isPlotVisible = !indicatorHidden;
const showPriceLabel = this.shouldShowSeriesPriceLabel(enriched, true, pane);
const showSeriesTitle = pane < 2 && showPriceLabel;
const regPlot = def?.plots?.find(p => p.id === plotId);
const resolvedColor = resolvePlotLineColor(
plotDef.color,
regPlot?.color ?? plotDef.color ?? (plotId === 'plot0' ? '#2196F3' : '#FF9800'),
);
const resolvedWidth = (plotId === 'plot0'
? Math.max(2, plotDef.lineWidth ?? regPlot?.lineWidth ?? 2)
: Math.max(1, plotDef.lineWidth ?? regPlot?.lineWidth ?? 1)) as 1 | 2 | 3 | 4;
const series = this.chart.addSeries(LineSeries, {
color: resolvedColor,
lineWidth: resolvedWidth,
lineStyle: plotSeriesLineStyle(plotDef.lineStyle),
priceLineVisible: false,
lastValueVisible: showPriceLabel,
crosshairMarkerVisible: true,
crosshairMarkerRadius: plotId === 'plot0' ? 4 : 3,
title: showSeriesTitle ? this.seriesTitleForPlot(enriched, plotDef) : '',
visible: isPlotVisible,
priceScaleId: obvScaleId,
...(obvAutoscaleProvider ? { autoscaleInfoProvider: obvAutoscaleProvider } : {}),
...(indicatorPriceFormat ? { priceFormat: indicatorPriceFormat } : {}),
}, targetPane);
// 첫 시리즈가 실제로 배치된 pane 으로 이후 시리즈를 고정
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, isHistogram: false, color: plotDef.color });
}
// 전용 축을 우측에 표시 (눈금 라벨 유지) + 여백
if (seriesList.length > 0) {
try {
this.chart.priceScale(obvScaleId, pane).applyOptions({
visible: true,
scaleMargins: { top: 0.12, bottom: 0.12 },
});
} catch { /* ok */ }
}
if (!indicatorHidden && seriesMeta.length < 2) {
console.warn('[OBV] incomplete series — expected plot0+plot1, got', seriesMeta.map(m => m.plotId));
}
}
/** RSI·CCI·TRIX — plot0→plot1 순서로 시리즈를 한 번에 생성 */
private _addDualLinePlotSeries(
seriesList: ISeriesApi<SeriesType>[],
seriesMeta: IndicatorEntry['seriesMeta'],
config: IndicatorConfig,
result: { plots: Record<string, PlotData> },
pane: number,
indicatorHidden: boolean,
def: ReturnType<typeof getIndicatorDef>,
): void {
const plotDefs = this._dualLinePlotDefsForRender(config, def);
const indicatorPriceFormat = priceFormatForIndicatorPane(pane);
for (const plotDef of plotDefs) {
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 ?? regPlot?.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 });
}
}
private _appendMissingPlotSeriesToLists(
seriesList: ISeriesApi<SeriesType>[],
seriesMeta: IndicatorEntry['seriesMeta'],
config: IndicatorConfig,
result: { plots: Record<string, PlotData> },
pane: number,
indicatorHidden: boolean,
def: ReturnType<typeof getIndicatorDef>,
): void {
const plotDefs = this._dualLinePlotDefsForRender(config, def);
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 (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 ?? regPlot?.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 });
}
}
/** plot0·plot1 시리즈 전부 제거 후 DB 설정 스타일로 재생성 */
private async _rebuildDualLinePlotSeriesForEntry(
entry: IndicatorEntry,
config: IndicatorConfig,
def: NonNullable<ReturnType<typeof getIndicatorDef>>,
): Promise<void> {
if (this.rawBars.length === 0) return;
const pane = entry.paneIndex ?? this._readSeriesPaneIndex(entry.seriesList[0]) ?? 2;
const indicatorHidden = !this._isIndicatorEffectivelyVisible(config);
for (const pl of entry.hlineRefs) {
try { entry.seriesList[0]?.removePriceLine(pl); } catch { /* ok */ }
}
entry.hlineRefs = [];
for (const s of entry.seriesList) {
try { this.chart.removeSeries(s); } catch { /* ok */ }
}
entry.seriesList = [];
entry.seriesMeta = [];
try {
const result = await calculateIndicator(config.type, this.rawBars.slice(), config.params);
this._addDualLinePlotSeries(
entry.seriesList,
entry.seriesMeta,
config,
result,
pane,
indicatorHidden,
def,
);
if (entry.seriesList.length > 0) {
this._setIndicatorPlotsFullData(entry, result.plots as Record<string, PlotData>, config);
}
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 rebuild error:`, e);
}
}
/** 스타일 갱신·초기 추가 후 OBV 등 본선(plot0) 시리즈 누락 보정 + 전체 setData */
private async _ensureDualLinePlotSeriesForEntry(entry: IndicatorEntry): Promise<void> {
if (!DUAL_LINE_INDICATOR_TYPES.has(entry.type)) return;
const config = enrichIndicatorConfig(entry.config!);
const def = getIndicatorDef(config.type);
if (!def) return;
if (this.rawBars.length === 0) return;
if (this._dualLineSeriesOutOfSync(entry, config, def)) {
await this._rebuildDualLinePlotSeriesForEntry(entry, config, def);
return;
}
if (entry.seriesList.length === 0) return;
try {
const result = await calculateIndicator(config.type, this.rawBars.slice(), config.params);
this._setIndicatorPlotsFullData(entry, result.plots as Record<string, PlotData>, config);
entry.config = config;
} catch (e) {
console.error(`[Indicator] ${config.type} dual-line repair error:`, e);
}
}
// ─── Indicators ─────────────────────────────────────────────────────────
async addIndicator(
config: IndicatorConfig,
options?: { skipLayout?: boolean },
): Promise<void> {
config = enrichIndicatorConfig(config);
if (this.indicators.has(config.id) || this.rawBars.length === 0) return;
const dataGenAtStart = this._dataGeneration;
const indicatorHidden = !this._isIndicatorEffectivelyVisible(config);
const def = getIndicatorDef(config.type);
const seriesList: ISeriesApi<SeriesType>[] = [];
const seriesMeta: IndicatorEntry['seriesMeta'] = [];
try {
// 스냅샷 전달: await 중 rawBars 가 변경돼도 이 지표는 일관된 초기 데이터로 setData
const result = await calculateIndicator(config.type, this.rawBars.slice(), config.params);
if (dataGenAtStart !== this._dataGeneration) return;
if (def?.returnsMarkers && result.markers && result.markers.length > 0) {
this.patternMarkers.push({ id: config.id, markers: result.markers });
this._reapplyAllPatternMarkers();
this.indicators.set(config.id, { id: config.id, type: config.type, seriesList: [], seriesMeta: [], alertLines: [], hlineRefs: [], config });
return;
}
const pane = def?.overlay ? 0 : this._resolveSubPane(config);
// ─── 일목균형표 특별 처리 ────────────────────────────────────────────
if (config.type === 'IchimokuCloud') {
const { cloudPlugin, seriesMeta: ichiMeta } = await this._addIchimokuSeries(result, pane, config, seriesList);
if (this._indicatorLoadStale(dataGenAtStart) || this.indicators.has(config.id)) {
this._discardIndicatorSeries(seriesList, { cloudPlugin });
return;
}
this.indicators.set(config.id, { id: config.id, type: config.type, seriesList, seriesMeta: ichiMeta, paneIndex: pane, alertLines: [], hlineRefs: [], config, cloudPlugin });
const ichiPane = this._readSeriesPaneIndex(seriesList[0]);
const ichiEntry = this.indicators.get(config.id);
if (ichiEntry && ichiPane >= 2) ichiEntry.paneIndex = ichiPane;
this.applyIndicatorStyle(config);
return;
}
const plotDefsRaw = config.plots ?? def?.plots ?? [];
if (config.type === 'OBV') {
this._addObvIndicatorSeries(
seriesList,
seriesMeta,
config,
result,
pane,
indicatorHidden,
def,
);
} else if (DUAL_LINE_INDICATOR_TYPES.has(config.type)) {
this._addDualLinePlotSeries(
seriesList,
seriesMeta,
config,
result,
pane,
indicatorHidden,
def,
);
} else {
for (const plotDef of plotDefsRaw) {
const plotData = result.plots[plotDef.id] as PlotData | undefined;
if (!plotData || plotData.length === 0) continue;
const filtered = plotData.filter(p => p.value !== null && !isNaN(p.value));
if (filtered.length === 0) continue;
let series: ISeriesApi<SeriesType>;
const indicatorPriceFormat = priceFormatForIndicatorPane(pane);
if (plotDef.type === 'histogram') {
series = this.chart.addSeries(HistogramSeries, {
color: plotDef.color,
...(indicatorPriceFormat ? { priceFormat: indicatorPriceFormat } : {}),
}, pane);
series.setData(mapHistogramSeriesData(filtered, plotDef));
seriesMeta.push({ plotId: plotDef.id, isHistogram: true, color: plotDef.color });
} else {
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);
series = this.chart.addSeries(LineSeries, {
color: resolvePlotLineColor(plotDef.color, regPlot?.color ?? plotDef.color ?? '#2962FF'),
lineWidth: Math.max(1, plotDef.lineWidth ?? regPlot?.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 })));
seriesMeta.push({ plotId: plotDef.id, isHistogram: false, color: plotDef.color });
}
seriesList.push(series);
}
}
if (DUAL_LINE_INDICATOR_TYPES.has(config.type) && !indicatorHidden && seriesList.length > 0) {
const dualEntry: IndicatorEntry = {
id: config.id, type: config.type, seriesList, seriesMeta,
paneIndex: pane, alertLines: [], hlineRefs: [], config,
};
this._setIndicatorPlotsFullData(dualEntry, result.plots as Record<string, PlotData>, config);
}
// hlines(과열선/중앙선/침체선)는 첫 번째 시리즈에만 한 번 생성 (중복 방지)
const hlineRefs: IPriceLine[] = [];
let fillPrimitive: IndicatorFillPrimitive | undefined;
if (!indicatorHidden && seriesList.length > 0) {
const firstSeries = seriesList[0];
const hlines = config.hlines ?? def?.hlines ?? [];
// HLineStyle → lightweight-charts LineStyle 변환
const toLineStyle = (s?: string): number => {
if (s === 'solid') return LineStyle.Solid;
if (s === 'dotted') return LineStyle.Dotted;
return LineStyle.Dashed; // 기본값
};
for (const hl of hlines) {
if (hl.visible === false) continue;
const pl = firstSeries.createPriceLine({
price: hl.price,
color: hl.color,
lineWidth: Math.min(4, hl.lineWidth ?? 1) as 1|2|3|4,
lineStyle: toLineStyle(hl.lineStyle),
axisLabelVisible: false,
});
hlineRefs.push(pl);
}
// 과열선/침체선 레이블이 있는 hlines 가 있으면 음영 프리미티브 부착
const prices = hlines.filter(h => h.visible !== false).map(h => h.price);
const hasZone = hlines.some(h => {
const lbl = h.label ?? getHLineLabel(h.price, prices);
return (lbl === '과열선' || lbl === '침체선') && h.visible !== false;
});
const hasBg = config.hlinesBackground?.visible !== false;
if (hasZone || hasBg) {
fillPrimitive = new IndicatorFillPrimitive(hlines, config.hlinesBackground);
firstSeries.attachPrimitive(fillPrimitive);
}
}
const entry: IndicatorEntry = {
id: config.id, type: config.type, seriesList, seriesMeta,
paneIndex: pane, alertLines: [], hlineRefs, fillPrimitive, config,
};
const actualPane = this._readSeriesPaneIndex(seriesList[0]);
if (actualPane >= 2) entry.paneIndex = actualPane;
if (this._indicatorLoadStale(dataGenAtStart) || this.indicators.has(config.id)) {
this._discardIndicatorSeries(seriesList, { fillPrimitive });
return;
}
this.indicators.set(config.id, entry);
if (config.type === 'OBV') {
if (entry.seriesMeta.length < 2 && !indicatorHidden && def) {
await this._ensureObvSeriesForEntry(entry);
}
if (entry.seriesList.length > 0 && !indicatorHidden) {
this._setIndicatorPlotsFullData(entry, result.plots as Record<string, PlotData>, config);
}
} else if (DUAL_LINE_INDICATOR_TYPES.has(config.type)) {
await this._ensureDualLinePlotSeriesForEntry(entry);
if (entry.seriesList.length > 0 && !indicatorHidden) {
this._setIndicatorPlotsFullData(entry, result.plots as Record<string, PlotData>, config);
}
}
this.applyIndicatorStyle(config, { skipDualLineRefresh: true });
if (config.type === 'BollingerBands' && !indicatorHidden) {
attachBbBandFill(entry, config, this._shouldShowBbBandFillForConfig(config));
entry.bbFillPrimitive?.requestRefresh();
}
} catch (e) {
console.error(`[Indicator] ${config.type} error:`, e);
} finally {
if (!options?.skipLayout) {
this._finalizeIndicatorPaneLayout();
}
}
}
/** 지표 추가·제거 후 pane index 동기화 + stretch 재배분 */
private _finalizeIndicatorPaneLayout(): void {
this._removeOrphanSubPanes();
if (this._activeIndicatorPaneIndices().size > 0) {
this._resyncIndicatorPaneIndices();
this._splitCollidingIndicatorPanes();
this._consolidateMultiSeriesPanes();
this._resyncIndicatorPaneIndices();
this._applyAllSeriesPriceFormats();
} else {
this._trimTrailingEmptySubPanes();
}
const H = this._lastLayoutAvailableHeight ?? this.container.clientHeight;
this.resetPaneHeights(H > 0 ? H : undefined);
if (this._compactPaneLayout && H > 0) {
try {
const w = this.container.clientWidth;
if (w > 0) this.chart.resize(w, H);
} catch { /* ok */ }
}
this._notifyPaneLayout();
}
/**
* 고정 높이 미니 차트 — pane 비율 재계산 + LWC resize 를 한 번에 수행.
* (autoSize:false 일 때 외부 ResizeObserver 가 호출)
*/
syncLayout(width: number, height: number): void {
if (height > 0) this._lastLayoutAvailableHeight = height;
this._removeOrphanSubPanes();
if (this._activeIndicatorPaneIndices().size > 0) {
this._resyncIndicatorPaneIndices();
this._consolidateMultiSeriesPanes();
} else {
this._trimTrailingEmptySubPanes();
}
this.resetPaneHeights(height);
try {
if (width > 0 && height > 0) this.chart.resize(width, height);
} catch { /* ok */ }
this._notifyPaneLayout();
}
/** 누락된 OBV plot 시리즈만 추가 (기존 plot1 유지 등) */
private _appendMissingObvPlotSeries(
entry: IndicatorEntry,
config: IndicatorConfig,
result: { plots: Record<string, PlotData> },
pane: number,
indicatorHidden: boolean,
def: NonNullable<ReturnType<typeof getIndicatorDef>>,
): void {
const existing = new Set(entry.seriesMeta.map(m => m.plotId));
if (existing.has('plot0') && existing.has('plot1')) return;
const scratchList: ISeriesApi<SeriesType>[] = [];
const scratchMeta: IndicatorEntry['seriesMeta'] = [];
this._addObvIndicatorSeries(
scratchList, scratchMeta, config, result, pane, indicatorHidden, def,
);
for (let i = 0; i < scratchList.length; i++) {
const meta = scratchMeta[i];
if (!meta || existing.has(meta.plotId)) {
try { this.chart.removeSeries(scratchList[i]); } catch { /* ok */ }
continue;
}
entry.seriesList.push(scratchList[i]);
entry.seriesMeta.push(meta);
}
}
/** OBV plot0/plot1 누락·데이터 길이 불일치 시 재생성 또는 전체 setData */
private async _ensureObvSeriesForEntry(entry: IndicatorEntry): Promise<void> {
if (entry.type !== 'OBV' || !entry.config || this.rawBars.length === 0) return;
const config = enrichIndicatorConfig(entry.config);
const def = getIndicatorDef('OBV');
if (!def) return;
const missingPlot = !entry.seriesMeta.some(m => m.plotId === 'plot0')
|| !entry.seriesMeta.some(m => m.plotId === 'plot1');
if (missingPlot) {
const pane = entry.paneIndex ?? this._readSeriesPaneIndex(entry.seriesList[0]) ?? 2;
const hidden = !this._isIndicatorEffectivelyVisible(config);
if (entry.seriesList.length > 0) {
try {
const result = await calculateIndicator('OBV', this.rawBars.slice(), config.params ?? {});
this._appendMissingObvPlotSeries(entry, config, result, pane, hidden, def);
if (entry.seriesList.length > 0) {
this._setIndicatorPlotsFullData(entry, result.plots as Record<string, PlotData>, config);
}
entry.config = config;
} catch (e) {
console.error('[Indicator] OBV append missing error:', e);
}
return;
}
for (const s of entry.seriesList) {
try { this.chart.removeSeries(s); } catch { /* ok */ }
}
entry.seriesList = [];
entry.seriesMeta = [];
try {
const result = await calculateIndicator('OBV', this.rawBars.slice(), config.params ?? {});
this._addObvIndicatorSeries(
entry.seriesList, entry.seriesMeta, config, result, pane, hidden, def,
);
if (entry.seriesList.length > 0) {
this._setIndicatorPlotsFullData(entry, result.plots as Record<string, PlotData>, config);
}
entry.config = config;
} catch (e) {
console.error('[Indicator] OBV ensure error:', e);
}
return;
}
if (entry.seriesList.length === 0) return;
try {
const result = await calculateIndicator('OBV', this.rawBars.slice(), config.params ?? {});
this._setIndicatorPlotsFullData(entry, result.plots as Record<string, PlotData>, config);
} catch { /* ok */ }
}
/** OBV·RSI 등 본선(plot0) 시리즈 누락 시 재생성 + 전체 plot setData + 스타일(DB) 반영 */
async repairDualLineSeries(): Promise<void> {
for (const entry of this.indicators.values()) {
if (!entry.config) continue;
if (entry.type === 'OBV') {
const config = enrichIndicatorConfig(entry.config);
await this._ensureObvSeriesForEntry(entry);
this.applyIndicatorStyle(config, { skipDualLineRefresh: true });
continue;
}
if (!DUAL_LINE_INDICATOR_TYPES.has(entry.type)) continue;
const config = enrichIndicatorConfig(entry.config);
const def = getIndicatorDef(entry.type);
if (!def) continue;
if (this._dualLineSeriesOutOfSync(entry, config, def)) {
await this._rebuildDualLinePlotSeriesForEntry(entry, config, def);
} else if (entry.seriesList.length > 0 && this.rawBars.length > 0) {
try {
const result = await calculateIndicator(
config.type, this.rawBars.slice(), config.params ?? {},
);
this._setIndicatorPlotsFullData(entry, result.plots as Record<string, PlotData>, config);
} catch { /* ok */ }
}
this.applyIndicatorStyle(config, { skipDualLineRefresh: true });
}
}
/** 여러 지표 추가 후 pane 레이아웃을 한 번만 갱신 */
async addIndicatorsBatch(configs: IndicatorConfig[]): Promise<void> {
for (const config of configs) {
await this.addIndicator(config, { skipLayout: true });
}
if (configs.length > 0) {
await this.repairDualLineSeries();
this._finalizeIndicatorPaneLayout();
this._refreshAllIchimokuClouds();
this.syncChartOverlayVisibility();
}
}
/** 배치 로드 후 구름 데이터·렌더 갱신 (다른 오버레이 추가로 primitive 순서가 바뀐 경우 대비) */
private _refreshAllIchimokuClouds(): void {
for (const entry of this.indicators.values()) {
if (entry.type !== 'IchimokuCloud' || !entry.cloudPlugin || !entry.config) continue;
this._applyIchimokuCloudPlugin(entry.cloudPlugin, {}, entry.config);
entry.cloudPlugin.updateAllViews();
}
}
private _detachIndicatorEntry(id: string): boolean {
const entry = this.indicators.get(id);
if (!entry) return false;
if (entry.seriesList.length > 0) {
for (const pl of entry.hlineRefs) {
try { entry.seriesList[0].removePriceLine(pl); } catch { /* ok */ }
}
if (entry.fillPrimitive) {
try { entry.seriesList[0].detachPrimitive(entry.fillPrimitive); } catch { /* ok */ }
}
}
if (entry.cloudPlugin) {
try { this.mainSeries?.detachPrimitive(entry.cloudPlugin); } catch { /* ok */ }
for (const s of entry.seriesList) {
try { s.detachPrimitive(entry.cloudPlugin); } catch { /* ok */ }
}
}
detachBbBandFill(entry);
for (const s of entry.seriesList) { try { this.chart.removeSeries(s); } catch { /* ok */ } }
this.patternMarkers = this.patternMarkers.filter(p => p.id !== id);
this._reapplyAllPatternMarkers();
this.indicators.delete(id);
return true;
}
/**
* 순서 변경 전용 재로드 — 메인 차트·볼륨 시리즈는 유지하고 보조지표만 제거 후 재추가.
* reloadAll 과 달리 setData()를 호출하지 않으므로 메인 캔들 차트가 깜빡이지 않는다.
*/
async reloadIndicatorsOnly(inds: import('../types').IndicatorConfig[]): Promise<void> {
this.cancelPendingIndicatorUpdates();
this._dataGeneration += 1;
const loadGen = this._dataGeneration;
// ① 기존 보조지표 시리즈 모두 제거 (메인·볼륨 제외)
for (const entry of this.indicators.values()) {
if (entry.cloudPlugin) {
try { this.mainSeries?.detachPrimitive(entry.cloudPlugin); } catch { /* ok */ }
for (const s of entry.seriesList) { try { s.detachPrimitive(entry.cloudPlugin); } catch { /* ok */ } }
}
if (entry.fillPrimitive && entry.seriesList[0]) {
try { entry.seriesList[0].detachPrimitive(entry.fillPrimitive); } catch { /* ok */ }
}
detachBbBandFill(entry);
for (const s of entry.seriesList) { try { this.chart.removeSeries(s); } catch { /* ok */ } }
}
this.indicators.clear();
this.patternMarkers = this.patternMarkers.filter(() => false);
this._reapplyAllPatternMarkers();
this._removeExtraSubPanes();
// ② 새 순서로 재추가 (병합 호스트 → 멤버 순) — 레이아웃은 마지막에 한 번만
this._indRunning = false;
const sorted = sortIndicatorsForPaneLoad(inds);
for (const ind of sorted) {
if (this._indicatorLoadStale(loadGen)) break;
await this.addIndicator(ind, { skipLayout: true });
}
if (this._indicatorLoadStale(loadGen)) return;
await this.repairDualLineSeries();
this._finalizeIndicatorPaneLayout();
this._refreshAllIchimokuClouds();
this.syncChartOverlayVisibility();
}
removeIndicator(id: string): void {
this.removeIndicators([id]);
}
/** 여러 지표 제거 후 pane 레이아웃을 한 번만 갱신 (줌·스크롤 유지에 유리) */
removeIndicators(ids: string[]): void {
let any = false;
for (const id of ids) {
if (this._detachIndicatorEntry(id)) any = true;
}
if (!any) return;
this._removeOrphanSubPanes();
this._trimTrailingEmptySubPanes();
this._resyncIndicatorPaneIndices();
this._splitCollidingIndicatorPanes();
this._resyncIndicatorPaneIndices();
this.resetPaneHeights(this._lastLayoutAvailableHeight);
this._notifyPaneLayout();
}
/** 파라미터 변경된 지표만 제거 후 재추가 (전체 보조지표 재로드 회피) */
async refreshIndicators(configs: IndicatorConfig[]): Promise<void> {
if (configs.length === 0) return;
this.cancelPendingIndicatorUpdates();
/** setData 와 달리 generation 을 올리지 않음 — 배치 add 중 다른 지표 로드를 끊지 않음 */
const loadGen = this._dataGeneration;
let any = false;
for (const c of configs) {
if (this._detachIndicatorEntry(c.id)) any = true;
}
if (any) {
this._removeOrphanSubPanes();
this._trimTrailingEmptySubPanes();
}
if (this._indicatorLoadStale(loadGen)) return;
for (const config of configs) {
if (this._indicatorLoadStale(loadGen)) break;
await this.addIndicator(config, { skipLayout: true });
}
if (this._indicatorLoadStale(loadGen)) return;
await this.repairDualLineSeries();
this._finalizeIndicatorPaneLayout();
}
/**
* 파라미터만 변경된 지표 — pane·시리즈를 유지하고 setData 로 갱신.
* (detach/re-add 시 orphan pane·레이블 좌표 어긋남·이중 표시 방지)
*/
async refreshIndicatorParamsInPlace(configs: IndicatorConfig[]): Promise<void> {
if (configs.length === 0) return;
this.cancelPendingIndicatorUpdates();
const loadGen = ++this._dataGeneration;
const fallback: IndicatorConfig[] = [];
for (const raw of configs) {
const config = enrichIndicatorConfig(raw);
const def = getIndicatorDef(config.type);
if (def?.returnsMarkers) {
fallback.push(config);
continue;
}
const entry = this.indicators.get(config.id);
if (!entry || entry.seriesList.length === 0 || entry.type !== config.type) {
fallback.push(config);
continue;
}
try {
const result = await calculateIndicator(
config.type, this.rawBars.slice(), config.params ?? {},
);
if (this._indicatorLoadStale(loadGen)) return;
if (!this._canApplyPlotsInPlace(entry, result.plots)) {
fallback.push(config);
continue;
}
entry.config = config;
if (config.type === 'IchimokuCloud') {
this._setIchimokuPlotsFullData(entry, result.plots, config);
if (entry.cloudPlugin) {
this._applyIchimokuCloudPlugin(entry.cloudPlugin, result.plots, config);
}
} else {
this._setIndicatorPlotsFullData(entry, result.plots, config);
}
this.applyIndicatorStyle(config);
} catch (e) {
console.error(`[Indicator in-place] ${config.type}:`, e);
fallback.push(config);
}
}
if (this._indicatorLoadStale(loadGen)) return;
if (fallback.length > 0) {
await this.refreshIndicators(fallback);
return;
}
this._resyncIndicatorPaneIndices();
this._notifyPaneLayout();
}
private _canApplyPlotsInPlace(
entry: IndicatorEntry,
plots: Record<string, PlotData>,
): boolean {
if (entry.seriesMeta.length === 0) return false;
for (const meta of entry.seriesMeta) {
const data = plots[meta.plotId] as PlotData | undefined;
if (!data?.length) return false;
const filtered = data.filter(p => p.value !== null && !isNaN(p.value));
if (filtered.length === 0) return false;
}
return true;
}
/** 기존 시리즈에 전체 plot 데이터 재적용 (pane 유지) */
private _setIndicatorPlotsFullData(
entry: IndicatorEntry,
plots: Record<string, PlotData>,
config: IndicatorConfig,
): void {
const def = getIndicatorDef(config.type);
const plotDefs = config.plots ?? def?.plots ?? [];
const plotById = Object.fromEntries(plotDefs.map((p: PlotDef) => [p.id, p]));
for (let i = 0; i < entry.seriesList.length; i++) {
const meta = entry.seriesMeta[i];
if (!meta) continue;
const plot = plotById[meta.plotId] ?? def?.plots?.find(p => p.id === meta.plotId);
const plotData = plots[meta.plotId] as PlotData | undefined;
if (!plotData?.length) continue;
const filtered = config.type === 'OBV'
? filterObvPlotDataForChart(plotData, plots)
: plotData.filter(p => p.value !== null && !isNaN(p.value));
if (filtered.length === 0) continue;
const series = entry.seriesList[i];
if (meta.isHistogram) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(series as ISeriesApi<any>).setData(mapHistogramSeriesData(filtered, plot));
} else {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(series as ISeriesApi<any>).setData(
filtered.map(p => ({ time: p.time as Time, value: p.value })),
);
}
}
if (config.type === 'BollingerBands') {
detachBbBandFill(entry);
attachBbBandFill(entry, config);
entry.bbFillPrimitive?.requestRefresh();
}
}
private _setIchimokuPlotsFullData(
entry: IndicatorEntry,
plots: Record<string, PlotData>,
config: IndicatorConfig,
): void {
const { senkou: displacement } = resolveIchimokuDisplacements(config.params);
const laggingPeriod = (config.params?.laggingSpan2Periods as number) ?? 52;
const convPlot = (plots['plot0'] as PlotData) ?? [];
const basePlot = (plots['plot1'] as PlotData) ?? [];
for (let i = 0; i < entry.seriesList.length; i++) {
const meta = entry.seriesMeta[i];
if (!meta) continue;
const plotData = plots[meta.plotId] as PlotData | undefined;
if (!plotData?.length) continue;
const filtered = plotData.filter(p => p.value !== null && !isNaN(p.value));
if (filtered.length === 0) continue;
let seriesData: Array<{ time: Time; value: number }> =
filtered.map(p => ({ time: p.time as Time, value: p.value }));
if (meta.plotId === 'plot3') {
seriesData = seriesData.concat(
this._ichimokuFutureSpanA(convPlot, basePlot, displacement),
);
} else if (meta.plotId === 'plot4') {
seriesData = seriesData.concat(
this._ichimokuFutureSpanB(displacement, laggingPeriod),
);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(entry.seriesList[i] as ISeriesApi<any>).setData(seriesData);
}
}
/** LWC pane index ↔ entry.paneIndex 동기화 (removePane 후 어긋남 보정) */
private _resyncIndicatorPaneIndices(): void {
for (const entry of this.indicators.values()) {
const first = entry.seriesList[0];
if (!first) continue;
try {
const pi = first.getPane().paneIndex();
if (pi >= 0) entry.paneIndex = pi;
} catch { /* ok */ }
}
}
/** 메인 시리즈 마커 플러그인 해제 (setData 등 시리즈 교체 전 호출) */
private _disposeMainMarkersPlugin(): void {
if (!this.mainMarkersPlugin) return;
try { this.mainMarkersPlugin.detach(); } catch { /* ok */ }
this.mainMarkersPlugin = null;
}
private _reapplyAllPatternMarkers(): void {
if (!this.mainSeries) return;
const patternAll = this.patternMarkers.flatMap(({ markers }) =>
markers.map(m => ({
time: m.time as Time,
position: m.position,
shape: m.shape,
color: m.color,
text: m.text,
}))
);
const backtestAll = this.backtestMarkers.map(m => ({
time: m.time as Time,
position: m.position,
shape: m.shape,
color: m.color,
text: '',
}));
const liveAll = this.liveStrategyMarkers.map(m => ({
time: m.time as Time,
position: m.position,
shape: m.shape,
color: m.color,
text: '',
}));
const priceExtremeAll = this.priceExtremeMarkers.map(m => ({
time: m.time as Time,
position: m.position,
shape: m.shape,
color: m.color,
text: '',
}));
const all = [...patternAll, ...backtestAll, ...liveAll, ...priceExtremeAll];
if (this.mainMarkersPlugin) {
try {
if (this.mainMarkersPlugin.getSeries() !== this.mainSeries) {
this._disposeMainMarkersPlugin();
}
} catch {
this._disposeMainMarkersPlugin();
}
}
if (!this.mainMarkersPlugin) {
this.mainMarkersPlugin = createSeriesMarkers(this.mainSeries, all);
} else {
this.mainMarkersPlugin.setMarkers(all);
}
}
/**
* 백테스팅 결과 매수/매도 시그널을 캔들 차트에 마커로 표시.
* signals 가 빈 배열이면 기존 백테스팅 마커를 제거.
*/
setBacktestMarkers(
signals: Array<{ time: number; type: string; price: number }>,
showPrice = true,
): void {
const isBuy = (t: string) => t === 'BUY' || t === 'SHORT_EXIT';
const isSell = (t: string) => t === 'SELL' || t === 'SHORT_ENTRY' || t === 'PARTIAL_SELL';
this.backtestMarkers = signals
.filter(s => isBuy(s.type) || isSell(s.type))
.map(s => {
const buy = isBuy(s.type);
const label =
s.type === 'PARTIAL_SELL' ? '부분매도'
: buy ? '매수'
: '매도';
const text = showPrice
? `${label} : ${formatUpbitKrwPrice(s.price)}`
: label;
return {
time: s.time,
position: buy ? ('belowBar' as const) : ('aboveBar' as const),
shape: buy ? ('arrowUp' as const) : ('arrowDown' as const),
color: s.type === 'PARTIAL_SELL' ? '#FF9800' : (buy ? TRADE_BUY_COLOR : TRADE_SELL_COLOR),
text,
price: s.price,
};
});
// 시리즈 교체·detach 이후 stale 플러그인 방지 — 백테스트 마커는 항상 재생성
this._disposeMainMarkersPlugin();
this._reapplyAllPatternMarkers();
this._notifyTradeSignalLabels();
}
/** 백테스팅 마커 제거 */
clearBacktestMarkers(): void {
this.backtestMarkers = [];
this._reapplyAllPatternMarkers();
this._notifyTradeSignalLabels();
}
/**
* 실시간 전략 체크 마커를 업데이트한다.
* 호출될 때마다 기존 목록을 완전히 교체하여 LWC 에 반영한다.
*/
setLiveStrategyMarkers(
markers: Array<{ time: number; signal: 'BUY' | 'SELL'; price: number }>,
showPrice = true,
): void {
this.liveStrategyMarkers = markers.map(m => {
const buy = m.signal === 'BUY';
const label = buy ? '매수' : '매도';
const text = showPrice
? `${label} : ${formatUpbitKrwPrice(m.price)}`
: label;
return {
time: m.time,
position: buy ? ('belowBar' as const) : ('aboveBar' as const),
shape: buy ? ('arrowUp' as const) : ('arrowDown' as const),
color: buy ? TRADE_BUY_COLOR : TRADE_SELL_COLOR,
text,
price: m.price,
};
});
this._reapplyAllPatternMarkers();
this._notifyTradeSignalLabels();
}
/** 실시간 전략 마커 전체 제거 */
clearLiveStrategyMarkers(): void {
this.liveStrategyMarkers = [];
this._reapplyAllPatternMarkers();
this._notifyTradeSignalLabels();
}
/** 매수·매도 시그널 라벨 (캔버스 오버레이용) */
getTradeSignalLabels(): TradeSignalLabelItem[] {
return [
...this.backtestMarkers.map(toTradeSignalLabelItem),
...this.liveStrategyMarkers.map(toTradeSignalLabelItem),
...this.priceExtremeMarkers.map(toTradeSignalLabelItem),
];
}
/** rawBars 기준 9·20일 신고가·신저가 마커·라벨 재계산 */
private _refreshPriceExtremeMarkers(): void {
if (!this._priceExtremeLabelsVisible) {
if (this.priceExtremeMarkers.length === 0) return;
this.priceExtremeMarkers = [];
this._reapplyAllPatternMarkers();
this._notifyTradeSignalLabels();
return;
}
const next = computePriceExtremeChartMarkers(this.rawBars);
if (this._samePriceExtremeMarkers(next, this.priceExtremeMarkers)) return;
this.priceExtremeMarkers = next;
this._reapplyAllPatternMarkers();
this._notifyTradeSignalLabels();
}
/** 실시간 차트 — 9·20일 신고가·신저가 라벨·마커 표시 */
setPriceExtremeLabelsVisible(visible: boolean): void {
if (this._priceExtremeLabelsVisible === visible) return;
this._priceExtremeLabelsVisible = visible;
this._refreshPriceExtremeMarkers();
}
isPriceExtremeLabelsVisible(): boolean {
return this._priceExtremeLabelsVisible;
}
private _samePriceExtremeMarkers(
a: TradeSignalMarkerWithPrice[],
b: TradeSignalMarkerWithPrice[],
): boolean {
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) {
if (a[i].time !== b[i].time || a[i].text !== b[i].text || a[i].position !== b[i].position) {
return false;
}
}
return true;
}
subscribeTradeSignalLabels(cb: () => void): () => void {
this._tradeSignalLabelListeners.add(cb);
return () => { this._tradeSignalLabelListeners.delete(cb); };
}
private _notifyTradeSignalLabels(): void {
for (const cb of this._tradeSignalLabelListeners) cb();
}
/**
* 일목균형표 전용: 전환선·기준선·후행스팬·선행스팬A·B 라인 + 구름 플러그인을 추가합니다.
*
* ichimoku.ts 실제 plot 매핑:
* plot0 = 전환선(Tenkan) plot1 = 기준선(Kijun)
* plot2 = 후행스팬(Chikou) plot3 = 선행스팬A plot4 = 선행스팬B
*/
private async _addIchimokuSeries(
result: Awaited<ReturnType<typeof calculateIndicator>>,
pane: number,
_config: IndicatorConfig,
seriesList: ISeriesApi<SeriesType>[],
): Promise<{ cloudPlugin: IchimokuCloudPlugin; seriesMeta: IndicatorEntry['seriesMeta'] }> {
const { senkou: displacement } = resolveIchimokuDisplacements(_config.params);
const laggingPeriod = (_config.params?.laggingSpan2Periods as number) ?? 52;
const seriesMeta: IndicatorEntry['seriesMeta'] = [];
const plotInfo = [
{ id: 'plot0', title: getIchimokuPlotTitle('plot0'), color: '#26c6da', lastVal: true },
{ id: 'plot1', title: getIchimokuPlotTitle('plot1'), color: '#ef5350', lastVal: true },
{ id: 'plot2', title: getIchimokuPlotTitle('plot2'), color: '#ab47bc', lastVal: false },
{ id: 'plot3', title: getIchimokuPlotTitle('plot3'), color: '#66bb6a', lastVal: true },
{ id: 'plot4', title: getIchimokuPlotTitle('plot4'), color: '#ef9a9a', lastVal: true },
];
const convPlot = result.plots['plot0'] as PlotData ?? [];
const basePlot = result.plots['plot1'] as PlotData ?? [];
let spanASeriesRef: ISeriesApi<SeriesType> | null = null;
for (const info of plotInfo) {
const plotData = result.plots[info.id] as PlotData | undefined;
if (!plotData || plotData.length === 0) continue;
const filtered = plotData.filter(p => p.value !== null && !isNaN(p.value));
if (filtered.length === 0) continue;
let seriesData: Array<{ time: Time; value: number }> =
filtered.map(p => ({ time: p.time as Time, value: p.value }));
// 선행스팬A/B: 미래 26봉 프로젝션 추가
if (info.id === 'plot3') {
seriesData = seriesData.concat(
this._ichimokuFutureSpanA(convPlot, basePlot, displacement)
);
} else if (info.id === 'plot4') {
seriesData = seriesData.concat(
this._ichimokuFutureSpanB(displacement, laggingPeriod)
);
}
const showPriceLabel = this.shouldShowSeriesPriceLabel(_config, info.lastVal, pane);
const lineVisible = this._resolveSeriesVisible(_config, info.id);
const series = this.chart.addSeries(LineSeries, {
color: info.color,
lineWidth: 1,
priceLineVisible: false,
lastValueVisible: showPriceLabel && lineVisible,
title: showPriceLabel && lineVisible ? info.title : '',
visible: lineVisible,
}, pane);
series.setData(seriesData);
seriesList.push(series);
seriesMeta.push({ plotId: info.id, isHistogram: false, color: info.color });
if (info.id === 'plot3') spanASeriesRef = series;
}
// 구름 플러그인 (과거 + 미래 포함)
// mainSeries 에 attach: mainSeries 는 항상 visible:true 이고 메인 priceScale 을 사용하므로
// primitive 가 확실하게 렌더링된다. SpanA(plot3) 시리즈의 visibility/priceScale 변화에
// 영향을 받지 않도록 mainSeries 우선, 없으면 SpanA→첫 번째 시리즈 순으로 fallback.
const cloudPlugin = new IchimokuCloudPlugin();
const colors = resolveIchimokuCloudColors(_config.cloudColors);
cloudPlugin.setColors(colors.bullishColor, colors.bearishColor);
const cloudHost = this.mainSeries ?? spanASeriesRef ?? seriesList[0] ?? null;
if (cloudHost) {
cloudHost.attachPrimitive(cloudPlugin);
this._applyIchimokuCloudPlugin(cloudPlugin, result.plots, _config);
}
return { cloudPlugin, seriesMeta };
}
/** 선행스팬A의 미래 26봉: (전환선 + 기준선) / 2, 미래 타임스탬프로 매핑 */
private _ichimokuFutureSpanA(
convPlot: PlotData,
basePlot: PlotData,
displacement: number,
): Array<{ time: Time; value: number }> {
const N = this.rawBars.length;
if (N < 2) return [];
const interval = this.rawBars[N - 1].time - this.rawBars[N - 2].time;
const lastTime = this.rawBars[N - 1].time;
const out: Array<{ time: Time; value: number }> = [];
for (let k = 1; k <= displacement; k++) {
const srcIdx = N - displacement - 1 + k; // N-26 +k-1 → 0-based
if (srcIdx < 0 || srcIdx >= N) continue;
const conv = convPlot[srcIdx]?.value;
const base = basePlot[srcIdx]?.value;
if (conv == null || isNaN(conv) || base == null || isNaN(base)) continue;
out.push({ time: (lastTime + k * interval) as Time, value: (conv + base) / 2 });
}
return out;
}
/** 선행스팬B의 미래 26봉: donchian(laggingPeriod), 미래 타임스탬프로 매핑 */
private _ichimokuFutureSpanB(
displacement: number,
laggingPeriod: number,
): Array<{ time: Time; value: number }> {
const N = this.rawBars.length;
if (N < 2) return [];
const interval = this.rawBars[N - 1].time - this.rawBars[N - 2].time;
const lastTime = this.rawBars[N - 1].time;
const out: Array<{ time: Time; value: number }> = [];
for (let k = 1; k <= displacement; k++) {
const srcIdx = N - displacement - 1 + k;
if (srcIdx < 0 || srcIdx >= N) continue;
const val = this._donchianAt(srcIdx, laggingPeriod);
if (val === null) continue;
out.push({ time: (lastTime + k * interval) as Time, value: val });
}
return out;
}
/** 특정 바 인덱스에서 Donchian 중선 (전환선·기준선·선행스팬B 공통) */
private _donchianAt(idx: number, period: number): number | null {
const start = idx - period + 1;
if (start < 0) return null;
let hi = -Infinity, lo = Infinity;
for (let i = start; i <= idx; i++) {
if (this.rawBars[i].high > hi) hi = this.rawBars[i].high;
if (this.rawBars[i].low < lo) lo = this.rawBars[i].low;
}
return (hi + lo) / 2;
}
/**
* 일목 구름: rawBars만으로 계산 (vite dev / 프로덕션 번들 동일 결과).
* lightweight-charts-indicators 의 plot3·plot4는 서버 프로덕션 빌드에서
* spanA===spanB 로 붕괴할 수 있어 사용하지 않는다.
*/
private _buildIchimokuCloudData(
conversionPeriods: number,
basePeriods: number,
displacement: number,
laggingPeriod: number,
): IchimokuCloudPoint[] {
const N = this.rawBars.length;
const historical: IchimokuCloudPoint[] = [];
for (let i = displacement; i < N; i++) {
const srcIdx = i - displacement;
const tenkan = this._donchianAt(srcIdx, conversionPeriods);
const kijun = this._donchianAt(srcIdx, basePeriods);
if (tenkan == null || kijun == null) continue;
const spanA = (tenkan + kijun) / 2;
const spanB = this._donchianAt(srcIdx, laggingPeriod);
if (spanB == null) continue;
historical.push({ time: this.rawBars[i].time, spanA, spanB });
}
if (N < 2) return historical;
const interval = this.rawBars[N - 1].time - this.rawBars[N - 2].time;
const lastTime = this.rawBars[N - 1].time;
const future: IchimokuCloudPoint[] = [];
for (let k = 1; k <= displacement; k++) {
const srcIdx = N - displacement - 1 + k;
if (srcIdx < 0 || srcIdx >= N) continue;
const tenkan = this._donchianAt(srcIdx, conversionPeriods);
const kijun = this._donchianAt(srcIdx, basePeriods);
if (tenkan == null || kijun == null) continue;
const spanA = (tenkan + kijun) / 2;
const spanB = this._donchianAt(srcIdx, laggingPeriod);
if (spanB == null) continue;
future.push({ time: lastTime + k * interval, spanA, spanB });
}
return [...historical, ...future];
}
private _readSeriesPaneIndex(series: ISeriesApi<SeriesType> | undefined): number {
if (!series) return -1;
try {
return series.getPane().paneIndex();
} catch {
return -1;
}
}
/** 볼륨 pane(1)이 실제로 표시되는지 여부 */
private _volumePaneShown(): boolean {
return this._volumePaneEnabled && this._volumeVisible && !this._candleOnlyLayout;
}
/**
* 보조지표가 들어갈 수 있는 첫 sub-pane index.
* - 볼륨 표시: 2 (pane 1 = 볼륨)
* - 볼륨 숨김: 1 (볼륨이 없으므로 pane 1 이 곧 첫 지표 pane)
*/
private _minIndicatorSubPane(): number {
return this._volumePaneShown() ? 2 : 1;
}
/** LWC 시리즈가 실제로 붙어 있는 sub-pane index (볼륨 숨김 시 1+, 표시 시 2+) */
private _collectUsedSubPaneIndices(): Set<number> {
const minSub = this._minIndicatorSubPane();
const indices = new Set<number>();
for (const entry of this.indicators.values()) {
let pane = -1;
for (const series of entry.seriesList) {
const pi = this._readSeriesPaneIndex(series);
if (pi >= minSub) pane = Math.max(pane, pi);
}
if (pane < minSub && entry.paneIndex != null && entry.paneIndex >= minSub) {
pane = entry.paneIndex;
}
if (pane >= minSub) indices.add(pane);
}
return indices;
}
private _indicatorConfigsFromMap(): IndicatorConfig[] {
return Array.from(this.indicators.values()).map(e => e.config);
}
/**
* 병합(mergedWith)이 아닌 서로 다른 호스트가 같은 sub-pane에 붙어 있으면 분리.
* entry.paneIndex 와 실제 시리즈 pane 불일치·지표 제거 후 재추가 시 겹침 방지.
*/
private _splitCollidingIndicatorPanes(): void {
const configs = this._indicatorConfigsFromMap();
const hostsByPane = new Map<number, string[]>();
for (const [id, entry] of this.indicators) {
const hostId = getPaneHostId(id, configs);
if (id !== hostId) continue;
const def = getIndicatorDef(entry.type);
if (def?.overlay || def?.returnsMarkers || entry.seriesList.length === 0) continue;
let pi = -1;
for (const series of entry.seriesList) {
pi = Math.max(pi, this._readSeriesPaneIndex(series));
}
if (pi < 2 && entry.paneIndex != null && entry.paneIndex >= 2) pi = entry.paneIndex;
if (pi < 2) continue;
const list = hostsByPane.get(pi) ?? [];
if (!list.includes(hostId)) list.push(hostId);
hostsByPane.set(pi, list);
}
let changed = false;
for (const hostIds of hostsByPane.values()) {
if (hostIds.length <= 1) continue;
for (let i = 1; i < hostIds.length; i++) {
const hostId = hostIds[i];
const target = this._getNextSubPane();
for (const [mid, ment] of this.indicators) {
if (getPaneHostId(mid, configs) !== hostId) continue;
for (const series of ment.seriesList) {
try {
series.moveToPane(target);
} catch { /* ok */ }
}
ment.paneIndex = target;
}
changed = true;
}
}
if (changed) this._removeOrphanSubPanes();
}
/**
* 같은 지표(entry)의 모든 plot 시리즈를 반드시 한 pane 으로 통일한다.
*
* lightweight-charts 의 가격축(price scale)은 pane 단위라서, 같은 priceScaleId
* 문자열이라도 plot0/plot1 이 서로 다른 pane 에 흩어지면 서로 다른 축 객체가 되어
* 본선이 신호선과 다른 스케일로 눌리는 문제가 발생한다. 레이아웃/리사이즈 직후
* 호출해 분리를 복구한다.
*/
private _consolidateMultiSeriesPanes(): void {
for (const entry of this.indicators.values()) {
if (entry.seriesList.length < 2) continue;
const def = getIndicatorDef(entry.type);
// 오버레이(BB 등)·마커 지표는 캔들 pane(0)을 공유하므로 통일 대상에서 제외
if (def?.overlay || def?.returnsMarkers) continue;
// 서브패널 지표: 모든 plot 을 동일한 실제 지표 pane 으로 통일.
// 시리즈가 흩어졌을 때 가장 깊은 pane(=정상 배치된 plot 의 pane)을 타깃으로 삼고,
// 모두 0/1(캔들·볼륨 슬롯)에 갇혀 있으면 첫 실제 sub-pane 으로 끌어올린다.
// (볼륨 숨김 시 minSub=1 이므로 pane 1 이 정상 지표 pane 이 된다.)
const minSub = this._minIndicatorSubPane();
let target = -1;
for (const s of entry.seriesList) {
const pi = this._readSeriesPaneIndex(s);
if (pi > target) target = pi;
}
if (target < minSub) target = minSub;
let moved = false;
for (const s of entry.seriesList) {
try {
if (this._readSeriesPaneIndex(s) !== target) {
s.moveToPane(target);
moved = true;
}
} catch { /* ok */ }
}
if (!moved && entry.paneIndex === target) continue;
entry.paneIndex = target;
// 통일된 pane 에서 OBV 전용 축 옵션 재확정 (이동 중 손실 방지)
if (entry.type === 'OBV' && entry.config) {
try {
this.chart.priceScale(`obv-${entry.config.id}`, target).applyOptions({
visible: true,
scaleMargins: { top: 0.12, bottom: 0.12 },
});
} catch { /* ok */ }
}
}
}
private _getNextSubPane(): number {
const used = this._collectUsedSubPaneIndices();
let pane = this._minIndicatorSubPane();
while (used.has(pane)) pane++;
return pane;
}
/** mergedWith 가 있으면 호스트 pane, 없으면 새 sub-pane */
private _resolveSubPane(config: IndicatorConfig): number {
if (config.mergedWith) {
const host = this.indicators.get(config.mergedWith);
const minSub = this._minIndicatorSubPane();
if (host?.paneIndex != null && host.paneIndex >= minSub) {
return host.paneIndex;
}
}
return this._getNextSubPane();
}
// ─── 실시간 바 업데이트 (WebSocket 틱) ────────────────────────────────────
/**
* 현재 캔들을 틱 데이터로 갱신.
* - 메인 캔들 + 볼륨: 즉시 반영 (매 틱)
* - 보조지표 마지막 포인트: 300ms 스로틀 후 갱신 (과도한 재계산 방지)
*/
updateBar(bar: OHLCVBar): void {
if (!this.mainSeries) return;
const t = getTheme(this.currentTheme);
// 1) rawBars 업데이트
let chartTime = bar.time;
if (this.rawBars.length > 0) {
const last = this.rawBars[this.rawBars.length - 1];
if (last.time === bar.time) {
this.rawBars[this.rawBars.length - 1] = bar;
} else if (bar.time > last.time) {
this.rawBars.push(bar);
} else {
// STOMP·히스토리 시계 차이 등: 마지막 봉 OHLC만 갱신 (시간축은 유지)
chartTime = last.time;
this.rawBars[this.rawBars.length - 1] = { ...bar, time: last.time };
}
}
// 2) 메인 시리즈 즉시 갱신 (캔들 또는 라인/에리어 형식)
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(this.mainSeries as ISeriesApi<any>).update({
time: chartTime as Time,
open: bar.open, high: bar.high, low: bar.low, close: bar.close,
value: bar.close, // line/area/baseline 용 (candlestick은 무시)
});
} catch (err) {
console.error('[ChartManager] updateBar mainSeries.update 오류:', err, bar);
return;
}
// 3) 볼륨 즉시 갱신
try {
this.volumeSeries?.update({
time: chartTime as Time,
value: bar.volume,
color: bar.close >= bar.open ? `${t.upColor}88` : `${t.downColor}88`,
});
} catch { /* 볼륨 갱신 실패 무시 */ }
// 4) 보조지표 마지막 포인트 스로틀 갱신
this._scheduleIndicatorLastUpdate();
this._refreshPriceExtremeMarkers();
}
/**
* 보조지표 마지막 포인트 갱신 스케줄 (스로틀: 300ms)
*
* 동작:
* - 계산 실행 중(_indRunning)이거나 타이머 대기 중이면 pending 마킹만 하고 반환
* - 타이머 종료 후 _indRunning 가드 아래 실행 → 중복 실행 방지
* - 실행 후 pending 이 남아 있으면 즉시 재스케줄 (최신 틱 보장)
*/
private _scheduleIndicatorLastUpdate(): void {
// 이미 계산 중이면 pending 마킹 → 완료 후 자동 재실행
if (this._indRunning) {
this._pendingIndUpdate = true;
return;
}
// 타이머 대기 중이면 pending 마킹만
if (this._indUpdateTimer) {
this._pendingIndUpdate = true;
return;
}
this._pendingIndUpdate = false;
this._indUpdateTimer = setTimeout(async () => {
this._indUpdateTimer = null;
// 계산 중 플래그로 동시 실행 완전 차단
if (this._indRunning) {
this._pendingIndUpdate = true;
return;
}
this._indRunning = true;
try {
await this._updateIndicatorLastPoints();
// 실행 중 도착한 틱이 있으면 한 번 더 실행
if (this._pendingIndUpdate) {
this._pendingIndUpdate = false;
await this._updateIndicatorLastPoints();
}
} finally {
this._indRunning = false;
}
// 두 번째 실행 중 또 틱이 왔으면 다시 스케줄
if (this._pendingIndUpdate) {
this._scheduleIndicatorLastUpdate();
}
}, ChartManager.IND_THROTTLE_MS);
}
/** 모든 보조지표 시리즈의 마지막 데이터 포인트를 현재 rawBars 기준으로 재계산·갱신 */
private async _updateIndicatorLastPoints(): Promise<void> {
if (this.rawBars.length === 0) return;
const dataGenAtStart = this._dataGeneration;
// 루프 시작 전 스냅샷 고정: 루프 실행 중 rawBars 가 변경돼도 모든 지표가
// 동일한 데이터 기준으로 계산되어 지표 간 시각적 불일치를 방지한다.
const barsSnapshot = this.rawBars.slice();
for (const [, entry] of this.indicators.entries()) {
if (dataGenAtStart !== this._dataGeneration) return;
if (!entry.config) continue;
try {
if (entry.type === 'OBV') {
const missing = !entry.seriesMeta.some(m => m.plotId === 'plot0')
|| !entry.seriesMeta.some(m => m.plotId === 'plot1');
if (missing) await this._ensureObvSeriesForEntry(entry);
} else if (DUAL_LINE_INDICATOR_TYPES.has(entry.type)) {
const config = enrichIndicatorConfig(entry.config);
const def = getIndicatorDef(entry.type);
if (this._dualLineSeriesOutOfSync(entry, config, def)) {
await this._ensureDualLinePlotSeriesForEntry(entry);
}
}
if (entry.seriesList.length === 0) continue;
const { plots } = await calculateIndicator(
entry.config.type, barsSnapshot, entry.config.params ?? {}
);
if (dataGenAtStart !== this._dataGeneration) return;
// 일목균형표: 선행스팬 미래 구간 포함 전체 setData (틱 갱신 시에도 유지)
if (entry.type === 'IchimokuCloud') {
this._refreshIchimokuSeriesData(entry, plots);
continue;
}
if (entry.type === 'OBV' && entry.config) {
this._setIndicatorPlotsFullData(
entry, plots, enrichIndicatorConfig(entry.config),
);
continue;
}
this._applyLastPointsToSeries(entry, plots, /* updateFutureSpans */ false);
} catch { /* 계산 실패 무시 */ }
}
}
/**
* plots 의 마지막 데이터 포인트를 각 시리즈에 update() 합니다.
*
* ★ seriesMeta[i].plotId 를 사용해 plots 에서 정확한 데이터를 조회합니다.
* (Object.values(plots) 인덱스 의존 제거 → plot 스킵 시 인덱스 어긋남 방지)
*
* - histogram: 색상(상승/하락/양수/음수) 재계산
* - IchimokuCloud SpanA/SpanB: updateFutureSpans=true 일 때 미래 프로젝션 포함 setData()
* - null/NaN 값 방어 처리
*/
private _applyLastPointsToSeries(
entry: IndicatorEntry,
plots: Record<string, PlotData>,
updateFutureSpans: boolean,
): void {
for (let i = 0; i < entry.seriesList.length; i++) {
const meta = entry.seriesMeta[i];
if (!meta) continue;
// ★ plotId 로 정확한 플롯 데이터 조회 (인덱스 매핑 오류 방지)
const plotData = plots[meta.plotId] as PlotData | undefined;
if (!plotData?.length) continue;
const lastPoint = plotData[plotData.length - 1];
if (!lastPoint) continue;
// ── IchimokuCloud: SpanA/SpanB 미래 프로젝션 갱신 ──────────────────
if (updateFutureSpans && entry.type === 'IchimokuCloud') {
const config = entry.config;
const { senkou: disp } = resolveIchimokuDisplacements(config.params);
const lagPeriod = (config.params?.laggingSpan2Periods as number) ?? 52;
const convPlot = (plots['plot0'] as PlotData) ?? [];
const basePlot = (plots['plot1'] as PlotData) ?? [];
if (meta.plotId === 'plot3') {
const spanAData: Array<{ time: Time; value: number }> = [
...((plots['plot3'] as PlotData) ?? [])
.filter(p => p.value !== null && !isNaN(p.value))
.map(p => ({ time: p.time as Time, value: p.value })),
...this._ichimokuFutureSpanA(convPlot, basePlot, disp),
];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(entry.seriesList[i] as ISeriesApi<any>).setData(spanAData);
continue;
}
if (meta.plotId === 'plot4') {
const spanBData: Array<{ time: Time; value: number }> = [
...((plots['plot4'] as PlotData) ?? [])
.filter(p => p.value !== null && !isNaN(p.value))
.map(p => ({ time: p.time as Time, value: p.value })),
...this._ichimokuFutureSpanB(disp, lagPeriod),
];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(entry.seriesList[i] as ISeriesApi<any>).setData(spanBData);
continue;
}
}
const curVal = lastPoint.value;
// null / NaN 방어: 유효하지 않은 값은 update 생략
if (curVal === null || curVal === undefined || isNaN(curVal as number)) continue;
// ── Histogram: 색상 재계산 ──────────────────────────────────────────
if (meta.isHistogram) {
const plotDef = (entry.config?.plots ?? getIndicatorDef(entry.type)?.plots ?? [])
.find(p => p.id === meta.plotId);
const prevVal = plotData.length >= 2 ? plotData[plotData.length - 2]?.value : null;
const barColor = plotDef
? histogramBarColor(curVal as number, prevVal, plotDef)
: (lastPoint.color ?? '#26A69A88');
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(entry.seriesList[i] as ISeriesApi<any>).update({
time: lastPoint.time as Time,
value: curVal as number,
color: barColor,
});
continue;
}
// ── Line / 기타 ─────────────────────────────────────────────────────
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(entry.seriesList[i] as ISeriesApi<any>).update({
time: lastPoint.time as Time,
value: curVal as number,
});
}
if (entry.type === 'BollingerBands') {
entry.bbFillPrimitive?.requestRefresh();
}
}
/** histogram 막대 색상(상승/하락) 변경 후 전체 재색칠 */
private async _repaintHistogramSeries(indicatorId: string, config: IndicatorConfig): Promise<void> {
const entry = this.indicators.get(indicatorId);
if (!entry || this.rawBars.length === 0) return;
const plotDefs = config.plots ?? getIndicatorDef(config.type)?.plots ?? [];
const plotById = Object.fromEntries(plotDefs.map((p: PlotDef) => [p.id, p]));
try {
const { plots } = await calculateIndicator(config.type, this.rawBars.slice(), config.params ?? {});
for (let i = 0; i < entry.seriesList.length; i++) {
const meta = entry.seriesMeta[i];
if (!meta?.isHistogram) continue;
const plot = plotById[meta.plotId];
if (!plot) continue;
const plotData = plots[meta.plotId] as PlotData | undefined;
if (!plotData?.length) continue;
const filtered = plotData.filter(p => p.value !== null && !isNaN(p.value));
if (filtered.length === 0) continue;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(entry.seriesList[i] as ISeriesApi<any>).setData(mapHistogramSeriesData(filtered, plot));
}
} catch { /* ignore */ }
}
/** 일목균형표 구름 플러그인 — 색상·가시성·데이터 동기화 */
private _applyIchimokuCloudPlugin(
plugin: IchimokuCloudPlugin,
plots: Record<string, PlotData>,
config: IndicatorConfig,
): void {
const colors = resolveIchimokuCloudColors(config.cloudColors);
// bullishVisible/bearishVisible 가 둘 다 false 이면 DB 잘못된 저장값으로 간주하고
// 기본값(표시)으로 복원한다. 하나만 false 인 경우는 사용자 의도이므로 유지.
let bullishVis = colors.bullishVisible !== false;
let bearishVis = colors.bearishVisible !== false;
if (this._isCustomOverlayApplied()) {
const sel = this._getAppliedCustomOverlaySelection();
bullishVis = isCustomIchimokuCloudVisible(sel, 'bullish');
bearishVis = isCustomIchimokuCloudVisible(sel, 'bearish');
} else {
const effectiveBullish = (!bullishVis && !bearishVis) ? true : bullishVis;
const effectiveBearish = (!bullishVis && !bearishVis) ? true : bearishVis;
bullishVis = effectiveBullish;
bearishVis = effectiveBearish;
}
plugin.setColors(colors.bullishColor, colors.bearishColor);
plugin.setVisibility(bullishVis, bearishVis);
if (!this._isIndicatorEffectivelyVisible(config)) {
plugin.setCloudData([]);
return;
}
const { senkou: displacement } = resolveIchimokuDisplacements(config.params);
const conversionPeriods = (config.params?.conversionPeriods as number) ?? 9;
const basePeriods = (config.params?.basePeriods as number) ?? 26;
const laggingPeriod = (config.params?.laggingSpan2Periods as number) ?? 52;
const cloud = this._buildIchimokuCloudData(
conversionPeriods, basePeriods, displacement, laggingPeriod,
);
plugin.setCloudData(cloud.length >= 2 ? cloud : []);
}
/**
* 새 캔들을 기존 차트 우측에만 추가.
* 전체 차트를 재그리지 않고 각 시리즈에 update(lastPoint)만 호출.
*/
async appendBar(bar: OHLCVBar): Promise<void> {
if (!this.mainSeries) return;
const dataGenAtStart = this._dataGeneration;
const t = getTheme(this.currentTheme);
// 새 캔들 추가 시 이 함수 내부에서 모든 지표를 직접 재계산하므로,
// updateBar 가 예약한 스로틀 타이머를 취소해 중복 계산을 방지합니다.
this.cancelPendingIndicatorUpdates();
// rawBars에 신규 바 추가 (중복 방지)
const last = this.rawBars[this.rawBars.length - 1];
if (last && last.time === bar.time) {
this.rawBars[this.rawBars.length - 1] = bar;
} else {
this.rawBars.push(bar);
}
// 메인 시리즈에 신규 바 추가 (time > last → LWC가 우측에 append)
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(this.mainSeries as ISeriesApi<any>).update({
time: bar.time as Time,
open: bar.open, high: bar.high, low: bar.low, close: bar.close,
value: bar.close, // line/area/baseline 용 (candlestick은 무시)
});
} catch (err) {
console.error('[ChartManager] appendBar mainSeries.update 오류:', err, bar);
return;
}
// 볼륨 시리즈 추가
try {
this.volumeSeries?.update({
time: bar.time as Time,
value: bar.volume,
color: bar.close >= bar.open ? `${t.upColor}88` : `${t.downColor}88`,
});
} catch { /* 볼륨 갱신 실패 무시 */ }
// 각 보조지표의 마지막 포인트 업데이트 + 일목균형표 미래 프로젝션 갱신
// _indRunning 가드: 이 루프 실행 중 도착한 tick 은 pending 으로 대기
this._indRunning = true;
try {
// rawBars 스냅샷을 찍어 루프 도중 rawBars 가 변경돼도 일관된 계산 보장
const barsSnapshot = this.rawBars.slice();
for (const [, entry] of this.indicators.entries()) {
if (dataGenAtStart !== this._dataGeneration) return;
if (!entry.config) continue;
try {
if (entry.type === 'OBV') {
const missing = !entry.seriesMeta.some(m => m.plotId === 'plot0')
|| !entry.seriesMeta.some(m => m.plotId === 'plot1');
if (missing) await this._ensureObvSeriesForEntry(entry);
} else if (DUAL_LINE_INDICATOR_TYPES.has(entry.type)) {
const config = enrichIndicatorConfig(entry.config);
const def = getIndicatorDef(entry.type);
if (this._dualLineSeriesOutOfSync(entry, config, def)) {
await this._ensureDualLinePlotSeriesForEntry(entry);
}
}
if (entry.seriesList.length === 0) continue;
const { plots } = await calculateIndicator(
entry.config.type, barsSnapshot, entry.config.params ?? {}
);
if (dataGenAtStart !== this._dataGeneration) return;
// 일목균형표: 구름 플러그인 + SpanA/B 미래 라인 갱신
if (entry.type === 'IchimokuCloud' && entry.cloudPlugin) {
this._applyIchimokuCloudPlugin(entry.cloudPlugin, plots, entry.config);
}
if (entry.type === 'OBV' && entry.config) {
this._setIndicatorPlotsFullData(
entry, plots, enrichIndicatorConfig(entry.config),
);
continue;
}
// updateFutureSpans=true → 새 캔들 추가 시 SpanA/B setData() 전체 재설정
this._applyLastPointsToSeries(entry, plots, /* updateFutureSpans */ true);
} catch { /* 인디케이터 계산 실패 무시 */ }
}
} finally {
this._indRunning = false;
}
// appendBar 완료 후에도 tick 이 도착해 pending 이 남아있으면 즉시 갱신 스케줄
if (this._pendingIndUpdate) {
this._scheduleIndicatorLastUpdate();
}
this._refreshPriceExtremeMarkers();
}
/**
* 새 캔들 완성 후 모든 보조지표를 새 데이터로 재계산 (전체 reload).
* 심볼/타임프레임 변경 시 사용. 실시간 수신 시에는 appendBar()를 사용할 것.
*/
async recalculateIndicators(newBars: OHLCVBar[]): Promise<void> {
this.cancelPendingIndicatorUpdates();
this._dataGeneration += 1;
const loadGen = this._dataGeneration;
this.rawBars = newBars;
const configs = Array.from(this.indicators.values()).map(e => e.config);
for (const entry of this.indicators.values()) {
detachBbBandFill(entry);
for (const s of entry.seriesList) { try { this.chart.removeSeries(s); } catch { /* ok */ } }
}
this.indicators.clear();
this.patternMarkers = [];
this._reapplyAllPatternMarkers();
this._removeExtraSubPanes();
const sorted = sortIndicatorsForPaneLoad(configs);
for (const cfg of sorted) {
if (this._indicatorLoadStale(loadGen)) break;
await this.addIndicator(cfg, { skipLayout: true });
}
if (!this._indicatorLoadStale(loadGen)) {
this._finalizeIndicatorPaneLayout();
}
this._refreshPriceExtremeMarkers();
}
// ─── Alerts ──────────────────────────────────────────────────────────────
addAlert(price: number, label = ''): void {
if (!this.mainSeries) return;
const line = this.mainSeries.createPriceLine({
price, color: '#FFB300', lineWidth: 1, lineStyle: LineStyle.Dashed,
axisLabelVisible: true, title: label || `Alert ${formatUpbitKrwPrice(price)}`,
});
this.alertEntries.push({ price, label, line });
}
removeAlert(price: number): void {
const idx = this.alertEntries.findIndex(a => Math.abs(a.price - price) < 0.0001);
if (idx < 0) return;
try { this.mainSeries?.removePriceLine(this.alertEntries[idx].line); } catch { /* ok */ }
this.alertEntries.splice(idx, 1);
}
getAlerts(): Array<{ price: number; label: string }> {
return this.alertEntries.map(({ price, label }) => ({ price, label }));
}
// ─── Auto Fibonacci ──────────────────────────────────────────────────────
drawAutoFib(lookback = 50): void {
if (!this.mainSeries || this.rawBars.length === 0) return;
this._clearFibLines();
const { levels } = calcAutoFib(this.rawBars, lookback);
const COLORS: Record<string, string> = {
'0%': '#EF5350', '23.6%': '#FF9800', '38.2%': '#FFC107',
'50%': '#FFFFFF', '61.8%': '#4CAF50', '78.6%': '#2196F3',
'100%': '#9C27B0', '127.2%': '#607D8B', '161.8%': '#607D8B',
};
for (const l of levels) {
const line = this.mainSeries.createPriceLine({
price: l.price, color: COLORS[l.label] ?? '#607D8B',
lineWidth: l.label === '0%' || l.label === '100%' ? 2 : 1,
lineStyle: l.label === '50%' ? LineStyle.Dashed : LineStyle.Dotted,
axisLabelVisible: true, title: `Fib ${l.label}`,
});
this.fibLines.push({ price: l.price, label: l.label, line });
}
}
clearFib(): void { this._clearFibLines(); }
private _clearFibLines(): void {
for (const f of this.fibLines) { try { this.mainSeries?.removePriceLine(f.line); } catch { /* ok */ } }
this.fibLines = [];
}
// ─── Drawing ─────────────────────────────────────────────────────────────
setDrawingTool(tool: string): void {
this.drawingTool = tool;
this.drawingPoints = [];
if (this._clickHandler) { this.chart.unsubscribeClick(this._clickHandler); this._clickHandler = null; }
this.container.style.cursor = tool === 'cursor' ? 'default' : 'crosshair';
if (tool === 'cursor') return;
this._clickHandler = (p: MouseEventParams<Time>) => {
if (!p.time || !p.point || !this.mainSeries) return;
const price = this.mainSeries.coordinateToPrice(p.point.y);
if (price === null) return;
this.drawingPoints.push({ time: p.time, price });
if (tool === 'hline') {
const l = this.mainSeries.createPriceLine({ price, color: '#FF6D00', lineWidth: 1, lineStyle: LineStyle.Solid, axisLabelVisible: true, title: '' });
this.drawingLines.push(l);
this.drawingPoints = [];
} else if (tool === 'trendline' && this.drawingPoints.length === 2) {
const [p1, p2] = this.drawingPoints;
const l = this.mainSeries.createPriceLine({ price: (p1.price + p2.price) / 2, color: '#2962FF', lineWidth: 1, lineStyle: LineStyle.Solid, axisLabelVisible: false, title: `${formatUpbitKrwPrice(p1.price)}${formatUpbitKrwPrice(p2.price)}` });
this.drawingLines.push(l);
this.drawingPoints = [];
} else if (tool === 'measure' && this.drawingPoints.length === 2) {
const [p1, p2] = this.drawingPoints;
const pct = ((p2.price - p1.price) / p1.price * 100).toFixed(2);
this.mainSeries.createPriceLine({ price: p1.price, color: '#FFC10788', lineWidth: 1, lineStyle: LineStyle.Dashed, axisLabelVisible: true, title: '' });
this.mainSeries.createPriceLine({ price: p2.price, color: '#FFC10788', lineWidth: 1, lineStyle: LineStyle.Dashed, axisLabelVisible: true, title: `${pct}%` });
this.drawingPoints = [];
}
};
this.chart.subscribeClick(this._clickHandler);
}
clearDrawings(): void {
for (const l of this.drawingLines) { try { this.mainSeries?.removePriceLine(l); } catch { /* ok */ } }
this.drawingLines = [];
this.drawingPoints = [];
this._clearFibLines();
}
// ─── Crosshair ────────────────────────────────────────────────────────────
/**
* 차트 클릭 감지: 시리즈 근처(40px 이내)를 단일/더블 클릭 시 콜백 호출.
* - entryId: 클릭한 인디케이터 ID ('__main__' = 메인캔들, null = 빈 공간)
* - point: 차트 컨테이너 기준 픽셀 좌표
*/
/**
* 클릭/더블클릭 구독 — 네이티브 DOM 이벤트 기반.
*
* LWC click 이벤트의 params.point.y 는 pane 내부 좌표계이므로
* getPaneLayouts() 의 chart-relative topY 와 혼용하면 패인 위치를 잘못 계산함.
* 네이티브 MouseEvent.clientY 는 항상 스크린 절대 좌표이므로
* containerRect 를 빼면 chart-relative Y 를 정확히 얻을 수 있음.
*
* 패인 탐색 결과:
* pane 0 → '__main__'
* pane 1 → null (volume, 무시)
* pane 2+ → 해당 indicator ID
*/
subscribeSeriesClick(
onSingle: (entryId: string | null, point: { x: number; y: number }) => void,
onDouble: (entryId: string | null, point: { x: number; y: number }) => void,
): () => void {
let lastClickTime = 0;
let lastEntryId = '';
let singleTimer: ReturnType<typeof setTimeout> | null = null;
/** e.clientY → chart-container 상대 Y → pane 탐색 → entryId + paneTopY */
const resolveEntry = (e: MouseEvent): { entryId: string | null; paneTopY: number } => {
const rect = this.container.getBoundingClientRect();
const clickY = e.clientY - rect.top;
const layouts = this.getPaneLayouts();
const pane = layouts.find(l => clickY >= l.topY && clickY < l.topY + l.height);
if (!pane) return { entryId: null, paneTopY: 0 };
if (pane.paneIndex === 0) {
// 크로스헤어가 오버레이 지표 위에 있으면 해당 지표 반환
if (this._crosshairEntryId && this._crosshairEntryId !== '__main__') {
const entry = this.indicators.get(this._crosshairEntryId);
if (entry && entry.paneIndex === 0) {
return { entryId: this._crosshairEntryId, paneTopY: 0 };
}
}
return { entryId: '__main__', paneTopY: 0 };
}
if (pane.paneIndex === 1) return { entryId: null, paneTopY: pane.topY }; // volume
// pane 2+ → 해당 패인의 indicator 탐색
const mapping = this.getIndicatorPaneMapping();
for (const [id, pIdx] of mapping) {
if (pIdx === pane.paneIndex) return { entryId: id, paneTopY: pane.topY };
}
return { entryId: null, paneTopY: pane.topY };
};
const clickHandler = (e: MouseEvent) => {
const now = Date.now();
const { entryId, paneTopY } = resolveEntry(e);
const rect = this.container.getBoundingClientRect();
const point = { x: rect.left, y: rect.top + paneTopY };
const isDouble = now - lastClickTime < 400 && lastEntryId === (entryId ?? '');
lastClickTime = now;
lastEntryId = entryId ?? '';
if (isDouble) {
if (singleTimer) { clearTimeout(singleTimer); singleTimer = null; }
onDouble(entryId, point);
} else {
if (singleTimer) clearTimeout(singleTimer);
singleTimer = setTimeout(() => {
onSingle(entryId, point);
singleTimer = null;
}, 220);
}
};
this.container.addEventListener('click', clickHandler);
return () => {
this.container.removeEventListener('click', clickHandler);
if (singleTimer) { clearTimeout(singleTimer); singleTimer = null; }
};
}
/** @deprecated 내부용: 더 이상 subscribeSeriesClick 에서 호출하지 않음 */
private _findClickedEntryId(_params: MouseEventParams<Time>): string | null {
return null;
}
/**
* sub-pane 의 시작 pane index 반환.
* pane 0 = 메인, pane 1 = 볼륨 (고정), pane 2+ = 보조지표
*/
getSubPaneStart(): number { return 2; }
/**
* 현재 indicators Map 에서 (indicatorId → 실제 paneIndex) 매핑 반환.
* 타이밍 문제 없이 ChartManager 에 등록된 실제 값 사용.
*/
getIndicatorPaneMapping(): Map<string, number> {
const map = new Map<string, number>();
for (const [id, entry] of this.indicators) {
if (entry.paneIndex !== undefined && entry.paneIndex !== null) {
map.set(id, entry.paneIndex);
}
}
return map;
}
/** 인디케이터 메타 반환 (컨텍스트 툴바용) */
getIndicatorMeta(id: string): { type: string; config: IndicatorConfig } | null {
if (id === '__main__') return null;
const entry = this.indicators.get(id);
return entry ? { type: entry.type, config: entry.config } : null;
}
/**
* 지정 pane index 의 차트 컨테이너 기준 상단 Y 좌표 반환.
* 각 pane height 합산으로 계산. 컨테이너 기준이므로 screen 좌표로
* 변환하려면 container.getBoundingClientRect().top 을 더해야 함.
*/
getPaneTopY(paneIndex: number): number {
const layouts = this.getPaneLayouts();
return layouts.find(l => l.paneIndex === paneIndex)?.topY ?? 0;
}
/** 인디케이터가 속한 pane index 반환 */
getIndicatorPaneIndex(id: string): number {
if (id === '__main__') return 0;
return this.indicators.get(id)?.paneIndex ?? 0;
}
/**
* 차트 컨테이너 기준 각 pane 의 topY 와 height 배열 반환.
* 크로스헤어 이동 시 호출하여 최신 레이아웃 유지.
*/
/** pane 레이아웃 변경 알림 (보조지표 추가·제거·높이 재배분 후) */
private readonly _paneLayoutListeners = new Set<() => void>();
subscribePaneLayout(cb: () => void): () => void {
this._paneLayoutListeners.add(cb);
return () => { this._paneLayoutListeners.delete(cb); };
}
private _notifyPaneLayout(): void {
for (const cb of this._paneLayoutListeners) {
try { cb(); } catch { /* ok */ }
}
}
/** 가시 시간 범위 변경 알림 (커스텀 패닝·줌 등 LWC 이벤트 미발생 경로 포함) */
private readonly _viewportListeners = new Set<() => void>();
private _viewportLwcHooked = false;
private _viewportNotifyQueued = false;
private readonly _lwcViewportHandler = (): void => {
this._notifyViewport();
this._scheduleNotifyViewport();
};
private _notifyViewport(): void {
for (const cb of this._viewportListeners) {
try { cb(); } catch { /* ok */ }
}
}
/** LWC timeScale·좌표 변환 반영 후 오버레이 재그리기 (연속 패닝 중 cancel 로 redraw 누락 방지) */
private _scheduleNotifyViewport(): void {
if (this._viewportNotifyQueued) return;
this._viewportNotifyQueued = true;
requestAnimationFrame(() => {
requestAnimationFrame(() => {
this._viewportNotifyQueued = false;
this._notifyViewport();
});
});
}
private _hookViewportLwc(): void {
if (this._viewportLwcHooked) return;
this._viewportLwcHooked = true;
this.chart.timeScale().subscribeVisibleTimeRangeChange(this._lwcViewportHandler);
this.chart.timeScale().subscribeVisibleLogicalRangeChange(this._lwcViewportHandler);
}
private _unhookViewportLwc(): void {
if (!this._viewportLwcHooked) return;
this._viewportLwcHooked = false;
try { this.chart.timeScale().unsubscribeVisibleTimeRangeChange(this._lwcViewportHandler); } catch { /* ok */ }
try { this.chart.timeScale().unsubscribeVisibleLogicalRangeChange(this._lwcViewportHandler); } catch { /* ok */ }
}
/** 논리 범위 변경 + 오버레이(시간축·드로잉) 동기화 */
private _setVisibleLogicalRange(range: { from: number; to: number }): void {
try {
this.chart.timeScale().setVisibleLogicalRange(range);
this._notifyViewport();
this._scheduleNotifyViewport();
} catch { /* 데이터 로드 전 */ }
}
/** PaneLegend 등 — 보조지표 pane DOM 배치가 끝난 뒤 레이블 위치 재동기화 */
notifyPaneLayoutChanged(): void {
this._notifyPaneLayout();
}
/** 시간축 오버레이 등 — 뷰포트 동기화 강제 (패닝·스크롤 중) */
notifyViewportChanged(): void {
this._notifyViewport();
this._scheduleNotifyViewport();
}
/** visible logical range → 플롯 X (LWC 좌표 API와 동일한 선형 매핑) */
private _logicalToAxisX(logical: number, lr: { from: number; to: number }, plotWidth: number): number {
const span = lr.to - lr.from;
if (!Number.isFinite(span) || span <= 0) return 0;
return ((logical - lr.from) / span) * plotWidth;
}
/**
* 차트 컨테이너 기준 각 pane 의 topY 와 height 배열 반환.
* IPaneApi.getHTMLElement() 로 pane index ↔ 화면 위치를 직접 매칭한다.
* (tr 행 순서는 orphan 빈 pane 때문에 pane index 와 어긋날 수 있음)
*/
getPaneLayouts(): Array<{ paneIndex: number; topY: number; height: number }> {
const panes = this.chart.panes();
const containerRect = this.container.getBoundingClientRect();
const result: Array<{ paneIndex: number; topY: number; height: number }> = [];
let missing = 0;
for (const pane of panes) {
const paneIndex = pane.paneIndex();
const el = pane.getHTMLElement();
if (el) {
const rect = el.getBoundingClientRect();
result.push({
paneIndex,
topY: Math.round(rect.top - containerRect.top),
height: Math.round(rect.height),
});
} else {
missing++;
result.push({ paneIndex, topY: 0, height: 0 });
}
}
if (missing > 0) {
const fallback = this._getPaneLayoutsStretchEstimate();
for (let i = 0; i < result.length; i++) {
if (result[i].height <= 0) {
const fb = fallback.find(f => f.paneIndex === result[i].paneIndex);
if (fb) result[i] = fb;
}
}
}
const active = this._activeIndicatorPaneIndices();
if (active.size > 0) {
return result.filter(l => l.paneIndex < 2 || active.has(l.paneIndex));
}
return result;
}
/** stretchFactor 비율로 pane 위치 추정 (getHTMLElement 미준비 시) */
private _getPaneLayoutsStretchEstimate(): Array<{ paneIndex: number; topY: number; height: number }> {
const panes = this.chart.panes();
const paneCount = panes.length;
const SEP_H = 4;
const TIME_AXIS_H = 26;
const sepCount = Math.max(0, paneCount - 1);
const chartAreaH = Math.max(1, this.container.clientHeight - TIME_AXIS_H - sepCount * SEP_H);
const totalFactor = panes.reduce((s, p) => s + p.getStretchFactor(), 0) || 1;
const result: Array<{ paneIndex: number; topY: number; height: number }> = [];
let y = 0;
for (let i = 0; i < paneCount; i++) {
const h = Math.round((panes[i].getStretchFactor() / totalFactor) * chartAreaH);
result.push({ paneIndex: panes[i].paneIndex(), topY: y, height: h });
y += h + (i < paneCount - 1 ? SEP_H : 0);
}
return result;
}
/**
* 보조지표가 실제로 붙어 있는 sub-pane 레이아웃만 (pane 2+).
* orphan pane·빈 pane 은 제외해 PaneLegend 위치 오차를 방지한다.
*/
getIndicatorSubPaneLayouts(): Array<{ paneIndex: number; topY: number; height: number }> {
const MIN_H = 12;
const active = this._activeIndicatorPaneIndices();
return this.getPaneLayouts()
.filter(l => active.has(l.paneIndex) && l.height > MIN_H)
.sort((a, b) => a.topY - b.topY);
}
/**
* 보조지표 id 기준 화면 pane 위치 (시리즈가 붙은 LWC pane DOM).
* PaneLegend 레이블 위치 — pane index·tr 순서와 무관.
*/
getIndicatorPaneScreenLayout(id: string): { topY: number; height: number } | null {
const entry = this.indicators.get(id);
if (!entry || entry.paneIndex == null) return null;
/* pane 0 = 메인 캔들차트 → 제외.
* volumeVisible=false 시 볼륨 패인(1)이 없어 RSI 가 LWC pane 1 에 배치되는 경우를
* _resyncIndicatorPaneIndices() 가 entry.paneIndex=1 로 갱신하므로,
* 기존 < 2 가드 대신 === 0 가드로 완화한다. */
if (entry.paneIndex === 0) return null;
const containerRect = this.container.getBoundingClientRect();
const MIN_H = 4;
for (const series of entry.seriesList) {
try {
const pane = series.getPane();
if (pane.paneIndex() === 0) continue; // 메인 캔들 패인 제외
const el = pane.getHTMLElement();
if (!el) continue;
const rect = el.getBoundingClientRect();
const height = Math.round(rect.height);
if (height < MIN_H) continue;
return {
topY: Math.round(rect.top - containerRect.top),
height,
};
} catch {
/* try next series */
}
}
/* series.getPane().getHTMLElement() 이 null 반환 시 (compactPaneLayout 등)
* getPaneLayouts() 의 fallback 추정값으로 대체 */
const paneLayout = this.getPaneLayouts().find(l => l.paneIndex === entry.paneIndex);
if (paneLayout && paneLayout.height >= MIN_H) {
return { topY: paneLayout.topY, height: paneLayout.height };
}
return null;
}
/**
* 서브 pane(비오버레이) 인디케이터 정보 목록 반환.
* PaneLegend 컴포넌트에서 각 pane 레이블 렌더링에 사용.
*/
getIndicatorPaneInfo(): Array<{
id: string;
type: string;
config: IndicatorConfig;
paneIndex: number;
plotColors: string[];
}> {
const result = [];
for (const [id, entry] of this.indicators) {
result.push({
id,
type: entry.type,
config: entry.config,
paneIndex: entry.paneIndex ?? 0,
plotColors: this._plotOrderForEntry(entry).map(plotId => {
const meta = entry.seriesMeta.find(m => m.plotId === plotId);
return (meta?.color ?? '#888888').slice(0, 7);
}),
});
}
return result;
}
/**
* 크로스헤어 위치의 각 인디케이터 시리즈 값을
* indicatorId → number[] (plot 순서) 형태로 반환.
*/
getIndicatorValuesByIdFromParams(params: MouseEventParams<Time>): Record<string, number[]> {
const result: Record<string, number[]> = {};
for (const [id, entry] of this.indicators) {
const vals = this._indicatorValuesInPlotOrder(entry, params);
if (vals.some(v => !isNaN(v))) result[id] = vals;
}
return result;
}
getChartOverlayVisibility(): ChartOverlayVisibility {
return { ...this._chartOverlayVisibility };
}
setChartOverlayVisibility(visibility: ChartOverlayVisibility): void {
this._chartOverlayVisibility = { ...visibility };
this.syncChartOverlayVisibility();
}
setChartOverlayGroupVisible(key: ChartOverlayToggleKey, visible: boolean): void {
this._chartOverlayVisibility = {
...this._chartOverlayVisibility,
[key]: visible,
};
this.syncChartOverlayVisibility();
}
/** 사용자 hidden + 세션 오버레이 토글 — entry.config.hidden 은 사용자 의도만 반영 */
private _isIndicatorEffectivelyVisible(config: IndicatorConfig): boolean {
if (config.hidden === true) return false;
if (this._isCustomOverlayApplied() && isCustomOverlayPlotControlledType(config.type)) {
return true;
}
const overlayKey = chartOverlayKeyForType(config.type);
if (overlayKey && !this._chartOverlayVisibility[overlayKey]) return false;
return true;
}
getChartCustomOverlayActive(slot: ChartCustomOverlaySlotId = 'custom'): boolean {
return this._customOverlaySlots[slot].active;
}
getChartCustomOverlaySelection(slot: ChartCustomOverlaySlotId = 'custom'): ChartCustomOverlaySelection {
const sel = this._customOverlaySlots[slot].selection;
return {
smaPlots: { ...sel.smaPlots },
bollinger: { ...sel.bollinger },
ichimoku: { ...sel.ichimoku },
candle: sel.candle,
};
}
setChartCustomOverlaySelection(
selection: ChartCustomOverlaySelection,
slot: ChartCustomOverlaySlotId = 'custom',
): void {
const defaults = createDefaultChartCustomOverlaySelection();
this._customOverlaySlots[slot].selection = {
smaPlots: { ...defaults.smaPlots, ...selection.smaPlots },
bollinger: { ...defaults.bollinger, ...selection.bollinger },
ichimoku: { ...defaults.ichimoku, ...selection.ichimoku },
candle: selection.candle,
};
if (this._customOverlaySlots[slot].active) {
this._syncCustomOverlayVisibilityToChart();
}
}
/** 슬롯 적용 시 다른 슬롯은 자동 미적용 (Custom ↔ Custom1 상호 배타) */
setChartCustomOverlayActive(active: boolean, slot: ChartCustomOverlaySlotId = 'custom'): void {
if (this._customOverlaySlots[slot].active === active) return;
this._customOverlaySlots[slot].active = active;
if (active) {
const other: ChartCustomOverlaySlotId = slot === 'custom' ? 'custom1' : 'custom';
this._customOverlaySlots[other].active = false;
}
this._syncCustomOverlayVisibilityToChart();
}
/** Custom 적용 중 — plot·캔들·구름 가시성을 선택값과 동기화 */
private _syncCustomOverlayVisibilityToChart(): void {
this.syncChartOverlayVisibility();
if (!this._isCustomOverlayApplied()) return;
for (const entry of this.indicators.values()) {
const config = entry.config;
if (!config || !isCustomOverlayPlotControlledType(config.type)) continue;
this.applyIndicatorStyle(config);
}
}
/** custom 미적용 시 지표 설정 plotVisibility, 적용 시 custom 선택만 반영 */
private _resolveSeriesVisible(config: IndicatorConfig, plotId: string | undefined): boolean {
if (!plotId || !this._isIndicatorEffectivelyVisible(config)) return false;
if (!this._isCustomOverlayApplied()) {
return config.plotVisibility?.[plotId] !== false;
}
if (config.type === 'SMA' || config.type === 'BollingerBands' || config.type === 'IchimokuCloud') {
return isCustomPlotSelected(this._getAppliedCustomOverlaySelection(), config.type, plotId);
}
if (isMovingAverageOverlayType(config.type)) {
return config.plotVisibility?.[plotId] !== false;
}
return config.plotVisibility?.[plotId] !== false;
}
private _resolveCandleVisible(): boolean {
if (this._isCustomOverlayApplied()) {
return this._getAppliedCustomOverlaySelection().candle;
}
if (!this._chartOverlayVisibility.candle) return false;
return true;
}
private _shouldShowBbBandFillForConfig(config: IndicatorConfig): boolean {
if (!this._isIndicatorEffectivelyVisible(config)) return false;
if (!this._isCustomOverlayApplied()) {
const bg = resolveBbBandBackground(config.bandBackground);
if (!bg.visible) return false;
if (config.plotVisibility?.plot1 === false || config.plotVisibility?.plot2 === false) return false;
return true;
}
const b = this._getAppliedCustomOverlaySelection().bollinger;
return b.background && b.upper && b.lower;
}
/** 오버레이·plotVisibility 반영해 시리즈 visible 만 갱신 (플롯 정의 없어도 전체 시리즈 대상) */
private _applySeriesVisibilityToEntry(entry: IndicatorEntry): void {
const config = entry.config;
if (!config) return;
entry.seriesList.forEach((series, i) => {
const plotId = entry.seriesMeta[i]?.plotId;
const visible = this._resolveSeriesVisible(config, plotId);
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
series.applyOptions({ visible } as any);
} catch { /* ok */ }
});
}
/** 오버레이 그룹·캔들 visible — 시리즈 재생성 없이 applyOptions 만 사용 */
syncChartOverlayVisibility(): void {
if (this.mainSeries) {
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(this.mainSeries as ISeriesApi<any>).applyOptions({
visible: this._resolveCandleVisible(),
});
} catch { /* ok */ }
}
for (const entry of this.indicators.values()) {
const config = entry.config;
if (!config) continue;
const key = chartOverlayKeyForType(config.type);
if (!key) continue;
this._applySeriesVisibilityToEntry(entry);
if (entry.type === 'BollingerBands') {
detachBbBandFill(entry);
if (this._shouldShowBbBandFillForConfig(config)) {
attachBbBandFill(entry, config, true);
entry.bbFillPrimitive?.requestRefresh();
}
}
if (entry.type === 'IchimokuCloud' && entry.cloudPlugin) {
if (!this._isIndicatorEffectivelyVisible(config)) {
entry.cloudPlugin.setCloudData([]);
} else {
void calculateIndicator(
'IchimokuCloud',
this.rawBars,
config.params ?? {},
).then(({ plots }) => {
this._applyIchimokuCloudPlugin(entry.cloudPlugin!, plots, config);
}).catch(() => { /* ignore */ });
}
}
}
}
/** 인디케이터 전체 가시성 토글 (시리즈 visible 옵션 변경) */
toggleIndicatorVisible(id: string, visible: boolean): void {
const entry = this.indicators.get(id);
if (!entry) return;
for (const series of entry.seriesList) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
series.applyOptions({ visible } as any);
}
if (entry.cloudPlugin) {
// 일목균형표 구름 플러그인: 가시성은 series 통해 처리됨
}
}
/** 특정 plot 의 색상/선폭 즉시 변경 */
updateIndicatorPlotStyle(id: string, plotIndex: number, color: string, lineWidth?: number): void {
const entry = this.indicators.get(id);
if (!entry) return;
const series = entry.seriesList[plotIndex];
if (!series) return;
const opts: Record<string, unknown> = { color };
if (lineWidth != null) opts['lineWidth'] = lineWidth;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
series.applyOptions(opts as any);
}
/** 차트 설정: 캔들·보조지표 영역 가격축 라벨·설명 (동일 값이면 기존 API 호환) */
setSeriesPriceLabelsEnabled(enabled: boolean): void {
this.setCandleAreaPriceLabelsEnabled(enabled);
this.setIndicatorAreaPriceLabelsEnabled(enabled);
}
setCandleAreaPriceLabelsEnabled(enabled: boolean): void {
this._candleAreaPriceLabelsEnabled = enabled;
this._refreshPriceLabelStyles();
}
setIndicatorAreaPriceLabelsEnabled(enabled: boolean): void {
this._indicatorAreaPriceLabelsEnabled = enabled;
this._refreshPriceLabelStyles();
}
getCandleAreaPriceLabelsEnabled(): boolean {
return this._candleAreaPriceLabelsEnabled;
}
getIndicatorAreaPriceLabelsEnabled(): boolean {
return this._indicatorAreaPriceLabelsEnabled;
}
private _refreshPriceLabelStyles(): void {
for (const entry of this.indicators.values()) {
this.applyIndicatorStyle(entry.config);
}
this._applyAllSeriesPriceFormats();
}
/** 차트 설정: 거래량 pane 표시 on/off */
setVolumeVisible(visible: boolean, availableHeight?: number): void {
if (!this._volumePaneEnabled) {
this._volumeVisible = false;
return;
}
this._volumeVisible = visible;
if (this._candleOnlyLayout) return;
this.applyVolumeVisibility(visible, availableHeight);
}
getVolumeVisible(): boolean {
return this._volumeVisible;
}
private applyVolumeVisibility(visible: boolean, availableHeight?: number): void {
try {
this.volumeSeries?.applyOptions({ visible } as Parameters<ISeriesApi<SeriesType>['applyOptions']>[0]);
} catch { /* ok */ }
this.resetPaneHeights(availableHeight);
}
private shouldShowSeriesPriceLabel(
config: IndicatorConfig,
plotAllowsLastValue = true,
paneIndex = 0,
): boolean {
const areaEnabled = paneIndex < 2
? this._candleAreaPriceLabelsEnabled
: this._indicatorAreaPriceLabelsEnabled;
return areaEnabled
&& plotAllowsLastValue
&& config.lastValueVisible !== false;
}
private seriesTitleForPlot(
config: IndicatorConfig,
plot: PlotDef | undefined,
plotAllowsLastValue = true,
paneIndex = 0,
): string {
if (!this.shouldShowSeriesPriceLabel(config, plotAllowsLastValue, paneIndex)) return '';
if (config.type === 'IchimokuCloud' && plot?.id) {
return getIchimokuPlotTitle(plot.id);
}
return plot?.title ?? '';
}
/**
* 인디케이터의 스타일(색상·선폭·plotVisibility)을 in-place 로 일괄 적용.
* 시리즈를 제거·재생성하지 않으므로 pane 재번호 문제가 없음.
*/
applyIndicatorStyle(config: IndicatorConfig, options?: { skipDualLineRefresh?: boolean }): void {
config = enrichIndicatorConfig(config);
const entry = this.indicators.get(config.id);
if (!entry) return;
const prevUserHidden = entry.config?.hidden === true;
const def = getIndicatorDef(config.type);
const plotDefs = config.plots ?? def?.plots ?? [];
const plotById = Object.fromEntries(plotDefs.map((p: PlotDef) => [p.id, p]));
const indicatorVisible = this._isIndicatorEffectivelyVisible(config);
entry.seriesList.forEach((series, i) => {
let plot: PlotDef | undefined;
const pid = entry.seriesMeta[i]?.plotId;
plot = (pid ? plotById[pid] : undefined) ?? plotDefs[i];
const visible = entry.type === 'OBV'
? indicatorVisible
: this._resolveSeriesVisible(config, pid);
if (!plot) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
series.applyOptions({ visible } as any);
return;
}
const paneIdx = entry.paneIndex ?? 0;
const indicatorPriceFormat = priceFormatForIndicatorPane(paneIdx);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const opts: Record<string, unknown> = {
visible,
};
if (indicatorPriceFormat) {
opts['priceFormat'] = indicatorPriceFormat;
}
if (!entry.seriesMeta[i]?.isHistogram) {
const regPlot = def?.plots?.find(p => p.id === pid);
const defaultObvColor = pid === 'plot0' ? '#2196F3' : pid === 'plot1' ? '#FF9800' : '#2962FF';
opts['color'] = resolvePlotLineColor(
plot.color,
regPlot?.color ?? plot.color ?? (entry.type === 'OBV' ? defaultObvColor : '#aaa'),
);
opts['lineWidth'] = entry.type === 'OBV'
? ((pid === 'plot0'
? Math.max(2, plot.lineWidth ?? regPlot?.lineWidth ?? 2)
: Math.max(1, plot.lineWidth ?? regPlot?.lineWidth ?? 1)) as 1 | 2 | 3 | 4)
: Math.max(1, plot.lineWidth ?? regPlot?.lineWidth ?? 1);
opts['lineStyle'] = plotSeriesLineStyle(plot.lineStyle);
let plotAllows = true;
if (entry.type === 'IchimokuCloud') {
plotAllows = entry.seriesMeta[i]?.plotId !== 'plot2';
}
const showPriceLabel = this.shouldShowSeriesPriceLabel(config, plotAllows, paneIdx);
opts['lastValueVisible'] = showPriceLabel && visible;
opts['title'] = paneIdx < 2 && showPriceLabel && visible
? this.seriesTitleForPlot(config, plot, plotAllows, paneIdx)
: '';
} else {
opts['color'] = plot.color ?? '#aaa';
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
series.applyOptions(opts as any);
});
// ── hlines(과열선/중앙선/침체선) 동기화 ─────────────────────────────────
if (entry.seriesList.length > 0) {
const firstSeries = entry.seriesList[0];
const hlines = config.hlines ?? def?.hlines ?? [];
const toLineStyle2 = (s?: string): number => {
if (s === 'solid') return LineStyle.Solid;
if (s === 'dotted') return LineStyle.Dotted;
return LineStyle.Dashed;
};
// 기존 price line 제거
for (const pl of entry.hlineRefs) {
try { firstSeries.removePriceLine(pl); } catch { /* 이미 제거됨 */ }
}
entry.hlineRefs = [];
if (entry.fillPrimitive) {
try { firstSeries.detachPrimitive(entry.fillPrimitive); } catch { /* ok */ }
entry.fillPrimitive = undefined;
}
if (indicatorVisible) {
for (const hl of hlines) {
if (hl.visible === false) continue;
const pl = firstSeries.createPriceLine({
price: hl.price,
color: hl.color,
lineWidth: Math.min(4, hl.lineWidth ?? 1) as 1|2|3|4,
lineStyle: toLineStyle2(hl.lineStyle),
axisLabelVisible: false,
});
entry.hlineRefs.push(pl);
}
const prices = hlines.filter(h => h.visible !== false).map(h => h.price);
const hasZone = hlines.some(h => {
const lbl = h.label ?? getHLineLabel(h.price, prices);
return (lbl === '과열선' || lbl === '침체선') && h.visible !== false;
});
const hasBg = config.hlinesBackground?.visible !== false;
if (hasZone || hasBg) {
entry.fillPrimitive = new IndicatorFillPrimitive(hlines, config.hlinesBackground);
firstSeries.attachPrimitive(entry.fillPrimitive);
}
}
}
if (entry.type === 'BollingerBands') {
detachBbBandFill(entry);
if (this._shouldShowBbBandFillForConfig(config)) {
attachBbBandFill(entry, config, true);
entry.bbFillPrimitive?.requestRefresh();
}
}
if (entry.type === 'IchimokuCloud' && entry.cloudPlugin) {
void calculateIndicator(
'IchimokuCloud',
this.rawBars,
config.params ?? {},
).then(({ plots }) => {
this._applyIchimokuCloudPlugin(entry.cloudPlugin!, plots, config);
}).catch(() => { /* ignore */ });
}
const overlayKey = chartOverlayKeyForType(config.type);
const overlayOn = overlayKey == null || this._chartOverlayVisibility[overlayKey];
const toStore = { ...config };
if (overlayKey) {
if (overlayOn && config.hidden !== true) {
delete toStore.hidden;
} else if (!overlayOn && toStore.hidden === true && !prevUserHidden && config.hidden !== true) {
delete toStore.hidden;
}
}
entry.config = toStore;
if (entry.seriesMeta.some(m => m.isHistogram)) {
void this._repaintHistogramSeries(config.id, config);
}
if (DUAL_LINE_INDICATOR_TYPES.has(config.type) && !options?.skipDualLineRefresh) {
void this._ensureDualLinePlotSeriesForEntry(entry).then(() => {
if (entry.config) {
this.applyIndicatorStyle(enrichIndicatorConfig(entry.config), { skipDualLineRefresh: true });
}
});
}
if (this._candleOnlyLayout && this._isSubPaneIndicator(entry)) {
this._hideSubPaneIndicator(entry);
}
}
/** 메인 차트(캔들) 색상 스타일 즉시 업데이트 */
updateMainSeriesStyle(style: import('../types').MainChartStyle): void {
if (!this.mainSeries) return;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(this.mainSeries as ISeriesApi<any>).applyOptions({
upColor: style.upColor,
downColor: style.downColor,
borderUpColor: style.borderUpColor,
borderDownColor: style.borderDownColor,
wickUpColor: style.wickUpColor,
wickDownColor: style.wickDownColor,
});
// 볼륨 색상도 동기화
if (this.volumeSeries) {
// 볼륨은 개별 bar 색상이어서 전체 재적용이 필요
// rawBars 가 있을 때만 재색칠
if (this.rawBars.length > 0) {
this.volumeSeries.setData(
this.rawBars.map(b => ({
time: b.time as Time,
value: b.volume,
color: b.close >= b.open ? `${style.upColor}88` : `${style.downColor}88`,
}))
);
}
}
}
subscribeCrosshair(cb: (p: MouseEventParams<Time>) => void): () => void {
this.chart.subscribeCrosshairMove(cb);
return () => this.chart.unsubscribeCrosshairMove(cb);
}
/**
* 크로스헤어 자석모드 전환.
* - enabled=true → CrosshairMode.Magnet (캔들 OHLCV에 스냅)
* - enabled=false → CrosshairMode.Normal (마우스 위치 자유 이동)
*/
setCrosshairMagnet(enabled: boolean): void {
this.chart.applyOptions({
crosshair: { mode: enabled ? CrosshairMode.Magnet : CrosshairMode.Normal },
});
}
// ─── Screenshot ───────────────────────────────────────────────────────────
screenshot(): string | null {
try {
const canvas = this.chart.takeScreenshot();
return canvas.toDataURL('image/png');
} catch { return null; }
}
// ─── Layout ───────────────────────────────────────────────────────────────
fitContent(): void {
this.chart.timeScale().fitContent();
this._scheduleNotifyViewport();
}
/** 시간축 확대 (visible logical range 20% 축소) */
zoomIn(): void {
const ts = this.chart.timeScale();
const lr = ts.getVisibleLogicalRange();
if (!lr) return;
const span = lr.to - lr.from;
const center = (lr.from + lr.to) / 2;
const newSpan = Math.max(span * 0.8, 5); // 최소 5바
this._setVisibleLogicalRange({ from: center - newSpan / 2, to: center + newSpan / 2 });
}
/** 시간축 축소 (visible logical range 20% 확대) */
zoomOut(): void {
const ts = this.chart.timeScale();
const lr = ts.getVisibleLogicalRange();
if (!lr) return;
const span = lr.to - lr.from;
const center = (lr.from + lr.to) / 2;
const newSpan = span * 1.25;
this._setVisibleLogicalRange({ from: center - newSpan / 2, to: center + newSpan / 2 });
}
/** 시간축 왼쪽(과거)으로 스크롤 — visible 범위의 20%씩 이동 */
scrollLeft(): void {
const ts = this.chart.timeScale();
const lr = ts.getVisibleLogicalRange();
if (!lr) return;
const shift = (lr.to - lr.from) * 0.2;
this._setVisibleLogicalRange({ from: lr.from - shift, to: lr.to - shift });
}
/** 시간축 오른쪽(미래)으로 스크롤 — visible 범위의 20%씩 이동 */
scrollRight(): void {
const ts = this.chart.timeScale();
const lr = ts.getVisibleLogicalRange();
if (!lr) return;
const shift = (lr.to - lr.from) * 0.2;
this._setVisibleLogicalRange({ from: lr.from + shift, to: lr.to + shift });
}
/** 캔들 전용(보조지표·거래량 숨김) 레이아웃 적용 중 여부 */
private _candleOnlyLayout = false;
isCandleOnlyLayout(): boolean {
return this._candleOnlyLayout;
}
/**
* 캔들 pane(0) 플롯 영역(가격·시간축 제외) 여부.
*/
isInCandlePlotArea(chartX: number, chartY: number): boolean {
if (this.getPaneIndexAtChartY(chartY) !== 0) return false;
if (this.isOnPriceAxis(chartX, chartY) || this.isOnTimeAxis(chartY)) return false;
return true;
}
/**
* chart-container 좌표 기준 클릭 대상 (pane 0 오버레이 지표 / 메인 / 보조지표 pane).
*/
resolveEntryAtChartPoint(chartX: number, chartY: number): string | null {
const layouts = this.getPaneLayouts();
const pane = layouts.find(l => chartY >= l.topY && chartY < l.topY + l.height);
if (!pane) return null;
if (pane.paneIndex === 0) {
if (this._crosshairEntryId && this._crosshairEntryId !== '__main__') {
const entry = this.indicators.get(this._crosshairEntryId);
if (entry && entry.paneIndex === 0) return this._crosshairEntryId;
}
return '__main__';
}
if (pane.paneIndex === 1) return null;
const mapping = this.getIndicatorPaneMapping();
for (const [id, pIdx] of mapping) {
if (pIdx === pane.paneIndex) return id;
}
return null;
}
/** chart X 좌표에 가장 가까운 캔들 bar (미래 빈 구간은 null) */
private _barAtChartX(chartX: number): OHLCVBar | undefined {
const time = this.xToTime(chartX);
if (time == null || this.rawBars.length === 0) return undefined;
const exact = this.getBarAtTime(time);
if (exact) return exact;
let best = this.rawBars[0];
let bestDt = Math.abs((best.time as number) - time);
for (const b of this.rawBars) {
const dt = Math.abs((b.time as number) - time);
if (dt < bestDt) {
bestDt = dt;
best = b;
}
}
const interval = this._barIntervalSecs();
return bestDt <= interval * 0.55 ? best : undefined;
}
/**
* 캔들 pane에서 실제 메인 시리즈(캔들·라인 등) 위 더블클릭인지 판별.
* 빈 플롯 영역이면 false → 전체보기 토글 대상.
*/
isClickOnMainSeries(chartX: number, chartY: number): boolean {
if (!this.isInCandlePlotArea(chartX, chartY) || !this.mainSeries) return false;
const bar = this._barAtChartX(chartX);
if (!bar) return false;
const price = this.yToPrice(chartY);
if (price == null || !Number.isFinite(price)) return false;
const ct = this.currentChartType;
if (ct === 'line' || ct === 'area' || ct === 'baseline') {
const tol = Math.max(Math.abs(bar.close) * 0.008, (bar.high - bar.low) * 0.15, 1e-8);
return Math.abs(price - bar.close) <= tol;
}
const bodyPad = Math.max((bar.high - bar.low) * 0.12, Math.abs(bar.close) * 0.0008, 1e-8);
return price >= bar.low - bodyPad && price <= bar.high + bodyPad;
}
/**
* 캔들 pane 전체보기 — pane 0(캔들+오버레이 지표)만 전체 높이,
* 거래량·하단 보조지표 pane 은 접힘. 지표 시리즈는 제거하지 않음(복귀 시 즉시 복원).
*/
applyCandleOnlyLayout(enabled: boolean, availableHeight?: number): void {
this._candleOnlyLayout = enabled;
const panes = this.chart.panes();
if (panes.length === 0) return;
if (enabled) {
try {
this.volumeSeries?.applyOptions({
visible: false,
} as Parameters<ISeriesApi<SeriesType>['applyOptions']>[0]);
} catch { /* ok */ }
this._resetPaneHeightsCandleFullscreen(availableHeight);
this._hideAllSubPaneIndicators();
const H = availableHeight && availableHeight > 0 ? availableHeight : 0;
const W = this.container.clientWidth;
if (H > 0 && W > 0) {
try { this.chart.resize(W, H); } catch { /* ok */ }
}
} else {
this.restoreFromCandleFullscreen(availableHeight);
}
}
/**
* 캔들 전체보기 해제 — 플래그·거래량·pane 비율·지표 데이터를 정상 모드로 복원.
*/
restoreFromCandleFullscreen(availableHeight?: number): void {
this._candleOnlyLayout = false;
this.container.style.height = '';
try {
this.volumeSeries?.applyOptions({
visible: this._volumeVisible,
} as Parameters<ISeriesApi<SeriesType>['applyOptions']>[0]);
} catch { /* ok */ }
this._resetMainPricePan();
this._restoreSubPaneIndicatorVisibility();
const H = (availableHeight && availableHeight > 0)
? availableHeight
: this.container.clientHeight;
this.resetPaneHeights(H);
try {
const w = this.container.clientWidth;
if (w > 0 && H > 0) this.chart.resize(w, H);
} catch { /* ok */ }
this._scheduleIndicatorLastUpdate();
this.autoScale();
}
/** 하단 보조지표 pane 여부 (pane 0 오버레이 제외, 볼륨 숨김 시 pane 1 포함) */
private _isSubPaneIndicator(entry: IndicatorEntry): boolean {
return (entry.paneIndex ?? 0) >= this._minIndicatorSubPane();
}
/** 캔들 전체보기 — 하단 보조지표 pane 시리즈·기준선 숨김 */
private _hideSubPaneIndicator(entry: IndicatorEntry): void {
for (const series of entry.seriesList) {
try {
series.applyOptions({
visible: false,
lastValueVisible: false,
title: '',
} as Parameters<ISeriesApi<SeriesType>['applyOptions']>[0]);
} catch { /* ok */ }
}
const first = entry.seriesList[0];
if (first) {
for (const pl of entry.hlineRefs) {
try { first.removePriceLine(pl); } catch { /* ok */ }
}
entry.hlineRefs = [];
if (entry.fillPrimitive) {
try { first.detachPrimitive(entry.fillPrimitive); } catch { /* ok */ }
}
}
if (entry.cloudPlugin) {
for (const s of entry.seriesList) {
try { s.detachPrimitive(entry.cloudPlugin); } catch { /* ok */ }
}
}
detachBbBandFill(entry);
}
private _hideAllSubPaneIndicators(): void {
for (const entry of this.indicators.values()) {
if (this._isSubPaneIndicator(entry)) {
this._hideSubPaneIndicator(entry);
}
}
}
/** 캔들 전체보기 해제 — 하단 보조지표 표시·스타일 복원 */
private _restoreSubPaneIndicatorVisibility(): void {
for (const entry of this.indicators.values()) {
if (!this._isSubPaneIndicator(entry)) continue;
if (entry.cloudPlugin) {
const host = seriesByPlotId(entry, 'plot3') ?? entry.seriesList[0];
if (host) {
try { host.attachPrimitive(entry.cloudPlugin); } catch { /* ok */ }
}
}
this.applyIndicatorStyle(entry.config);
}
}
/** 실제 보조지표가 붙어 있는 pane index 집합 (2+) */
private _activeIndicatorPaneIndices(): Set<number> {
return this._collectUsedSubPaneIndices();
}
/** pane 2+ 빈 sub-pane 제거 — reloadIndicatorsOnly 전용 (모든 지표 시리즈 제거 후) */
private _removeExtraSubPanes(): void {
for (;;) {
const panes = this.chart.panes();
if (panes.length <= 2) return;
const last = panes.length - 1;
try {
this.chart.removePane(last);
} catch {
return;
}
}
}
/** 활성 지표가 없는 sub-pane 제거 (중간·상단 orphan pane 포함) */
private _removeOrphanSubPanes(): void {
const active = this._activeIndicatorPaneIndices();
for (let i = this.chart.panes().length - 1; i >= 2; i--) {
const panes = this.chart.panes();
if (i >= panes.length) continue;
const pi = panes[i].paneIndex();
if (active.has(pi)) continue;
try {
this.chart.removePane(i);
} catch {
return;
}
}
}
/** 활성 지표가 없는 맨 끝 sub-pane 만 제거 — 단일 지표 제거 시 나머지 pane 유지 */
private _trimTrailingEmptySubPanes(): void {
const active = this._activeIndicatorPaneIndices();
for (;;) {
const panes = this.chart.panes();
if (panes.length <= 2) return;
const last = panes.length - 1;
const paneIndex = panes[last].paneIndex();
if (active.has(paneIndex)) return;
try {
this.chart.removePane(last);
} catch {
return;
}
}
}
/** chart-container 기준 Y(px) → pane index (0=캔들, 1=거래량, 2+=보조지표) */
getPaneIndexAtChartY(chartY: number): number {
const layouts = this.getPaneLayouts();
const pane = layouts.find(l => chartY >= l.topY && chartY < l.topY + l.height);
return pane?.paneIndex ?? 0;
}
/** 우측 가격축 라벨 영역 클릭 여부 (LWC 축 스케일 드래그에 위임) */
isOnPriceAxis(chartX: number, chartY: number): boolean {
const paneIndex = this.getPaneIndexAtChartY(chartY);
const scaleW = this.getPriceScaleWidth(paneIndex);
if (scaleW <= 0) return false;
return chartX >= this.container.clientWidth - scaleW;
}
/** pane 우측 가격축(값 라벨) 너비 — PaneLegend 버튼 배치용 */
getPriceScaleWidth(paneIndex: number): number {
try {
const ps = paneIndex === 1
? this.chart.priceScale('volume', 1)
: this.chart.priceScale('right', paneIndex);
const scaleW = ps.width();
return scaleW > 0 ? scaleW : 56;
} catch {
return 56;
}
}
/** 캔들 pane(0) 플롯 영역 너비 (우측 가격축 제외) */
private _candlePlotWidth(): number {
const scaleW = this.getPriceScaleWidth(0);
return Math.max(40, this.container.clientWidth - scaleW);
}
/**
* 캔들 pane 하단 시간축 밴드 위치 (보조지표·거래량이 아래에 있을 때).
*/
getCandlePaneTimeAxisBand(): { topY: number; height: number; plotWidth: number } | null {
return this._getCandlePaneTimeAxisBand();
}
/** 크로스헤어 세로선 시간 — 설정된 차트 시간 포맷·시간대 적용 */
formatCrosshairAxisTime(time: Time): string {
return formatLwcTimeWithPattern(
time as number | { year: number; month: number; day: number },
this.displayTimeframe,
this.displayTimezone,
this.chartTimeFormat,
);
}
private _getCandlePaneTimeAxisBand(): { topY: number; height: number; plotWidth: number } | null {
if (!this.mainSeries || this.rawBars.length < 2) return null;
const layouts = this.getPaneLayouts();
const hasLowerPane = layouts.some(l => l.paneIndex > 0 && l.height > 8);
if (!hasLowerPane) return null;
const main = layouts.find(l => l.paneIndex === 0);
if (!main || main.height < 48) return null;
const AXIS_H = 22;
const plotWidth = Math.max(40, this.chart.timeScale().width());
return {
topY: main.topY + main.height - AXIS_H,
height: AXIS_H,
plotWidth,
};
}
/**
* 캔들 pane 하단 시간축 오버레이 — 거래량·보조지표가 있을 때 캔들 영역 바로 아래에도 시각 표시.
*/
getCandlePaneTimeAxisOverlay(): {
topY: number;
height: number;
plotWidth: number;
labels: Array<{ x: number; text: string }>;
} | null {
const band = this._getCandlePaneTimeAxisBand();
if (!band) return null;
const ts = this.chart.timeScale();
const lr = ts.getVisibleLogicalRange();
if (!lr) return null;
const { topY, height, plotWidth } = band;
const lrSpan = lr.to - lr.from;
if (!Number.isFinite(lrSpan) || lrSpan <= 0) return null;
const fmt = this.chartTimeFormat;
const tz = this.displayTimezone;
const tf = this.displayTimeframe;
const startLogical = Math.max(0, Math.ceil(lr.from));
const endLogical = Math.min(this.rawBars.length - 1, Math.floor(lr.to));
if (endLogical <= startLogical) return null;
const firstT = this.rawBars[startLogical]?.time;
const lastT = this.rawBars[endLogical]?.time;
if (!firstT || !lastT || lastT <= firstT) return null;
// 하단 네이티브 시간축과 동일하게 "정시 경계"에 눈금 배치 → x·텍스트 정렬.
// (네이티브 LWC 도 실제 바 위치에 round time 눈금을 찍는다)
const NICE_STEPS_SEC = [
60, 120, 300, 600, 900, 1800, // 분 단위
3600, 7200, 10800, 21600, 43200, // 시간 단위
86400, 172800, 604800, // 일·주 단위
];
const targetCount = Math.max(3, Math.min(10, Math.floor(plotWidth / 92)));
const visSpanSec = lastT - firstT;
let stepSec = NICE_STEPS_SEC[NICE_STEPS_SEC.length - 1];
for (const s of NICE_STEPS_SEC) {
if (visSpanSec / s <= targetCount) { stepSec = s; break; }
}
// 표시 시간대 기준 벽시계 정렬용 오프셋
const offsetSec = getTimezoneOffsetSec(firstT, tz);
const labels: Array<{ x: number; text: string }> = [];
let prevBucket: number | null = null;
let prevDayKey: number | null = null;
for (let logical = startLogical; logical <= endLogical; logical++) {
const bar = this.rawBars[logical];
if (!bar?.time) continue;
const bucket = Math.floor((bar.time + offsetSec) / stepSec);
if (bucket === prevBucket) continue; // 같은 경계 버킷 → 첫 바만 눈금
prevBucket = bucket;
const x = this._logicalToAxisX(logical, lr, plotWidth);
if (!Number.isFinite(x) || x < 12 || x > plotWidth - 12) continue;
const dayKey = Math.floor((bar.time + offsetSec) / 86400);
const isDayChange = prevDayKey !== null && dayKey !== prevDayKey;
prevDayKey = dayKey;
const tickType = stepSec >= 86400 || isDayChange
? TickMarkType.DayOfMonth
: TickMarkType.Time;
labels.push({
x,
text: formatLwcTickMarkWithPattern(bar.time as Time, tickType, tf, tz, fmt),
});
}
if (labels.length === 0) return null;
return {
topY,
height,
plotWidth,
labels,
};
}
/** 하단 시간축 영역 클릭 여부 */
isOnTimeAxis(chartY: number): boolean {
try {
const h = this.chart.timeScale().height();
return chartY >= this.container.clientHeight - h;
} catch {
return chartY >= this.container.clientHeight - 28;
}
}
/** 캔들 pane(0) 우측 가격축 라벨 영역 */
isCandlePanePriceAxis(chartX: number, chartY: number): boolean {
return this.getPaneIndexAtChartY(chartY) === 0 && this.isOnPriceAxis(chartX, chartY);
}
/**
* 캔들 pane(0) 우측 가격축 휠 — pane 높이는 유지, 플롯 내 가격 범위만 확대/축소.
* wheel up → 줌 인(캔들·그래프가 세로로 커 보임), wheel down → 줌 아웃.
*/
zoomMainPriceByWheel(deltaY: number, anchorChartY?: number): boolean {
if (!this.mainSeries || deltaY === 0) return false;
const main = this.getPaneLayouts().find(l => l.paneIndex === 0);
if (!main || main.height <= 0) return false;
let range = this._mainPriceVisibleRange ?? this._readMainPriceRange();
if (!range) return false;
const span = range.to - range.from;
if (!Number.isFinite(span) || span <= 0) return false;
const ps = this.mainSeries.priceScale();
if (!this._mainPriceVisibleRange) {
ps.setAutoScale(false);
this.mainSeries.applyOptions({ autoscaleInfoProvider: undefined });
this._mainPriceVisibleRange = { ...range };
}
const paneLocalY = anchorChartY != null
? Math.max(0, Math.min(main.height, anchorChartY - main.topY))
: main.height / 2;
const rawAnchor = this.mainSeries.coordinateToPrice(paneLocalY);
const anchorPrice = (rawAnchor != null && Number.isFinite(rawAnchor))
? rawAnchor
: (range.from + range.to) / 2;
const factor = Math.exp(deltaY * 0.0012);
const newSpan = Math.max(span * 1e-6, span * factor);
const minSpan = Math.max(Math.abs(anchorPrice) * 1e-6, 1e-8);
const clampedSpan = Math.max(minSpan, newSpan);
const ratio = Math.max(0, Math.min(1, (anchorPrice - range.from) / span));
const next = {
from: anchorPrice - ratio * clampedSpan,
to: anchorPrice + (1 - ratio) * clampedSpan,
};
this._mainPriceVisibleRange = next;
try {
ps.setVisibleRange(next);
return true;
} catch {
return false;
}
}
/**
* 마우스 드래그(px)만큼 차트를 패닝합니다.
* - deltaX: 시간축 이동 — 모든 pane(캔들·보조지표) 동시 이동
* - deltaY: 캔들 플롯 영역 — 가격 범위 이동(그래프 상하 패닝, 축 확대/축소 아님)
*/
panByPixelDelta(
deltaX: number,
deltaY: number,
options?: { allowVerticalPan?: boolean },
): void {
if (deltaX !== 0) {
const ts = this.chart.timeScale();
const lr = ts.getVisibleLogicalRange();
if (lr) {
const w = Math.max(1, ts.width());
const span = lr.to - lr.from;
const dLog = (deltaX / w) * span;
this._setVisibleLogicalRange({ from: lr.from - dLog, to: lr.to - dLog });
}
}
const allowVertical = options?.allowVerticalPan !== false;
if (deltaY !== 0 && allowVertical) {
this._panMainPriceVertical(deltaY);
}
}
/** 캔들 pane 수동 가격 범위 (세로 패닝용) */
private _mainPriceVisibleRange: { from: number; to: number } | null = null;
private _readMainPriceRange(): { from: number; to: number } | null {
if (!this.mainSeries) return null;
const ps = this.mainSeries.priceScale();
const existing = ps.getVisibleRange();
if (existing) return { from: existing.from, to: existing.to };
const main = this.getPaneLayouts().find(l => l.paneIndex === 0);
if (!main || main.height <= 0) return null;
const pTop = this.mainSeries.coordinateToPrice(main.topY);
const pBot = this.mainSeries.coordinateToPrice(main.topY + main.height);
if (pTop === null || pBot === null) return null;
return { from: Math.min(pTop, pBot), to: Math.max(pTop, pBot) };
}
private _panMainPriceVertical(deltaY: number): void {
if (!this.mainSeries) return;
const main = this.getPaneLayouts().find(l => l.paneIndex === 0);
if (!main || main.height <= 0) return;
let range = this._mainPriceVisibleRange ?? this._readMainPriceRange();
if (!range) return;
const ps = this.mainSeries.priceScale();
if (!this._mainPriceVisibleRange) {
ps.setAutoScale(false);
this.mainSeries.applyOptions({ autoscaleInfoProvider: undefined });
this._mainPriceVisibleRange = { ...range };
}
const span = range.to - range.from;
const shift = (deltaY / main.height) * span;
const next = { from: range.from + shift, to: range.to + shift };
this._mainPriceVisibleRange = next;
try {
ps.setVisibleRange(next);
this._notifyViewport();
this._scheduleNotifyViewport();
} catch { /* 범위 미설정 등 */ }
}
private _resetMainPricePan(): void {
this._mainPriceVisibleRange = null;
if (!this.mainSeries) return;
this.mainSeries.applyOptions({ autoscaleInfoProvider: undefined });
try {
this.mainSeries.priceScale().setAutoScale(true);
} catch { /* ok */ }
}
// ── 멀티차트 동기화 헬퍼 ────────────────────────────────────────────────
/**
* Crosshair sync: 특정 time 위치에 크로스헤어를 표시한다.
* - rawBars 에서 해당 time 의 bar 를 찾아 mid 가격에 수평선을 맞춤
* - 데이터가 없으면 no-op
*/
setCrosshairAtTime(time: number): void {
if (!this.mainSeries) return;
const bar = this.rawBars?.find(b => (b.time as number) === time);
if (!bar) return;
const mid = (bar.high + bar.low) / 2;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
this.chart.setCrosshairPosition(mid, time as Time, this.mainSeries as ISeriesApi<any>);
}
/** Crosshair sync: 크로스헤어 제거 */
clearCrosshairPosition(): void {
this.chart.clearCrosshairPosition();
}
/** Time sync: 현재 가시 시간 범위(unix sec) 반환 */
getVisibleTimeRange(): { from: number; to: number } | null {
const r = this.chart.timeScale().getVisibleRange();
if (!r) return null;
return { from: r.from as number, to: r.to as number };
}
/** Date range sync: 현재 가시 논리 범위(bar index) 반환 */
getVisibleLogicalRange(): { from: number; to: number } | null {
return this.chart.timeScale().getVisibleLogicalRange();
}
/**
* Time sync: 가시 시간 범위 외부 적용.
* rawBars 가 비어 있거나 LWC 내부 시간 맵이 아직 구축되지 않은 경우
* (멀티차트 전환 직후 데이터 로드 전 등) setVisibleRange 가 "Value is null" 을
* throw 하므로 안전하게 무시한다.
*/
applyVisibleTimeRange(from: number, to: number): void {
if (this.rawBars.length === 0) return;
try {
this.chart.timeScale().setVisibleRange({ from: from as Time, to: to as Time });
this._notifyViewport();
this._scheduleNotifyViewport();
} catch { /* 데이터 로딩 전 호출 무시 */ }
}
/**
* Date range sync: 가시 논리 범위 외부 적용.
* 데이터 로드 전 호출 시 LWC 가 throw 하므로 안전하게 무시한다.
*/
applyVisibleLogicalRange(from: number, to: number): void {
if (this.rawBars.length === 0) return;
this._setVisibleLogicalRange({ from, to });
}
/** Time sync: 가시 시간 범위 변화 구독. unsubscribe fn 반환 */
subscribeVisibleTimeRange(
cb: (range: { from: number; to: number } | null) => void,
): () => void {
const handler = () => cb(this.getVisibleTimeRange());
this.chart.timeScale().subscribeVisibleTimeRangeChange(handler);
return () => {
try { this.chart.timeScale().unsubscribeVisibleTimeRangeChange(handler); } catch { /* ok */ }
};
}
/** Date range sync: 가시 논리 범위 변화 구독. unsubscribe fn 반환 */
subscribeVisibleLogicalRange(
cb: (range: { from: number; to: number } | null) => void,
): () => void {
const handler = (lr: LogicalRange | null) => cb(lr ?? null);
this.chart.timeScale().subscribeVisibleLogicalRangeChange(handler);
return () => {
try { this.chart.timeScale().unsubscribeVisibleLogicalRangeChange(handler); } catch { /* ok */ }
};
}
/**
* LWC 공식 데모의 "Go to realtime" 기능
* 사용자가 과거 데이터를 탐색하다가 최신 캔들로 즉시 이동할 때 호출
* (ref: https://tradingview.github.io/lightweight-charts/tutorials/demos/realtime-updates)
*/
scrollToRealTime(): void {
this.chart.timeScale().scrollToRealTime();
this._scheduleNotifyViewport();
}
/** 줌 툴: 두 X 픽셀 위치(canvas 내부)를 시간 범위로 변환하여 확대 */
zoomToXRange(x0: number, x1: number): void {
const t0 = this.xToTime(Math.min(x0, x1));
const t1 = this.xToTime(Math.max(x0, x1));
if (t0 === null || t1 === null || t0 >= t1) return;
try {
this.chart.timeScale().setVisibleRange({ from: t0 as import('lightweight-charts').Time, to: t1 as import('lightweight-charts').Time });
this._notifyViewport();
this._scheduleNotifyViewport();
} catch { /* ok */ }
}
/**
* 연속된 바의 중앙값 간격(초)을 계산합니다.
* 일목균형표 미래 확장 시 사용.
*/
private _barIntervalSecs(): number {
const bars = this.rawBars;
if (!bars || bars.length < 2) return 60;
const intervals: number[] = [];
for (let i = Math.max(0, bars.length - 20); i < bars.length - 1; i++) {
intervals.push((bars[i + 1].time as number) - (bars[i].time as number));
}
intervals.sort((a, b) => a - b);
return intervals[Math.floor(intervals.length / 2)] ?? 60;
}
/**
* 워밍업 바를 제외한 "표시용 마지막 displayCount 바" 가 화면에 꽉 차도록
* visibleRange를 설정합니다.
*
* - rawBars 전체는 차트에 등록되어 있어 왼쪽으로 스크롤 가능
* - 초기 뷰에서는 워밍업 구간을 숨기고, 지표가 완전히 그려진 영역만 표시
* - extendFutureBars > 0 이면 우측을 그만큼 미래로 확장 (일목균형표 선행스팬용)
*
* @param displayCount 표시할 최신 바 개수 (예: 200)
* @param extendFutureBars 마지막 바 이후 추가로 보여줄 바 수 (기본 0)
*/
setInitialVisibleRange(displayCount: number, extendFutureBars = 0): void {
this._applyDefaultVisibleRange(displayCount, extendFutureBars);
}
/** 일목균형표가 활성화되어 있는지 여부 */
hasIchimoku(): boolean {
for (const [, entry] of this.indicators) {
if (entry.type === 'IchimokuCloud') return true;
}
return false;
}
/** 메인 시리즈가 데이터와 함께 초기화되었는지 확인 */
hasMainSeries(): boolean {
return this.mainSeries !== null && this.rawBars.length > 0;
}
/** 로드된 보조지표 수 (종목 전환 후 캔들만 그려진 상태 감지용) */
getIndicatorCount(): number {
return this.indicators.size;
}
/** 실제 시리즈·구름이 붙은 보조지표 수 (부분 로드·중단 감지용) */
getLoadedIndicatorCount(): number {
return this.getLoadedIndicatorIds().size;
}
/** 차트에 실제로 그려진 보조지표 id (부분 로드·증분 복구용) */
getLoadedIndicatorIds(): Set<string> {
const ids = new Set<string>();
for (const entry of this.indicators.values()) {
if (entry.config?.hidden === true) continue;
if (entry.cloudPlugin || entry.seriesList.length > 0) ids.add(entry.id);
}
return ids;
}
/** 지표에 붙은 plotId 목록 (OBV plot0/plot1 누락 진단용) */
getIndicatorSeriesPlotIds(id: string): string[] {
return this.indicators.get(id)?.seriesMeta.map(m => m.plotId) ?? [];
}
/** 현재 로드된 rawBars 개수 반환 (데이터 로드 여부 확인용) */
getRawBarsLength(): number {
return this.rawBars.length;
}
/** 현재 차트에 로드된 캔들 스냅샷 (과거 추가 로드·백테스트 재계산용) */
getRawBars(): OHLCVBar[] {
return this.rawBars.slice();
}
/** 두 시각(초) 사이에 포함된 봉 개수 — 기간(날짜 범위) 측정 라벨용 */
countBarsBetweenTime(t0: number, t1: number): number {
const lo = Math.min(t0, t1);
const hi = Math.max(t0, t1);
let n = 0;
for (const b of this.rawBars) {
if (b.time >= lo && b.time <= hi) n++;
}
return n;
}
/** 가장 오래된 bar 의 Unix 타임스탬프(초) 반환. 데이터 없으면 null. */
getOldestBarTime(): number | null {
return this.rawBars.length > 0 ? this.rawBars[0].time as number : null;
}
/**
* 더 오래된 bar 를 앞에 추가하고 시리즈·보조지표를 갱신합니다.
*
* - 이미 존재하는 시각보다 오래된 bar 만 실제로 추가합니다.
* - 현재 가시 범위(logical range)를 추가된 bar 수만큼 우측으로 이동시켜
* 사용자가 보고 있던 구간이 유지되도록 합니다.
* - 모든 보조지표를 전체 데이터 기준으로 재계산합니다.
*/
async prependBars(olderBars: OHLCVBar[]): Promise<number> {
if (!this.mainSeries || olderBars.length === 0) return 0;
const firstExistingTime = this.rawBars[0]?.time as number | undefined;
const newBars = firstExistingTime !== undefined
? olderBars.filter(b => (b.time as number) < firstExistingTime)
: olderBars;
if (newBars.length === 0) return 0;
// 현재 가시 논리 범위 저장 (prepend 후 복원용)
const logicalRange = this.chart.timeScale().getVisibleLogicalRange();
this.rawBars = [...newBars, ...this.rawBars];
const t = getTheme(this.currentTheme);
const ct = this.currentChartType;
// 메인 시리즈 전체 데이터 갱신
if (ct === 'line' || ct === 'area' || ct === 'baseline') {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(this.mainSeries as ISeriesApi<any>).setData(
this.rawBars.map(b => ({ time: b.time as Time, value: b.close }))
);
} else {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(this.mainSeries as ISeriesApi<any>).setData(
this.rawBars.map(b => ({
time: b.time as Time,
open: b.open, high: b.high, low: b.low, close: b.close,
}))
);
}
// 볼륨 시리즈 전체 데이터 갱신
this.volumeSeries?.setData(
this.rawBars.map(b => ({
time: b.time as Time,
value: b.volume,
color: b.close >= b.open ? `${t.upColor}88` : `${t.downColor}88`,
}))
);
// 가시 논리 범위 복원: 앞에 추가된 bar 수만큼 오른쪽으로 이동
if (logicalRange) {
this._setVisibleLogicalRange({
from: logicalRange.from + newBars.length,
to: logicalRange.to + newBars.length,
});
}
// 보조지표 전체 재계산 (새 데이터가 왼쪽에 추가됐으므로 전체 갱신 필요)
await this._refreshAllIndicatorsData();
this._refreshPriceExtremeMarkers();
return newBars.length;
}
/** 모든 보조지표를 현재 rawBars 기준으로 전체 재계산하고 시리즈 데이터를 교체합니다. */
private async _refreshAllIndicatorsData(): Promise<void> {
if (this.rawBars.length === 0) return;
const bars = this.rawBars.slice();
// 패턴 마커 초기화 후 재구성
this.patternMarkers = [];
for (const [, entry] of this.indicators.entries()) {
if (!entry.config) continue;
try {
const result = await calculateIndicator(entry.config.type, bars, entry.config.params ?? {});
const { plots } = result;
// 패턴 마커 지표 (seriesList 가 비어 있음)
if (entry.seriesList.length === 0) {
if (result.markers?.length) {
this.patternMarkers.push({ id: entry.id, markers: result.markers });
}
continue;
}
// 일목균형표: 미래 프로젝션 포함 특별 처리
if (entry.type === 'IchimokuCloud') {
this._refreshIchimokuSeriesData(entry, plots);
continue;
}
if (entry.type === 'OBV' && entry.config) {
this._setIndicatorPlotsFullData(
entry, plots, enrichIndicatorConfig(entry.config),
);
continue;
}
// 일반 지표: 각 시리즈에 전체 데이터 setData
for (let i = 0; i < entry.seriesList.length; i++) {
const series = entry.seriesList[i];
const meta = entry.seriesMeta[i];
if (!meta) continue;
const plotData = plots[meta.plotId] as PlotData | undefined;
if (!plotData?.length) continue;
const filtered = plotData.filter(p => p.value !== null && !isNaN(p.value));
if (filtered.length === 0) continue;
if (meta.isHistogram) {
const plotDef = (entry.config?.plots ?? getIndicatorDef(entry.type)?.plots ?? [])
.find(p => p.id === meta.plotId) ?? { id: meta.plotId, title: '', color: meta.color, type: 'histogram' as const };
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(series as ISeriesApi<any>).setData(mapHistogramSeriesData(filtered, plotDef));
} else {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(series as ISeriesApi<any>).setData(
filtered.map(p => ({ time: p.time as Time, value: p.value }))
);
}
}
if (entry.type === 'BollingerBands') {
entry.bbFillPrimitive?.requestRefresh();
}
} catch { /* 계산 실패 무시 */ }
}
this._reapplyAllPatternMarkers();
}
/**
* 일목균형표 시리즈 데이터를 미래 프로젝션 포함하여 전체 갱신합니다.
* (prependBars / _refreshAllIndicatorsData 전용)
*/
private _refreshIchimokuSeriesData(
entry: IndicatorEntry,
plots: Record<string, PlotData>,
): void {
const { senkou: displacement } = resolveIchimokuDisplacements(entry.config?.params);
const laggingPeriod = (entry.config?.params?.laggingSpan2Periods as number) ?? 52;
const convPlot = (plots['plot0'] as PlotData) ?? [];
const basePlot = (plots['plot1'] as PlotData) ?? [];
for (let i = 0; i < entry.seriesList.length; i++) {
const series = entry.seriesList[i];
const meta = entry.seriesMeta[i];
if (!meta) continue;
const plotData = (plots[meta.plotId] as PlotData) ?? [];
const filtered = plotData.filter(p => p.value !== null && !isNaN(p.value));
let seriesData: Array<{ time: Time; value: number }> =
filtered.map(p => ({ time: p.time as Time, value: p.value }));
// 선행스팬A·B: 미래 26봉 프로젝션 추가
if (meta.plotId === 'plot3') {
seriesData = seriesData.concat(this._ichimokuFutureSpanA(convPlot, basePlot, displacement));
} else if (meta.plotId === 'plot4') {
seriesData = seriesData.concat(this._ichimokuFutureSpanB(displacement, laggingPeriod));
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(series as ISeriesApi<any>).setData(seriesData);
}
if (entry.cloudPlugin && entry.config) {
this._applyIchimokuCloudPlugin(entry.cloudPlugin, plots, entry.config);
}
}
/**
* 특정 time(Unix 초)에 해당하는 bar를 rawBars에서 반환합니다.
* 실시간 tick/appendBar 로 갱신된 최신 OHLCV 값을 포함합니다.
* 크로스헤어 레전드 표시 등 외부에서 최신 bar 데이터가 필요할 때 사용합니다.
*/
getBarAtTime(time: number): OHLCVBar | undefined {
return this.rawBars.find(b => (b.time as number) === time);
}
setLogScale(enabled: boolean): void {
this.mainSeries?.applyOptions({ priceScale: { mode: enabled ? 1 : 0 } } as Parameters<ISeriesApi<SeriesType>['applyOptions']>[0]);
}
/** 퍼센트(%) 스케일 토글 — 가격 대신 % 변동률로 Y축 표시 */
setPercentScale(enabled: boolean): void {
// PriceScaleMode: 0=Normal, 1=Logarithmic, 2=Percentage, 3=IndexedTo100
this.mainSeries?.priceScale().applyOptions({ mode: enabled ? 2 : 0 });
}
/** 차트 그리드 라인 표시/숨김 */
setGrid(enabled: boolean): void {
this.chart.applyOptions({
grid: {
vertLines: { color: enabled ? getTheme(this.currentTheme).gridColor : 'transparent' },
horzLines: { color: enabled ? getTheme(this.currentTheme).gridColor : 'transparent' },
},
});
}
/** 가격 스케일 자동(auto) 맞춤 — fitContent와 달리 시간축은 변경하지 않음 */
autoScale(): void {
this._resetMainPricePan();
this.mainSeries?.priceScale().setAutoScale(true);
}
/**
* 느린 네트워크·레이아웃 지연 후 가격축이 0~수억처럼 비정상인지 감지.
* (마지막 종가가 가시 범위 밖이거나, 범위 하한이 0 이하인 경우)
*/
isMainPriceScaleLikelyCorrupt(): boolean {
if (!this.mainSeries || this.rawBars.length < 2) return false;
const lastClose = this.rawBars[this.rawBars.length - 1].close;
if (!Number.isFinite(lastClose) || lastClose <= 0) return false;
const main = this.getPaneLayouts().find(l => l.paneIndex === 0);
if (!main || main.height < 24) return true;
try {
const topPrice = this.mainSeries.coordinateToPrice(4);
const bottomPrice = this.mainSeries.coordinateToPrice(main.height - 4);
if (topPrice == null || bottomPrice == null) return false;
const lo = Math.min(topPrice, bottomPrice);
const hi = Math.max(topPrice, bottomPrice);
if (lo <= 0) return true;
if (lastClose < lo * 0.5 || lastClose > hi * 2) return true;
} catch {
return false;
}
return false;
}
/**
* 크로스헤어 파라미터에서 보조지표 시리즈 값 추출
* { indicatorType → [plot0 값, plot1 값, ...] }
*/
getIndicatorValuesFromParams(params: MouseEventParams<Time>): Record<string, number[]> {
const result: Record<string, number[]> = {};
for (const [, entry] of this.indicators) {
const values: number[] = [];
for (const series of entry.seriesList) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const d = params.seriesData.get(series) as any;
const v = d != null
? (typeof d.value === 'number' ? d.value : d.close ?? NaN)
: NaN;
values.push(v);
}
if (values.some(v => !isNaN(v))) {
result[entry.type] = values;
}
}
return result;
}
resize(w: number, h: number): void { this.chart.resize(w, h); }
// ─── Compare overlay ──────────────────────────────────────────────────────
addCompareSeries(symbol: string, bars: OHLCVBar[], color: string): void {
if (this.compareSeriesMap.has(symbol)) return;
const s = this.chart.addSeries(LineSeries, { color, lineWidth: 2, priceLineVisible: false });
const base = bars[0]?.close ?? 1;
s.setData(bars.map(b => ({ time: b.time as Time, value: (b.close / base - 1) * 100 })));
this.compareSeriesMap.set(symbol, s);
}
removeCompareSeries(symbol: string): void {
const s = this.compareSeriesMap.get(symbol);
if (s) { try { this.chart.removeSeries(s); } catch { /* ok */ } this.compareSeriesMap.delete(symbol); }
}
// ─── Coordinate conversion (for drawing canvas) ───────────────────────────
timeToX(time: number): number | null {
const coord = this.chart.timeScale().timeToCoordinate(time as Time);
return coord === null ? null : coord;
}
priceToY(price: number): number | null {
const coord = this.mainSeries?.priceToCoordinate(price);
return coord === null || coord === undefined ? null : coord;
}
xToTime(x: number): number | null {
const t = this.chart.timeScale().coordinateToTime(x);
return t === null ? null : (t as number);
}
/**
* 전략 평가 — 클라이언트 좌표 → rawBars 내 가장 가까운 봉 index.
* 캔들·보조지표 pane 클릭 허용, 가격축·시간축·플롯 밖 제외.
*/
resolveNearestBarIndexAtClientPoint(clientX: number, clientY: number): number {
const rect = this.container.getBoundingClientRect();
const chartX = clientX - rect.left;
const chartY = clientY - rect.top;
if (
chartX < 0 || chartY < 0
|| chartX > this.container.clientWidth
|| chartY > this.container.clientHeight
) {
return -1;
}
if (this.isOnPriceAxis(chartX, chartY) || this.isOnTimeAxis(chartY)) return -1;
const layouts = this.getPaneLayouts();
const inPane = layouts.some(
l => chartY >= l.topY && chartY < l.topY + l.height && l.height >= 4,
);
if (!inPane || this.rawBars.length === 0) return -1;
const bar = this._barAtChartX(chartX);
if (!bar) return -1;
const idx = this.rawBars.findIndex(b => (b.time as number) === (bar.time as number));
return idx >= 0 ? idx : -1;
}
/** LWC 클릭 — 전략선택봉 이동 (캔들 pane 클릭) */
subscribeBarSelectClick(onSelect: (barIndex: number) => void): () => void {
const handler = (param: MouseEventParams<Time>) => {
if (param.time == null || this.rawBars.length === 0) return;
const time = param.time as number;
let idx = this.rawBars.findIndex(b => (b.time as number) === time);
if (idx < 0) {
let bestIdx = 0;
let bestDist = Number.POSITIVE_INFINITY;
for (let i = 0; i < this.rawBars.length; i++) {
const dist = Math.abs((this.rawBars[i].time as number) - time);
if (dist < bestDist) {
bestDist = dist;
bestIdx = i;
}
}
idx = bestIdx;
}
if (idx >= 0) onSelect(idx);
};
this.chart.subscribeClick(handler);
return () => {
try {
this.chart.unsubscribeClick(handler);
} catch {
/* ok */
}
};
}
yToPrice(y: number): number | null {
const p = this.mainSeries?.coordinateToPrice(y);
return p === null || p === undefined ? null : p;
}
/**
* 우클릭 매수·매도: 캔들 pane(0) 클릭 좌표 → 해당 Y축 가격.
* 가격축·시간축·거래량·보조지표 pane 클릭은 null.
*/
getTradePriceAtChartPoint(chartX: number, chartY: number): number | null {
if (!this.mainSeries) return null;
if (this.isOnPriceAxis(chartX, chartY) || this.isOnTimeAxis(chartY)) return null;
if (this.getPaneIndexAtChartY(chartY) !== 0) return null;
const layout = this.getPaneLayouts().find(l => l.paneIndex === 0);
if (!layout) return null;
const paneLocalY = chartY - layout.topY;
const p = this.mainSeries.coordinateToPrice(paneLocalY);
if (p == null || !Number.isFinite(p) || p <= 0) return null;
return p;
}
/** 가시 시간 범위 변경 구독 (LWC 네이티브 스크롤 + 커스텀 패닝). unsubscribe fn 반환 */
subscribeViewport(cb: () => void): () => void {
this._viewportListeners.add(cb);
this._hookViewportLwc();
return () => {
this._viewportListeners.delete(cb);
if (this._viewportListeners.size === 0) this._unhookViewportLwc();
};
}
/**
* Pane 높이를 아래 규칙에 따라 재배분하고 **필요한 총 높이**를 반환합니다.
*
* ┌────────────────────────────────────────────────────────────┐
* │ 보조지표 N개 │ 캔들 차트 │ 보조지표 영역 │
* ├────────────────┼───────────────┼────────────────────────────┤
* │ 0 │ H - 60 │ - │
* │ 1 ~ 3 │ H/2 │ H/2 를 N 등분 │
* │ 4+ │ max(H/3, min) │ 나머지를 N 등분 │
* │ (화면 초과 시) │ ↑ 고정 │ 각 80px 최소 → 스크롤 │
* └────────────────────────────────────────────────────────────┘
*
* - 거래량 pane (index 1): 항상 60px 고정
* - 캔들 차트 최소 높이: H / 3 (절대 그 이하로 줄지 않음)
* - 반환값 > 현재 컨테이너 높이 → 호출자가 스크롤 컨테이너 확장 필요
*/
/**
* Pane 높이를 **setStretchFactor** 기반으로 재배분합니다.
*
* setHeight() 는 내부적으로 현재 LWC 렌더링 높이 기준의 stretch factor 로 변환되어
* 시간봉 변경 등 리렌더링 직후에는 비율이 틀어지는 문제가 있습니다.
* setStretchFactor(0~1) 는 차트 전체 높이를 1 로 놓은 절대 비율이므로 항상 정확합니다.
*
* ┌────────────────┬──────────────────┬─────────────────────────────┐
* │ 보조지표 N 개 │ 캔들 비율 │ 보조지표 비율 │
* ├────────────────┼──────────────────┼─────────────────────────────┤
* │ 0 │ 1 - vol_frac │ - │
* │ 1 ~ 3 │ 0.5 │ (0.5 - vol_frac) / N 각각 │
* │ 4 + │ 최소 1/3 │ 나머지 균등 │
* └────────────────┴──────────────────┴─────────────────────────────┘
*
* @param availableHeight 외부에서 측정한 실제 가용 높이(px). 스크롤 필요 여부 판단에만 사용.
*/
/**
* 캔들 영역 vs 보조지표 영역 높이 비율 (예: 보조 1개 → 2:1, 2~5개 → 1:1, 6+ → 1:2)
* auxWeight 가 0 이면 비율 해제.
*/
setPaneAreaRatio(candleWeight: number, auxWeight: number): void {
if (auxWeight <= 0) {
this._paneAreaRatio = null;
} else {
this._paneAreaRatio = {
candle: Math.max(0.1, candleWeight),
aux: Math.max(0.1, auxWeight),
};
}
}
/**
* 보조지표 전용 레이아웃 — 캔들·오버레이 숨김, 하단 pane 선형 지표만 표시.
*/
applyAuxIndicatorOnlyLayout(enabled: boolean): void {
this._auxIndicatorOnlyLayout = enabled;
if (enabled) {
this.setChartOverlayVisibility({
ma: false,
bollinger: false,
ichimoku: false,
candle: false,
});
} else {
this.setChartOverlayVisibility({ ...DEFAULT_CHART_OVERLAY_VISIBILITY });
}
this.resetPaneHeights();
}
private _volumeFrac(H: number): number {
if (!this._volumePaneEnabled || !this._volumeVisible || this._candleOnlyLayout) return 0;
return Math.min(Math.max(60 / H, 0.04), 0.12);
}
private _applyPaneStretchFactors(mainFrac: number, availableHeight?: number): number {
const panes = this.chart.panes();
if (panes.length === 0) return availableHeight ?? this.container.clientHeight;
const activeIndPanes = this._activeIndicatorPaneIndices();
const N = activeIndPanes.size;
const H = (availableHeight && availableHeight > 0)
? availableHeight
: (this._lastLayoutAvailableHeight && this._lastLayoutAvailableHeight > 0
? this._lastLayoutAvailableHeight
: this.container.clientHeight);
const volumeShown = this._volumePaneEnabled && this._volumeVisible && !this._candleOnlyLayout;
const VOL_FRAC = this._volumeFrac(H);
const MIN_IND_PX = this._minIndPx(H);
const ORPHAN_STRETCH = 0.0001;
/** 보조지표 pane 은 동일 stretch weight → 항상 같은 높이 */
const IND_EQUAL_WEIGHT = 1;
const remaining = Math.max(0, 1 - mainFrac - (volumeShown ? VOL_FRAC : 0));
const eachIndFrac = N > 0 ? remaining / N : 0;
const naturalIndPx = eachIndFrac * H;
const actualIndFrac = naturalIndPx >= MIN_IND_PX
? eachIndFrac
: MIN_IND_PX / H;
const indWeight = IND_EQUAL_WEIGHT;
const indUnit = Math.max(actualIndFrac, 1e-9);
const mainWeight = N > 0 ? mainFrac / indUnit : mainFrac;
const volWeight = volumeShown
? (N > 0 ? VOL_FRAC / indUnit : VOL_FRAC)
: ORPHAN_STRETCH;
for (let i = 0; i < panes.length; i++) {
const paneIndex = panes[i].paneIndex();
if (paneIndex === 0) {
panes[i].setStretchFactor(mainWeight);
} else if (paneIndex === 1) {
if (volumeShown) {
panes[i].setStretchFactor(volWeight);
} else if (activeIndPanes.has(1)) {
// 볼륨 숨김 + pane 1 에 보조지표가 배치된 경우 → 지표 pane 으로 취급해 높이 부여
panes[i].setStretchFactor(indWeight);
} else {
panes[i].setStretchFactor(ORPHAN_STRETCH);
}
} else if (activeIndPanes.has(paneIndex)) {
panes[i].setStretchFactor(indWeight);
} else {
panes[i].setStretchFactor(ORPHAN_STRETCH);
}
}
const totalRequired = Math.round(
mainFrac * H + (volumeShown ? VOL_FRAC * H : 0) + N * Math.max(naturalIndPx, MIN_IND_PX),
);
this._notifyPaneLayout();
return totalRequired;
}
private _minIndPx(H: number): number {
if (this._compactPaneLayout) {
return Math.min(44, Math.max(24, Math.round(H * 0.15)));
}
// 일반 레이아웃: 최소 높이를 줄여 한 화면에 더 많은 보조지표 pane 표시
return 50;
}
private _paneHeightFractions(
indicatorPaneCount: number,
volFrac: number,
availableHeight?: number,
): { mainFrac: number; eachIndFrac: number } {
const N = indicatorPaneCount;
if (N === 0) {
return { mainFrac: 1 - volFrac, eachIndFrac: 0 };
}
if (this._compactPaneLayout && N === 1 && availableHeight != null && availableHeight < 400) {
const mainFrac = 0.52;
return { mainFrac, eachIndFrac: Math.max(0.24, 1 - mainFrac - volFrac) };
}
// 보조지표가 많을수록 캔들 차트 비율을 높여 가독성 확보
if (N <= 2) {
return { mainFrac: 0.60, eachIndFrac: (0.40 - volFrac) / N };
}
if (N <= 4) {
return { mainFrac: 0.55, eachIndFrac: (0.45 - volFrac) / N };
}
const mainFrac = Math.max(0.40, volFrac + 0.05);
return { mainFrac, eachIndFrac: (1 - mainFrac - volFrac) / N };
}
/** 캔들 pane 전체보기 — pane 0 만 사용, 하단 pane 접음 */
private _resetPaneHeightsCandleFullscreen(availableHeight?: number): number {
const panes = this.chart.panes();
if (panes.length === 0) return availableHeight ?? this.container.clientHeight;
const ORPHAN_STRETCH = 0.0001;
const H = (availableHeight && availableHeight > 0)
? availableHeight
: this.container.clientHeight;
for (let i = 0; i < panes.length; i++) {
panes[i].setStretchFactor(i === 0 ? 1 : ORPHAN_STRETCH);
}
this._hideAllSubPaneIndicators();
return H;
}
/** 보조지표 전용 — pane 0(캔들) 최소화, 보조 pane 균등 확장 */
private _resetPaneHeightsAuxIndicatorOnly(availableHeight?: number): number {
const panes = this.chart.panes();
if (panes.length === 0) return availableHeight ?? this.container.clientHeight;
const ORPHAN_STRETCH = 0.0001;
const IND_EQUAL_WEIGHT = 1;
const H = (availableHeight && availableHeight > 0)
? availableHeight
: (this._lastLayoutAvailableHeight && this._lastLayoutAvailableHeight > 0
? this._lastLayoutAvailableHeight
: this.container.clientHeight);
const activeIndPanes = this._activeIndicatorPaneIndices();
const volumeShown = this._volumePaneEnabled && this._volumeVisible && !this._candleOnlyLayout;
try {
this.volumeSeries?.applyOptions({ visible: false } as Parameters<ISeriesApi<SeriesType>['applyOptions']>[0]);
} catch { /* ok */ }
for (let i = 0; i < panes.length; i++) {
const paneIndex = panes[i].paneIndex();
if (paneIndex === 0) {
panes[i].setStretchFactor(ORPHAN_STRETCH);
} else if (paneIndex === 1) {
if (volumeShown) {
panes[i].setStretchFactor(ORPHAN_STRETCH);
} else if (activeIndPanes.has(1)) {
panes[i].setStretchFactor(IND_EQUAL_WEIGHT);
} else {
panes[i].setStretchFactor(ORPHAN_STRETCH);
}
} else if (activeIndPanes.has(paneIndex)) {
panes[i].setStretchFactor(IND_EQUAL_WEIGHT);
} else {
panes[i].setStretchFactor(ORPHAN_STRETCH);
}
}
this.syncChartOverlayVisibility();
this._restoreSubPaneIndicatorVisibility();
this._scheduleIndicatorLastUpdate();
return H;
}
resetPaneHeights(availableHeight?: number): number {
const panes = this.chart.panes();
if (panes.length === 0) return availableHeight ?? this.container.clientHeight;
if (availableHeight != null && availableHeight > 0) {
this._lastLayoutAvailableHeight = availableHeight;
}
if (this._candleOnlyLayout) {
return this._resetPaneHeightsCandleFullscreen(availableHeight);
}
if (this._auxIndicatorOnlyLayout) {
return this._resetPaneHeightsAuxIndicatorOnly(availableHeight);
}
const H = (availableHeight && availableHeight > 0)
? availableHeight
: (this._lastLayoutAvailableHeight && this._lastLayoutAvailableHeight > 0
? this._lastLayoutAvailableHeight
: this.container.clientHeight);
const N = this._activeIndicatorPaneIndices().size;
const volFrac = this._volumeFrac(H);
let mainFrac: number;
if (this._paneAreaRatio && N > 0) {
const total = this._paneAreaRatio.candle + this._paneAreaRatio.aux;
const candleGroupFrac = this._paneAreaRatio.candle / total;
mainFrac = Math.max(0.12, candleGroupFrac - volFrac);
} else {
mainFrac = this._paneHeightFractions(N, volFrac, H).mainFrac;
}
return this._applyPaneStretchFactors(mainFrac, availableHeight);
}
// ─── Diagnostic ───────────────────────────────────────────────────────────
/**
* 현재 rawBars 기준으로 모든 보조지표의 마지막 계산값을 콘솔에 출력합니다.
* 브라우저 콘솔에서 `chartManager.logIndicatorLastValues()` 로 호출하여
* 갱신 여부와 실제 값 변화를 수동으로 확인할 수 있습니다.
*
* 예: ADX 값이 두 번 호출 사이에 다른지 확인 → 값이 매우 조금씩 변하면
* 갱신 로직은 정상이며 Wilder's smoothing 의 수학적 특성으로 인해
* 변화가 작을 뿐임을 확인할 수 있습니다.
*/
async logIndicatorLastValues(): Promise<void> {
if (this.rawBars.length === 0) { console.warn('[ChartManager] rawBars empty'); return; }
const snapshot = this.rawBars.slice();
const lastBar = snapshot[snapshot.length - 1];
console.group(`[ChartManager] Indicator last values @ bar time=${new Date(lastBar.time * 1000).toISOString()}`);
console.log('Main bar:', lastBar);
for (const [, entry] of this.indicators.entries()) {
if (!entry.config || entry.seriesList.length === 0) continue;
try {
const { plots } = await calculateIndicator(entry.config.type, snapshot, entry.config.params ?? {});
const vals: Record<string, number | null> = {};
for (const meta of entry.seriesMeta) {
const pd = plots[meta.plotId] as PlotData | undefined;
vals[meta.plotId] = pd?.length ? (pd[pd.length - 1]?.value ?? null) : null;
}
console.log(` [${entry.config.type}]`, vals);
} catch (e) {
console.warn(` [${entry.config.type}] calc error:`, e);
}
}
console.groupEnd();
}
// ─── Destroy ─────────────────────────────────────────────────────────────
destroy(): void {
if (this._indUpdateTimer) { clearTimeout(this._indUpdateTimer); this._indUpdateTimer = null; }
if (this._clickHandler) this.chart.unsubscribeClick(this._clickHandler);
this.chart.remove();
}
}