/** * 전략 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 { mergeGlobalDefaultsOntoChartIndicators } from './indicatorMainConfig'; import { buildMainIndicatorConfig } from './indicatorMainConfig'; import { normalizeSmaConfig } from './smaConfig'; import { normalizeIchimokuConfig } from './ichimokuConfig'; import type { EditorConditionState } from './strategyConditionSerde'; import { collectUiEvaluationTimeframes } from './strategyTimeframeSync'; 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; }; /** 알림·가상투자 등 전략에서 생성된 차트 지표 id (워크스페이스 ind_* 와 구분) */ export function isStrategyChartIndicatorId(id: string): boolean { return id.startsWith('strat_'); } /** 전략 DSL 기준 안정 id — getVisualConfig/settingsRevision 변경 시 id 재발급 방지 */ export function stableStrategyIndicatorId(ref: IndicatorRef): string { const parts = [ref.registryType]; if (ref.period != null) parts.push(`p${ref.period}`); if (ref.leftPeriod != null) parts.push(`l${ref.leftPeriod}`); if (ref.rightPeriod != null) parts.push(`r${ref.rightPeriod}`); if (ref.targetValue != null) parts.push(`t${ref.targetValue}`); return `strat_${parts.join('_')}`; } 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'; } const TIMEFRAME_SORT_ORDER: Timeframe[] = ['1m', '3m', '5m', '10m', '15m', '30m', '1h', '4h', '1D', '1W', '1M']; function sortStrategyTimeframes(tfs: Timeframe[]): Timeframe[] { return [...tfs].sort( (a, b) => TIMEFRAME_SORT_ORDER.indexOf(a) - TIMEFRAME_SORT_ORDER.indexOf(b), ); } /** 편집기 START·flowLayout·저장 DSL에서 전략 평가 시간봉 수집 */ export function resolveStrategyTimeframes( strategy: StrategyDto | undefined, editorState?: { buy: EditorConditionState; sell: EditorConditionState }, ): Timeframe[] { const candleTypes = collectUiEvaluationTimeframes(strategy?.id ?? 0, strategy ?? null, editorState); if (candleTypes.length === 0) return ['1m']; const set = new Set(); for (const ct of candleTypes) { set.add(candleTypeToTimeframe(ct)); } return sortStrategyTimeframes([...set]); } export function resolveStrategyPrimaryTimeframe( strategy: StrategyDto | undefined, editorState?: { buy: EditorConditionState; sell: EditorConditionState }, ): Timeframe { const tfs = resolveStrategyTimeframes(strategy, editorState); 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, side?: 'BUY' | 'SELL'): IndicatorRef[] { const out: IndicatorRef[] = []; const seen = new Set(); if (!side || side === 'BUY') { walkIndicatorRefs(strategy.buyCondition as LogicNode | null, out, seen); } if (!side || side === 'SELL') { walkIndicatorRefs(strategy.sellCondition as LogicNode | null, out, seen); } return out; } /** 전략 DSL에 포함된 보조지표 registry type 목록 (중복 제거) */ export function collectStrategyRegistryTypes(strategy: StrategyDto | null | undefined): string[] { if (!strategy) return []; const refs = collectIndicatorRefs(strategy); return [...new Set(refs.map(r => r.registryType))]; } 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, stableId?: string, ): 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: stableId ?? stableStrategyIndicatorId(ref), type: def.type, params: { ...params }, plots, hlines, ...(cloudColors ? { cloudColors } : {}), }; /** * 모든 보조지표의 기간(period)은 반드시 지표 설정(getParams, DB 저장값)을 사용한다. * 전략 DSL 조건의 period/leftPeriod/rightPeriod 로 덮어쓰지 않는다. * 조건의 기간 필드는 부정확(미설정 시 1 등)할 수 있어 SMA(1)=본선 겹침 등 * 잘못된 그래프를 만든다. 설정에 저장된 값(예: OBV 신호선 9일)이 유일한 기준. * cfg.params 는 이미 getParams(저장 설정값) 결과이므로 그대로 사용. */ if (ref.registryType === 'SMA') { const mainSma = buildMainIndicatorConfig('SMA', [], getParams, getVisual as Parameters[3]); cfg = normalizeSmaConfig({ ...cfg, params: mainSma?.params ?? cfg.params, plots: mainSma?.plots ?? cfg.plots, plotVisibility: mainSma?.plotVisibility ?? cfg.plotVisibility, }); } else if (ref.registryType === 'OBV') { cfg = { ...cfg, params: normalizeObvParams({ ...cfg.params }), }; } 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, workspaceIndicators?: IndicatorConfig[], side?: 'BUY' | 'SELL', ): IndicatorConfig[] { if (!strategy) return []; const refs = collectIndicatorRefs(strategy, side); 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 mergeGlobalDefaultsOntoChartIndicators( result, getParams, (type, defaultPlots, defaultHlines) => getVisual(type, defaultPlots ?? [], defaultHlines ?? []), workspaceIndicators, ); } 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, 'strat_overlay_SMA', ); if (sma) prepend.push(sma); } if (!hasIchimoku) { const ichimoku = buildOneIndicator( { dslType: 'ICHIMOKU', registryType: 'IchimokuCloud' }, getParams, getVisual, 'strat_overlay_IchimokuCloud', ); if (ichimoku) prepend.push(ichimoku); } if (!hasBb) { const bb = buildOneIndicator( { dslType: 'BOLLINGER', registryType: 'BollingerBands' }, getParams, getVisual, 'strat_overlay_BollingerBands', ); 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); }