/** * 추천 안정형 전략 — 터틀·MA·일목·MACD·볼린저 등 복합지표 칩 */ import type { ConditionDSL, LogicNode } from './strategyTypes'; import { initConditionPeriodsInherit, type IndicatorPeriodDef } from './conditionPeriods'; import { genId } from './strategyNodeIds'; import { donchianUpperField, donchianLowerField } from './compositeIndicators'; export type StableStrategyFilterLevel = 'strict' | 'balanced' | 'relaxed'; export const DEFAULT_STABLE_FILTER_LEVEL: StableStrategyFilterLevel = 'balanced'; export type StableStrategyId = | 'TURTLE_S1' | 'TURTLE_S2' | 'DONCHIAN_20' | 'MA_TREND' | 'ICHIMOKU_TREND' | 'MACD_MOMENTUM' | 'BOLLINGER_RANGE'; export const STABLE_STRATEGY_FILTER_OPTIONS: { value: StableStrategyFilterLevel; label: string; desc: string; }[] = [ { value: 'strict', label: '강함', desc: 'ADX·거래량 등 필터 최대 — 신호 적음·품질↑' }, { value: 'balanced', label: '보통 (권장)', desc: '기본 추천 조합' }, { value: 'relaxed', label: '완화', desc: '핵심 조건만 — 신호 빈도↑' }, ]; type FilterFlags = { adx: number | null; volume: boolean }; const FILTER_FLAGS: Record = { strict: { adx: 25, volume: true }, balanced: { adx: 20, volume: false }, relaxed: { adx: null, volume: false }, }; const BOLLINGER_FILTER: Record = { strict: 20, balanced: 25, relaxed: null, }; // ── Palette values ─────────────────────────────────────────────────────────── export const TURTLE_S1_BUY = 'TURTLE_S1_BUY'; export const TURTLE_S1_SELL = 'TURTLE_S1_SELL'; export const TURTLE_S2_BUY = 'TURTLE_S2_BUY'; export const TURTLE_S2_SELL = 'TURTLE_S2_SELL'; export const DONCHIAN_20_BUY = 'DONCHIAN_20_BUY'; export const DONCHIAN_20_SELL = 'DONCHIAN_20_SELL'; export const MA_TREND_BUY = 'MA_TREND_BUY'; export const MA_TREND_SELL = 'MA_TREND_SELL'; export const ICHIMOKU_TREND_BUY = 'ICHIMOKU_TREND_BUY'; export const ICHIMOKU_TREND_SELL = 'ICHIMOKU_TREND_SELL'; export const MACD_MOMENTUM_BUY = 'MACD_MOMENTUM_BUY'; export const MACD_MOMENTUM_SELL = 'MACD_MOMENTUM_SELL'; export const BOLLINGER_RANGE_BUY = 'BOLLINGER_RANGE_BUY'; export const BOLLINGER_RANGE_SELL = 'BOLLINGER_RANGE_SELL'; const VALUE_TO_META: Record = { [TURTLE_S1_BUY]: { id: 'TURTLE_S1', mode: 'buy' }, [TURTLE_S1_SELL]: { id: 'TURTLE_S1', mode: 'sell' }, [TURTLE_S2_BUY]: { id: 'TURTLE_S2', mode: 'buy' }, [TURTLE_S2_SELL]: { id: 'TURTLE_S2', mode: 'sell' }, [DONCHIAN_20_BUY]: { id: 'DONCHIAN_20', mode: 'buy' }, [DONCHIAN_20_SELL]: { id: 'DONCHIAN_20', mode: 'sell' }, [MA_TREND_BUY]: { id: 'MA_TREND', mode: 'buy' }, [MA_TREND_SELL]: { id: 'MA_TREND', mode: 'sell' }, [ICHIMOKU_TREND_BUY]: { id: 'ICHIMOKU_TREND', mode: 'buy' }, [ICHIMOKU_TREND_SELL]: { id: 'ICHIMOKU_TREND', mode: 'sell' }, [MACD_MOMENTUM_BUY]: { id: 'MACD_MOMENTUM', mode: 'buy' }, [MACD_MOMENTUM_SELL]: { id: 'MACD_MOMENTUM', mode: 'sell' }, [BOLLINGER_RANGE_BUY]: { id: 'BOLLINGER_RANGE', mode: 'buy' }, [BOLLINGER_RANGE_SELL]: { id: 'BOLLINGER_RANGE', mode: 'sell' }, }; export interface StableStrategyPaletteMeta { value: string; label: string; desc: string; strategyId: StableStrategyId; mode: 'buy' | 'sell'; periodHint?: string; } export const STABLE_STRATEGY_PALETTE_ITEMS: StableStrategyPaletteMeta[] = [ { value: TURTLE_S1_BUY, label: '터틀 S1 매수', strategyId: 'TURTLE_S1', mode: 'buy', desc: '20일 최고가 돌파 진입 — 단기 추세추종 (리처드 데니스)', periodHint: '20 / 10일', }, { value: TURTLE_S1_SELL, label: '터틀 S1 청산', strategyId: 'TURTLE_S1', mode: 'sell', desc: '10일 최저가 이탈 청산 — 진입보다 짧은 손절', periodHint: '20 / 10일', }, { value: TURTLE_S2_BUY, label: '터틀 S2 매수', strategyId: 'TURTLE_S2', mode: 'buy', desc: '55일 최고가 돌파 — 장기 추세 포착', periodHint: '55 / 20일', }, { value: TURTLE_S2_SELL, label: '터틀 S2 청산', strategyId: 'TURTLE_S2', mode: 'sell', desc: '20일 최저가 이탈 청산', periodHint: '55 / 20일', }, { value: DONCHIAN_20_BUY, label: '돈천 20일 매수', strategyId: 'DONCHIAN_20', mode: 'buy', desc: '20일 채널 상단 돌파 — 박스권 상향 이탈', periodHint: '20일', }, { value: DONCHIAN_20_SELL, label: '돈천 20일 매도', strategyId: 'DONCHIAN_20', mode: 'sell', desc: '20일 채널 하단 이탈 — 추세 이탈 청산', periodHint: '20일', }, { value: MA_TREND_BUY, label: 'MA 추세 매수', strategyId: 'MA_TREND', mode: 'buy', desc: 'MA 골든크로스 + ADX 추세 확인', periodHint: '20 / 60', }, { value: MA_TREND_SELL, label: 'MA 추세 매도', strategyId: 'MA_TREND', mode: 'sell', desc: 'MA 데드크로스 또는 단기선 이탈', periodHint: '20 / 60', }, { value: ICHIMOKU_TREND_BUY, label: '일목 추세 매수', strategyId: 'ICHIMOKU_TREND', mode: 'buy', desc: '전환/기준선 골든크로스 + 구름 위', periodHint: '9 / 26', }, { value: ICHIMOKU_TREND_SELL, label: '일목 추세 매도', strategyId: 'ICHIMOKU_TREND', mode: 'sell', desc: '전환/기준선 데드크로스 또는 구름 아래', periodHint: '9 / 26', }, { value: MACD_MOMENTUM_BUY, label: 'MACD 모멘텀 매수', strategyId: 'MACD_MOMENTUM', mode: 'buy', desc: 'MACD 상향돌파 + EMA60 위 + ADX', periodHint: 'MACD + EMA60', }, { value: MACD_MOMENTUM_SELL, label: 'MACD 모멘텀 매도', strategyId: 'MACD_MOMENTUM', mode: 'sell', desc: 'MACD 하향돌파 또는 EMA60 이탈', periodHint: 'MACD + EMA60', }, { value: BOLLINGER_RANGE_BUY, label: '볼린저 박스 매수', strategyId: 'BOLLINGER_RANGE', mode: 'buy', desc: 'RSI 과매도 + ADX 약세 + 하단밴드 반등', periodHint: 'RSI + BB', }, { value: BOLLINGER_RANGE_SELL, label: '볼린저 박스 매도', strategyId: 'BOLLINGER_RANGE', mode: 'sell', desc: 'RSI 과매수 또는 상단밴드 이탈', periodHint: 'RSI + BB', }, ]; export function isStableStrategyPaletteValue(value: string): boolean { return value in VALUE_TO_META; } export function stableStrategyMetaFromValue(value: string): { id: StableStrategyId; mode: 'buy' | 'sell' } | null { return VALUE_TO_META[value] ?? null; } export function isStableStrategyPairRoot(node: LogicNode): boolean { return !!node.stableStrategyPair?.strategyId && (node.type === 'AND' || node.type === 'OR'); } function resolveFilterLevel(level?: StableStrategyFilterLevel): StableStrategyFilterLevel { return level ?? DEFAULT_STABLE_FILTER_LEVEL; } function adxField(threshold: number): string { return `ADX_${threshold}`; } function maField(period: number): string { return `MA${Math.max(1, Math.round(period))}`; } function emaField(period: number): string { return `EMA${Math.max(1, Math.round(period))}`; } function makeCondition( indicatorType: string, conditionType: string, leftField: string, rightField: string, def: IndicatorPeriodDef, extra?: Partial, ): LogicNode { const base: ConditionDSL = { indicatorType, conditionType, leftField, rightField, candleRange: 1, valuePeriodOverride: false, thresholdOverride: false, rightPeriodOverride: false, ...extra, }; return { id: genId(), type: 'CONDITION', condition: initConditionPeriodsInherit(indicatorType, base, def), }; } function makeAdxFilter(threshold: number, def: IndicatorPeriodDef): LogicNode { return makeCondition('ADX', 'GTE', 'ADX_VALUE', adxField(threshold), def, { targetValue: threshold }); } function makeVolumeFilter(def: IndicatorPeriodDef): LogicNode { return makeCondition('VOLUME', 'GT', 'VOLUME_VALUE', 'VOLUME_MA', def); } function appendTrendFilters( nodes: LogicNode[], level: StableStrategyFilterLevel, def: IndicatorPeriodDef, mode: 'buy' | 'sell', ): LogicNode[] { const flags = FILTER_FLAGS[level]; const adx = mode === 'buy' ? flags.adx : (level === 'strict' ? 20 : flags.adx); const out = [...nodes]; if (adx != null) out.push(makeAdxFilter(adx, def)); if (flags.volume && mode === 'buy') out.push(makeVolumeFilter(def)); return out; } function pairMeta( strategyId: StableStrategyId, mode: 'buy' | 'sell', filterLevel: StableStrategyFilterLevel, ): LogicNode['stableStrategyPair'] { return { strategyId, mode, filterLevel }; } function wrapOr(children: LogicNode[]): LogicNode { return { id: genId(), type: 'OR', children }; } function wrapAnd(children: LogicNode[], meta: LogicNode['stableStrategyPair']): LogicNode { return { id: genId(), type: 'AND', stableStrategyPair: meta, children }; } function wrapOrRoot(children: LogicNode[], meta: LogicNode['stableStrategyPair']): LogicNode { return { id: genId(), type: 'OR', stableStrategyPair: meta, children }; } // ── Builders ───────────────────────────────────────────────────────────────── function buildTurtleS1Buy(def: IndicatorPeriodDef, level: StableStrategyFilterLevel): LogicNode { const children = appendTrendFilters([ makeCondition('DONCHIAN', 'CROSS_UP', 'CLOSE_PRICE', donchianUpperField(20), def), ], level, def, 'buy'); return wrapAnd(children, pairMeta('TURTLE_S1', 'buy', level)); } function buildTurtleS1Sell(def: IndicatorPeriodDef, level: StableStrategyFilterLevel): LogicNode { return wrapOrRoot([ makeCondition('DONCHIAN', 'CROSS_DOWN', 'CLOSE_PRICE', donchianLowerField(10), def), ], pairMeta('TURTLE_S1', 'sell', level)); } function buildTurtleS2Buy(def: IndicatorPeriodDef, level: StableStrategyFilterLevel): LogicNode { const children = appendTrendFilters([ makeCondition('DONCHIAN', 'CROSS_UP', 'CLOSE_PRICE', donchianUpperField(55), def), ], level, def, 'buy'); return wrapAnd(children, pairMeta('TURTLE_S2', 'buy', level)); } function buildTurtleS2Sell(def: IndicatorPeriodDef, level: StableStrategyFilterLevel): LogicNode { return wrapOrRoot([ makeCondition('DONCHIAN', 'CROSS_DOWN', 'CLOSE_PRICE', donchianLowerField(20), def), ], pairMeta('TURTLE_S2', 'sell', level)); } function buildDonchian20Buy(def: IndicatorPeriodDef, level: StableStrategyFilterLevel): LogicNode { const children = appendTrendFilters([ makeCondition('DONCHIAN', 'CROSS_UP', 'CLOSE_PRICE', donchianUpperField(20), def), ], level, def, 'buy'); return wrapAnd(children, pairMeta('DONCHIAN_20', 'buy', level)); } function buildDonchian20Sell(def: IndicatorPeriodDef, level: StableStrategyFilterLevel): LogicNode { return wrapOrRoot([ makeCondition('DONCHIAN', 'CROSS_DOWN', 'CLOSE_PRICE', donchianLowerField(20), def), ], pairMeta('DONCHIAN_20', 'sell', level)); } function maTrendPeriods(): { short: number; long: number } { return { short: 20, long: 60 }; } function buildMaTrendBuy(def: IndicatorPeriodDef, level: StableStrategyFilterLevel): LogicNode { const { short, long } = maTrendPeriods(); const nodes: LogicNode[] = [ makeCondition('MA', 'CROSS_UP', maField(short), maField(long), def), ]; if (level !== 'relaxed') { nodes.push(...appendTrendFilters([], level, def, 'buy')); } return wrapAnd(nodes, pairMeta('MA_TREND', 'buy', level)); } function buildMaTrendSell(def: IndicatorPeriodDef, level: StableStrategyFilterLevel): LogicNode { const { short, long } = maTrendPeriods(); const branches: LogicNode[] = [ makeCondition('MA', 'CROSS_DOWN', maField(short), maField(long), def), ]; if (level === 'relaxed') { branches.push(makeCondition('MA', 'LT', 'CLOSE_PRICE', maField(short), def)); } return wrapOrRoot(branches, pairMeta('MA_TREND', 'sell', level)); } function buildIchimokuTrendBuy(def: IndicatorPeriodDef, level: StableStrategyFilterLevel): LogicNode { const nodes: LogicNode[] = [ makeCondition('ICHIMOKU', 'CROSS_UP', 'CONVERSION_LINE', 'BASE_LINE', def), ]; if (level !== 'relaxed') { nodes.push(makeCondition('ICHIMOKU', 'ABOVE_CLOUD', 'CLOSE_PRICE', 'NONE', def)); } if (level === 'strict') { nodes.push(makeAdxFilter(20, def)); } return wrapAnd(nodes, pairMeta('ICHIMOKU_TREND', 'buy', level)); } function buildIchimokuTrendSell(def: IndicatorPeriodDef, level: StableStrategyFilterLevel): LogicNode { const branches: LogicNode[] = [ makeCondition('ICHIMOKU', 'CROSS_DOWN', 'CONVERSION_LINE', 'BASE_LINE', def), ]; if (level !== 'relaxed') { branches.push(makeCondition('ICHIMOKU', 'BELOW_CLOUD', 'CLOSE_PRICE', 'NONE', def)); } return wrapOrRoot(branches, pairMeta('ICHIMOKU_TREND', 'sell', level)); } function buildMacdMomentumBuy(def: IndicatorPeriodDef, level: StableStrategyFilterLevel): LogicNode { const nodes: LogicNode[] = [ makeCondition('MACD', 'CROSS_UP', 'MACD_LINE', 'SIGNAL_LINE', def), ]; if (level !== 'relaxed') { nodes.push(makeCondition('EMA', 'GT', 'CLOSE_PRICE', emaField(60), def)); nodes.push(...appendTrendFilters([], level, def, 'buy')); } return wrapAnd(nodes, pairMeta('MACD_MOMENTUM', 'buy', level)); } function buildMacdMomentumSell(def: IndicatorPeriodDef, level: StableStrategyFilterLevel): LogicNode { const branches: LogicNode[] = [ makeCondition('MACD', 'CROSS_DOWN', 'MACD_LINE', 'SIGNAL_LINE', def), ]; if (level !== 'relaxed') { branches.push(makeCondition('EMA', 'LT', 'CLOSE_PRICE', emaField(60), def)); } return wrapOrRoot(branches, pairMeta('MACD_MOMENTUM', 'sell', level)); } function buildBollingerRangeBuy(def: IndicatorPeriodDef, level: StableStrategyFilterLevel): LogicNode { const adxCap = BOLLINGER_FILTER[level]; const nodes: LogicNode[] = [ makeCondition('RSI', 'LTE', 'RSI_VALUE', 'OVERSOLD_30', def), makeCondition('BOLLINGER', 'CROSS_UP', 'CLOSE_PRICE', 'LOWER_BAND', def), ]; if (adxCap != null) { nodes.splice(1, 0, makeCondition('ADX', 'LTE', 'ADX_VALUE', adxField(adxCap), def, { targetValue: adxCap })); } return wrapAnd(nodes, pairMeta('BOLLINGER_RANGE', 'buy', level)); } function buildBollingerRangeSell(def: IndicatorPeriodDef, level: StableStrategyFilterLevel): LogicNode { return wrapOrRoot([ makeCondition('RSI', 'GTE', 'RSI_VALUE', 'OVERBOUGHT_70', def), makeCondition('BOLLINGER', 'CROSS_UP', 'CLOSE_PRICE', 'UPPER_BAND', def), ], pairMeta('BOLLINGER_RANGE', 'sell', level)); } const BUILDERS: Record LogicNode; sell: (def: IndicatorPeriodDef, level: StableStrategyFilterLevel) => LogicNode; }> = { TURTLE_S1: { buy: buildTurtleS1Buy, sell: buildTurtleS1Sell }, TURTLE_S2: { buy: buildTurtleS2Buy, sell: buildTurtleS2Sell }, DONCHIAN_20: { buy: buildDonchian20Buy, sell: buildDonchian20Sell }, MA_TREND: { buy: buildMaTrendBuy, sell: buildMaTrendSell }, ICHIMOKU_TREND: { buy: buildIchimokuTrendBuy, sell: buildIchimokuTrendSell }, MACD_MOMENTUM: { buy: buildMacdMomentumBuy, sell: buildMacdMomentumSell }, BOLLINGER_RANGE: { buy: buildBollingerRangeBuy, sell: buildBollingerRangeSell }, }; export function buildStableStrategyTree( value: string, def: IndicatorPeriodDef, filterLevel: StableStrategyFilterLevel = DEFAULT_STABLE_FILTER_LEVEL, ): LogicNode | null { const meta = stableStrategyMetaFromValue(value); if (!meta) return null; const level = resolveFilterLevel(filterLevel); const builder = BUILDERS[meta.id][meta.mode]; return builder(def, level); } export function stableStrategyDisplayName(strategyId: StableStrategyId, mode: 'buy' | 'sell'): string { const item = STABLE_STRATEGY_PALETTE_ITEMS.find( i => i.strategyId === strategyId && i.mode === mode, ); return item?.label ?? `${strategyId} ${mode === 'buy' ? '매수' : '매도'}`; } export function stableStrategySummaryText( strategyId: StableStrategyId, mode: 'buy' | 'sell', level: StableStrategyFilterLevel = DEFAULT_STABLE_FILTER_LEVEL, def: IndicatorPeriodDef = { rsiPeriod: 14, cciPeriod: 20, adxPeriod: 14, williamsR: 14, trixPeriod: 12, vrPeriod: 26, psyPeriod: 12, newPsy: 12, investPsy: 12, }, ): string { const tree = BUILDERS[strategyId][mode](def, level); const labels: Record = { CROSS_UP: '상향돌파', CROSS_DOWN: '하향돌파', GT: '>', LT: '<', GTE: '≥', LTE: '≤', ABOVE_CLOUD: '구름 위', BELOW_CLOUD: '구름 아래', }; const fmt = (n: LogicNode): string => { if (n.type === 'CONDITION' && n.condition) { const c = n.condition; return `${c.indicatorType} ${c.leftField} ${labels[c.conditionType] ?? c.conditionType} ${c.rightField ?? ''}`.trim(); } if (n.type === 'AND' || n.type === 'OR') { return (n.children ?? []).map(fmt).join(` ${n.type} `); } return ''; }; return fmt(tree); } export function stableStrategyFilterSummary( strategyId: StableStrategyId, level: StableStrategyFilterLevel, ): string { const flags = FILTER_FLAGS[level]; const parts: string[] = [STABLE_STRATEGY_FILTER_OPTIONS.find(o => o.value === level)?.label ?? level]; if (flags.adx != null) parts.push(`ADX≥${flags.adx}`); if (flags.volume) parts.push('거래량>MA'); if (strategyId === 'BOLLINGER_RANGE') { const cap = BOLLINGER_FILTER[level]; if (cap != null) parts.push(`ADX≤${cap}(횡보)`); else parts.push('ADX 횡보필터 없음'); } if (strategyId === 'ICHIMOKU_TREND' && level === 'relaxed') parts.push('구름필터 없음'); if (strategyId === 'MACD_MOMENTUM' && level === 'relaxed') parts.push('EMA60·ADX 없음'); if (strategyId === 'MA_TREND' && level === 'relaxed') parts.push('ADX 없음'); return parts.join(', '); } export function stableStrategyPaletteDesc(value: string): string { return STABLE_STRATEGY_PALETTE_ITEMS.find(i => i.value === value)?.desc ?? ''; } export function inferStableStrategyFilterLevel(node: LogicNode): StableStrategyFilterLevel { const stored = node.stableStrategyPair?.filterLevel; if (stored) return stored; const flat = (n: LogicNode): ConditionDSL[] => { if (n.type === 'CONDITION' && n.condition) return [n.condition]; return (n.children ?? []).flatMap(flat); }; const conds = flat(node); const hasVol = conds.some(c => c.indicatorType === 'VOLUME'); const adxGte = conds.find(c => c.indicatorType === 'ADX' && c.conditionType === 'GTE'); const adxVal = adxGte?.targetValue ?? (adxGte?.rightField?.match(/ADX_(\d+)/)?.[1] ? parseInt(adxGte.rightField.match(/ADX_(\d+)/)![1], 10) : null); if (hasVol || (adxVal != null && adxVal >= 25)) return 'strict'; if (adxVal != null && adxVal >= 20) return 'balanced'; const id = node.stableStrategyPair?.strategyId; if (id === 'ICHIMOKU_TREND' && !conds.some(c => c.conditionType === 'ABOVE_CLOUD' || c.conditionType === 'BELOW_CLOUD')) { return 'relaxed'; } if (id === 'MACD_MOMENTUM' && !conds.some(c => c.rightField === emaField(60))) return 'relaxed'; if (id === 'MA_TREND' && !conds.some(c => c.indicatorType === 'ADX')) return 'relaxed'; if (id === 'BOLLINGER_RANGE') { const adxLte = conds.find(c => c.indicatorType === 'ADX' && c.conditionType === 'LTE'); if (!adxLte) return 'relaxed'; } return 'balanced'; } export function setStableStrategyPairFilterLevel( node: LogicNode, filterLevel: StableStrategyFilterLevel, def: IndicatorPeriodDef, ): LogicNode { if (!isStableStrategyPairRoot(node) || !node.stableStrategyPair) return node; const { strategyId, mode } = node.stableStrategyPair; const id = strategyId as StableStrategyId; const rebuilt = BUILDERS[id][mode](def, filterLevel); return { ...rebuilt, id: node.id }; } export function patchStableStrategyPairInTree( root: LogicNode | null, pairNodeId: string, filterLevel: StableStrategyFilterLevel, def: IndicatorPeriodDef, ): LogicNode | null { if (!root) return null; if (root.id === pairNodeId && isStableStrategyPairRoot(root)) { return setStableStrategyPairFilterLevel(root, filterLevel, def); } if (!root.children?.length) return root; let changed = false; const children = root.children.map(c => { const patched = patchStableStrategyPairInTree(c, pairNodeId, filterLevel, def); if (patched !== c) changed = true; return patched ?? c; }); return changed ? { ...root, children } : root; } export function stableStrategyFilterLevelLabel(level: StableStrategyFilterLevel): string { return STABLE_STRATEGY_FILTER_OPTIONS.find(o => o.value === level)?.label ?? level; }