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, resolveSignalBarTime, 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, resolveHLineDrawPrice, 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, }; /** 서브 pane 보조지표 — 볼륨 숨김(pane 1)에서도 지표 전용 포맷 사용 */ function priceFormatForIndicatorSeries(def: ReturnType | undefined) { if (!def || def.overlay || def.returnsMarkers) return undefined; return INDICATOR_PRICE_FORMAT; } 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[]; /** * 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 | undefined { const i = entry.seriesMeta.findIndex(m => m.plotId === plotId); return i >= 0 ? entry.seriesList[i] : undefined; } /** hline·음영 프리미티브 부착 — histogram 이 있으면 0선과 동일 스케일·기준선에 부착 */ function hlineAnchorSeries(entry: Pick): ISeriesApi | undefined { if (entry.seriesList.length === 0) return undefined; const histIdx = entry.seriesMeta.findIndex(m => m.isHistogram); if (histIdx >= 0) return entry.seriesList[histIdx]; const lineIdx = entry.seriesMeta.findIndex(m => !m.isHistogram); return entry.seriesList[lineIdx >= 0 ? lineIdx : 0]; } 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 | null = null; private volumeSeries: ISeriesApi<'Histogram'> | null = null; private indicators = new Map(); private alertEntries: AlertEntry[] = []; private fibLines: FibLevel[] = []; private drawingTool = 'cursor'; private drawingPoints: { time: Time; price: number }[] = []; private drawingLines: IPriceLine[] = []; private _clickHandler: ((p: MouseEventParams