/** * 전략 DSL → 차트 IndicatorConfig[] 변환 (가상투자 차트 팝업) */ import type { StrategyDto } from './backendApi'; import type { IndicatorConfig, Timeframe } from '../types'; import type { LogicNode } from './strategyTypes'; import { enrichIndicatorConfig, getIndicatorDef, getHLineLabel, mergePlotDefs, normalizeObvParams, type HLineDef, } from './indicatorRegistry'; import { initializeIndicatorConfigForEditor } from './indicatorSettingsEditor'; import { buildMainIndicatorConfig } from './indicatorMainConfig'; import { normalizeSmaConfig, smaPeriodKey } from './smaConfig'; import { normalizeIchimokuConfig } from './ichimokuConfig'; import { extractVirtualConditions } from './virtualStrategyConditions'; 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', BWI: 'BBBandWidth', DONCHIAN: 'DonchianChannels', NEW_HIGH: 'DonchianChannels', NEW_LOW: 'DonchianChannels', ICHIMOKU: 'IchimokuCloud', ATR: 'ATR', MFI: 'MFI', }; interface IndicatorRef { dslType: string; registryType: string; period?: number; leftPeriod?: number; rightPeriod?: number; targetValue?: number | null; } type ParamRecord = Record; type GetParams = (type: string, defaults?: ParamRecord) => ParamRecord; type GetVisual = ( type: string, plots: import('./indicatorRegistry').PlotDef[], hlines: HLineDef[], ) => { plots: import('./indicatorRegistry').PlotDef[]; hlines: HLineDef[]; cloudColors?: import('./ichimokuConfig').IchimokuCloudColors; }; let _idSeq = 0; function newIndId(): string { _idSeq += 1; return `vstrat_${_idSeq}_${Date.now()}`; } export function candleTypeToTimeframe(candleType: string): Timeframe { const c = (candleType ?? '1m').trim().toLowerCase(); if (c === '1d') return '1D'; if (c === '1w') return '1W'; if (c === '1h') return '1h'; if (c === '4h') return '4h'; if (['1m', '3m', '5m', '10m', '15m', '30m'].includes(c)) return c as Timeframe; return '1m'; } export function resolveStrategyTimeframes(strategy: StrategyDto | undefined): Timeframe[] { if (!strategy) return ['1m']; const conditions = extractVirtualConditions(strategy); const set = new Set(); for (const row of conditions) { set.add(candleTypeToTimeframe(row.timeframe)); } if (set.size === 0) set.add('1m'); return [...set].sort((a, b) => { const order: Timeframe[] = ['1m', '3m', '5m', '10m', '15m', '30m', '1h', '4h', '1D', '1W', '1M']; return order.indexOf(a) - order.indexOf(b); }); } export function resolveStrategyPrimaryTimeframe(strategy: StrategyDto | undefined): Timeframe { const tfs = resolveStrategyTimeframes(strategy); return tfs[0] ?? '1m'; } function walkIndicatorRefs( node: LogicNode | null | undefined, out: IndicatorRef[], seen: Set, ): void { if (!node) return; if (node.type === 'TIMEFRAME') { node.children?.forEach(c => walkIndicatorRefs(c, out, seen)); return; } if (node.type === 'CONDITION' && node.condition) { const c = node.condition; const dslType = c.indicatorType; const registryType = DSL_TO_REGISTRY[dslType] ?? dslType; const key = `${registryType}:${c.period ?? ''}:${c.leftPeriod ?? ''}:${c.rightPeriod ?? ''}:${c.targetValue ?? ''}`; if (seen.has(key)) return; seen.add(key); out.push({ dslType, registryType, period: c.period, leftPeriod: c.leftPeriod, rightPeriod: c.rightPeriod, targetValue: c.targetValue ?? c.compareValue ?? null, }); return; } node.children?.forEach(ch => walkIndicatorRefs(ch, out, seen)); } function collectIndicatorRefs(strategy: StrategyDto): IndicatorRef[] { const out: IndicatorRef[] = []; const seen = new Set(); walkIndicatorRefs(strategy.buyCondition as LogicNode | null, out, seen); walkIndicatorRefs(strategy.sellCondition as LogicNode | null, out, seen); return out; } function applyTargetHlines( hlines: HLineDef[], target: number | null | undefined, ): HLineDef[] { if (target == null || Number.isNaN(target)) return hlines; const next = hlines.map(h => ({ ...h })); const prices = next.map(h => h.price); const idx = next.findIndex(h => Math.abs(h.price - target) < 1e-6); if (idx >= 0) { next[idx] = { ...next[idx], visible: true, price: target }; return next; } next.push({ price: target, color: '#ffb300', label: getHLineLabel(target, [...prices, target]), visible: true, lineStyle: 'dashed', lineWidth: 1, }); return next; } function buildOneIndicator( ref: IndicatorRef, getParams: GetParams, getVisual: GetVisual, ): IndicatorConfig | null { const def = getIndicatorDef(ref.registryType); if (!def) return null; const params = getParams(ref.registryType, def.defaultParams as ParamRecord); const { plots, hlines, cloudColors } = getVisual(ref.registryType, def.plots, def.hlines ?? []); let cfg: IndicatorConfig = { id: newIndId(), type: def.type, params: { ...params }, plots, hlines, ...(cloudColors ? { cloudColors } : {}), }; if (ref.registryType === 'SMA') { const mainSma = buildMainIndicatorConfig('SMA', [], getParams, getVisual as Parameters[3]); const periods = [ref.period, ref.leftPeriod, ref.rightPeriod] .filter((p): p is number => typeof p === 'number' && p > 0); const p = { ...cfg.params }; if (periods.length > 0) { periods.slice(0, 11).forEach((val, i) => { p[smaPeriodKey(i + 1)] = val; }); } cfg = normalizeSmaConfig({ ...cfg, params: p, plots: mainSma?.plots ?? cfg.plots, plotVisibility: mainSma?.plotVisibility ?? cfg.plotVisibility, }); } else if (ref.registryType === 'OBV') { const p = normalizeObvParams({ ...cfg.params }); const signalLen = ref.rightPeriod ?? ref.period; if (signalLen != null && signalLen > 0) { p.maLength = signalLen; } cfg = { ...cfg, params: p, plots: mergePlotDefs(cfg.plots, def.plots), }; } else if (ref.period != null && ref.period > 0) { cfg = { ...cfg, params: { ...cfg.params, length: ref.period }, }; } if (ref.registryType === 'IchimokuCloud') { cfg = normalizeIchimokuConfig(cfg); } if (ref.targetValue != null) { cfg = { ...cfg, hlines: applyTargetHlines(cfg.hlines ?? [], ref.targetValue) }; } return initializeIndicatorConfigForEditor( cfg, getParams, (type, defaultPlots, defaultHlines) => getVisual(type, defaultPlots, defaultHlines ?? []), ); } /** 전략에 포함된 보조지표를 차트용 IndicatorConfig 로 변환 (중복 type 제거) */ export function buildStrategyChartIndicators( strategy: StrategyDto | undefined, getParams: GetParams, getVisual: GetVisual, ): IndicatorConfig[] { if (!strategy) return []; const refs = collectIndicatorRefs(strategy); const byType = new Map(); for (const ref of refs) { const existing = byType.get(ref.registryType); if (!existing) { byType.set(ref.registryType, ref); continue; } byType.set(ref.registryType, { ...existing, period: existing.period ?? ref.period, leftPeriod: existing.leftPeriod ?? ref.leftPeriod, rightPeriod: existing.rightPeriod ?? ref.rightPeriod, targetValue: existing.targetValue ?? ref.targetValue, }); } const result: IndicatorConfig[] = []; for (const ref of byType.values()) { const cfg = buildOneIndicator(ref, getParams, getVisual); if (cfg) result.push(cfg); } return result; } const MA_OVERLAY_TYPES = new Set(['SMA', 'EMA']); /** * 캔들 영역 기본 오버레이 — 이동평균(SMA)·일목균형표·볼린저밴드 * (전략에 없어도 표시, 우측 상단 토글로 표시/숨김) */ export function ensurePaperChartOverlays( indicators: IndicatorConfig[], getParams: GetParams, getVisual: GetVisual, ): IndicatorConfig[] { const hasMa = indicators.some(i => MA_OVERLAY_TYPES.has(i.type)); const hasIchimoku = indicators.some(i => i.type === 'IchimokuCloud'); const hasBb = indicators.some(i => i.type === 'BollingerBands'); const prepend: IndicatorConfig[] = []; if (!hasMa) { const sma = buildOneIndicator( { dslType: 'MA', registryType: 'SMA' }, getParams, getVisual, ); if (sma) prepend.push(sma); } if (!hasIchimoku) { const ichimoku = buildOneIndicator( { dslType: 'ICHIMOKU', registryType: 'IchimokuCloud' }, getParams, getVisual, ); if (ichimoku) prepend.push(ichimoku); } if (!hasBb) { const bb = buildOneIndicator( { dslType: 'BOLLINGER', registryType: 'BollingerBands' }, getParams, getVisual, ); if (bb) prepend.push(bb); } if (prepend.length === 0) return indicators; return [...prepend, ...indicators]; } /** 가상투자 차트 — 전략 지표 + 기본 일목균형표 */ export function buildVirtualTradingChartIndicators( strategy: StrategyDto | undefined, getParams: GetParams, getVisual: GetVisual, ): IndicatorConfig[] { const strategyIndicators = buildStrategyChartIndicators(strategy, getParams, getVisual); if (strategyIndicators.some(ind => ind.type === 'IchimokuCloud')) { return strategyIndicators; } const defaultIchimoku = buildOneIndicator( { dslType: 'ICHIMOKU', registryType: 'IchimokuCloud' }, getParams, getVisual, ); if (!defaultIchimoku) return strategyIndicators; return [defaultIchimoku, ...strategyIndicators]; } /** 가상매매 카드 인라인 차트 — 캔들 영역 확보(일목·다중 지표 pane 제외, 전략 지표 최대 1개) */ export function buildVirtualTradingCardChartIndicators( strategy: StrategyDto | undefined, getParams: GetParams, getVisual: GetVisual, ): IndicatorConfig[] { return buildStrategyChartIndicators(strategy, getParams, getVisual).slice(0, 1); }