import type { OHLCVBar } from '../types'; import type { StrategyDto } from './backendApi'; import { extractVirtualConditions } from './virtualStrategyConditions'; import { formatIndicatorDisplayLabel } from './indicatorRegistry'; const DSL_TO_REGISTRY: Record = { RSI: 'RSI', MACD: 'MACD', MA: 'SMA', EMA: 'EMA', BOLLINGER: 'BollingerBands', STOCHASTIC: 'Stochastic', WILLIAMS_R: 'WilliamsPercentRange', CCI: 'CCI', ADX: 'ADX', DMI: 'DMI', OBV: 'OBV', TRIX: 'TRIX', VOLUME_OSC: 'VolumeOscillator', VR: 'VR', DISPARITY: 'Disparity', PSYCHOLOGICAL: 'Psychological', NEW_PSYCHOLOGICAL: 'NewPsychological', INVEST_PSYCHOLOGICAL: 'InvestPsychological', ICHIMOKU: 'IchimokuCloud', ATR: 'ATR', MFI: 'MFI', }; /** 메인 캔들 pane 오버레이 — 하단 스파크 pane 제외 */ const OVERLAY_DSL = new Set([ 'MA', 'EMA', 'BOLLINGER', 'ICHIMOKU', 'DONCHIAN', 'NEW_HIGH', 'NEW_LOW', 'DISPARITY', ]); export interface OscillatorPaneSpec { key: string; registryType: string; label: string; period?: number; } export interface OscillatorPaneData { spec: OscillatorPaneSpec; values: number[]; refLines?: number[]; color: string; } const PANE_COLORS: Record = { RSI: '#a78bfa', MACD: '#38bdf8', CCI: '#fbbf24', Stochastic: '#f472b6', WilliamsPercentRange: '#c084fc', ADX: '#34d399', MFI: '#fb923c', OBV: '#94a3b8', TRIX: '#22d3ee', VolumeOscillator: '#86efac', VR: '#fcd34d', Psychological: '#e879f9', NewPsychological: '#e879f9', InvestPsychological: '#e879f9', }; function computeRsi(closes: number[], period = 14): number[] { if (closes.length < period + 1) return []; const out: number[] = []; let avgGain = 0; let avgLoss = 0; for (let i = 1; i <= period; i++) { const d = closes[i] - closes[i - 1]; if (d >= 0) avgGain += d; else avgLoss -= d; } avgGain /= period; avgLoss /= period; for (let i = period; i < closes.length; i++) { const d = closes[i] - closes[i - 1]; const gain = d > 0 ? d : 0; const loss = d < 0 ? -d : 0; avgGain = (avgGain * (period - 1) + gain) / period; avgLoss = (avgLoss * (period - 1) + loss) / period; const rs = avgLoss === 0 ? 100 : avgGain / avgLoss; out.push(100 - 100 / (1 + rs)); } return out; } function computeMacd(closes: number[]): number[] { const ema = (arr: number[], p: number) => { const k = 2 / (p + 1); let v = arr[0]; return arr.map((x, i) => (i === 0 ? x : (v = x * k + v * (1 - k)))); }; const e12 = ema(closes, 12); const e26 = ema(closes, 26); return e12.map((v, i) => v - e26[i]); } function computeCci(bars: OHLCVBar[], period = 20): number[] { const tp = bars.map(b => (b.high + b.low + b.close) / 3); const out: number[] = []; for (let i = period; i < tp.length; i++) { const slice = tp.slice(i - period, i); const sma = slice.reduce((a, b) => a + b, 0) / slice.length; const md = slice.reduce((a, v) => a + Math.abs(v - sma), 0) / slice.length; out.push(md === 0 ? 0 : (tp[i] - sma) / (0.015 * md)); } return out; } function computeSeries(bars: OHLCVBar[], registryType: string, period?: number): number[] { const closes = bars.map(b => b.close); switch (registryType) { case 'RSI': return computeRsi(closes, period ?? 14); case 'MACD': return computeMacd(closes); case 'CCI': return computeCci(bars, period ?? 20); default: return computeRsi(closes, 14); } } function refLinesFor(registryType: string): number[] | undefined { if (registryType === 'RSI') return [30, 50, 70]; if (registryType === 'CCI') return [-100, 0, 100]; if (registryType === 'MACD') return [0]; return undefined; } export function collectStrategyOscillatorPanes(strategy: StrategyDto | undefined): OscillatorPaneSpec[] { if (!strategy) return []; const rows = extractVirtualConditions(strategy); const seen = new Set(); const out: OscillatorPaneSpec[] = []; for (const row of rows) { if (OVERLAY_DSL.has(row.indicatorType)) continue; const registryType = DSL_TO_REGISTRY[row.indicatorType] ?? row.indicatorType; if (OVERLAY_DSL.has(registryType) || registryType === 'SMA' || registryType === 'EMA' || registryType === 'BollingerBands' || registryType === 'IchimokuCloud') { continue; } if (seen.has(registryType)) continue; seen.add(registryType); out.push({ key: registryType, registryType, label: formatIndicatorDisplayLabel(row.indicatorType), period: undefined, }); } return out; } export function buildOscillatorPaneData( bars: OHLCVBar[], specs: OscillatorPaneSpec[], ): OscillatorPaneData[] { if (bars.length < 20) return []; const out: OscillatorPaneData[] = []; for (const spec of specs) { const values = computeSeries(bars, spec.registryType, spec.period).slice(-80); if (values.length < 2) continue; out.push({ spec, values, refLines: refLinesFor(spec.registryType), color: PANE_COLORS[spec.registryType] ?? '#94a3b8', }); } return out; } export function defaultOscillatorPaneSpecs(): OscillatorPaneSpec[] { return [ { key: 'RSI', registryType: 'RSI', label: 'RSI (14)', period: 14 }, { key: 'MACD', registryType: 'MACD', label: 'MACD (12, 26, 9)' }, ]; } /** StrategyOscillatorPanes 에 실제로 그려지는 보조 pane 개수 */ export function countAuxiliaryOscillatorPaneCount(strategy: StrategyDto | undefined): number { const specs = collectStrategyOscillatorPanes(strategy); return (specs.length > 0 ? specs : defaultOscillatorPaneSpecs()).length; } /** TradingChart 보조 pane(비오버레이 지표) 개수 */ export function countNonOverlayIndicatorPanes( indicators: Array<{ type: string }>, isOverlay: (type: string) => boolean, ): number { return indicators.filter(ind => !isOverlay(ind.type)).length; } /** * 캔들 vs 보조지표 영역 flex 비율 * - 보조 1개: 2:1 * - 보조 2~5개: 1:1 * - 보조 6개+: 1:2 */ export function chartPaneFlexRatio(auxPaneCount: number): { candle: number; aux: number } { const n = Math.max(0, auxPaneCount); if (n === 0) return { candle: 1, aux: 0 }; if (n === 1) return { candle: 2, aux: 1 }; if (n <= 5) return { candle: 1, aux: 1 }; return { candle: 1, aux: 2 }; }