/** * 33변곡 전략 — Ta4j IsRising/IsFalling(EMA33) + 종가/EMA 필터 + 33봉 종가 채널 돌파 * * 매수 AND: EMA33 상승전환 + (선택) 종가>EMA33 + 33봉 종가최고 돌파 + (선택) ADX·거래량 * 매도 OR: (EMA33 하락전환 ∧ 종가 = { strict: { slopeBars: 2, requireCloseAboveEma: true, buyAdx: 25, sellAdx: 20, volume: true, sellEmaBranch: true, sellChannelBranch: true, sellRequireCloseBelowEma: true, }, balanced: { slopeBars: 2, requireCloseAboveEma: true, buyAdx: null, sellAdx: null, volume: false, sellEmaBranch: true, sellChannelBranch: true, sellRequireCloseBelowEma: true, }, relaxed: { slopeBars: 1, requireCloseAboveEma: false, buyAdx: null, sellAdx: null, volume: false, sellEmaBranch: false, sellChannelBranch: true, sellRequireCloseBelowEma: false, }, }; export function emaField(period: number): string { return `EMA${Math.max(1, Math.round(period))}`; } /** ta4j HighestValueIndicator(close, N) — 33봉 종가 채널 상단 */ export function closeMaxField(period: number): string { return `CLOSE_MAX_${Math.max(1, Math.round(period))}`; } /** ta4j LowestValueIndicator(close, N) — 33봉 종가 채널 하단 */ export function closeMinField(period: number): string { return `CLOSE_MIN_${Math.max(1, Math.round(period))}`; } export function isInflection33BuyPaletteValue(value: string): boolean { return value === INFLECTION_33_BUY_VALUE; } export function isInflection33SellPaletteValue(value: string): boolean { return value === INFLECTION_33_SELL_VALUE; } export function isInflection33PaletteValue(value: string): boolean { return isInflection33BuyPaletteValue(value) || isInflection33SellPaletteValue(value); } export function isInflection33PairRoot(node: LogicNode): boolean { return !!node.inflection33Pair?.mode && (node.type === 'AND' || node.type === 'OR'); } function adxThresholdField(threshold: number): string { return `ADX_${threshold}`; } 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 makeFilterCondition( indicatorType: string, conditionType: string, leftField: string, rightField: string, def: IndicatorPeriodDef, ): LogicNode { const base: ConditionDSL = { indicatorType, conditionType, leftField, rightField, candleRange: 1, valuePeriodOverride: false, thresholdOverride: rightField.startsWith('ADX_'), rightPeriodOverride: false, ...(rightField.startsWith('ADX_') ? { targetValue: parseInt(rightField.split('_')[1], 10) } : null), }; return { id: genId(), type: 'CONDITION', condition: initConditionPeriodsInherit(indicatorType, base, def), }; } function resolveFilterLevel(level?: Inflection33FilterLevel): Inflection33FilterLevel { return level ?? DEFAULT_INFLECTION33_FILTER_LEVEL; } function flattenConditions(node: LogicNode): ConditionDSL[] { if (node.type === 'CONDITION' && node.condition) return [node.condition]; return (node.children ?? []).flatMap(flattenConditions); } function findSlopeBars(node: LogicNode, mode: 'buy' | 'sell'): number | null { const slopeType = mode === 'buy' ? 'SLOPE_UP' : 'SLOPE_DOWN'; for (const c of flattenConditions(node)) { if (c.conditionType === slopeType && c.slopePeriod != null) { return c.slopePeriod; } } return null; } /** 저장된 트리에서 필터 강도 추론 (레거시 balanced 호환) */ export function inferInflection33FilterLevel(node: LogicNode): Inflection33FilterLevel { const stored = node.inflection33Pair?.filterLevel; if (stored) return stored; const mode = node.inflection33Pair?.mode; if (!mode) return DEFAULT_INFLECTION33_FILTER_LEVEL; const conds = flattenConditions(node); const hasAdx = conds.some(c => c.indicatorType === 'ADX'); const hasVol = conds.some(c => c.indicatorType === 'VOLUME'); const adxCond = conds.find(c => c.indicatorType === 'ADX'); const adxVal = adxCond?.targetValue ?? (adxCond?.rightField?.match(/ADX_(\d+)/)?.[1] ? parseInt(adxCond.rightField.match(/ADX_(\d+)/)![1], 10) : null); if (mode === 'buy') { if (hasAdx && adxVal != null && adxVal >= 25) return 'strict'; const slope = findSlopeBars(node, 'buy'); const hasCloseAboveEma = conds.some( c => c.conditionType === 'GT' && c.leftField === 'CLOSE_PRICE' && c.rightField?.startsWith('EMA'), ); if (slope === 1 || !hasCloseAboveEma) return 'relaxed'; if (hasAdx || hasVol) return 'strict'; return 'balanced'; } const topChildren = node.children ?? []; const hasEmaBranch = topChildren.some(ch => { const inner = flattenConditions(ch); return inner.some(c => c.conditionType === 'SLOPE_DOWN'); }); const hasChannelBranch = topChildren.some(ch => { const inner = flattenConditions(ch); return inner.some(c => c.conditionType === 'CROSS_DOWN' && c.rightField?.startsWith('CLOSE_MIN_')); }); if (!hasEmaBranch && hasChannelBranch) return 'relaxed'; if (hasAdx && adxVal != null && adxVal >= 20) return 'strict'; return 'balanced'; } function buildBuyPriceChildren( preset: Inflection33Preset, period: number, def: IndicatorPeriodDef, ): LogicNode[] { const ema = emaField(period); const maxF = closeMaxField(period); const nodes: LogicNode[] = [ makeCondition('EMA', 'SLOPE_UP', ema, 'NONE', def, { slopePeriod: preset.slopeBars }), ]; if (preset.requireCloseAboveEma) { nodes.push(makeCondition('EMA', 'GT', 'CLOSE_PRICE', ema, def)); } nodes.push(makeCondition('EMA', 'CROSS_UP', 'CLOSE_PRICE', maxF, def)); return nodes; } function buildFilterChildren( mode: 'buy' | 'sell', preset: Inflection33Preset, def: IndicatorPeriodDef, ): LogicNode[] { const nodes: LogicNode[] = []; const adx = mode === 'buy' ? preset.buyAdx : preset.sellAdx; if (adx != null) { nodes.push(makeFilterCondition('ADX', 'GTE', 'ADX_VALUE', adxThresholdField(adx), def)); } if (preset.volume && mode === 'buy') { nodes.push(makeFilterCondition('VOLUME', 'GT', 'VOLUME_VALUE', 'VOLUME_MA', def)); } return nodes; } function wrapAnd(children: LogicNode[]): LogicNode { if (children.length === 1) return children[0]; return { id: genId(), type: 'AND', children }; } function buildSellBranchChildren( preset: Inflection33Preset, period: number, def: IndicatorPeriodDef, ): LogicNode[] { const ema = emaField(period); const minF = closeMinField(period); const branches: LogicNode[] = []; if (preset.sellEmaBranch) { const emaChildren: LogicNode[] = [ makeCondition('EMA', 'SLOPE_DOWN', ema, 'NONE', def, { slopePeriod: preset.slopeBars }), ]; if (preset.sellRequireCloseBelowEma) { emaChildren.push(makeCondition('EMA', 'LT', 'CLOSE_PRICE', ema, def)); } if (preset.sellAdx != null) { emaChildren.push(makeFilterCondition('ADX', 'GTE', 'ADX_VALUE', adxThresholdField(preset.sellAdx), def)); } branches.push(wrapAnd(emaChildren)); } if (preset.sellChannelBranch) { const channelChildren: LogicNode[] = [ makeCondition('EMA', 'CROSS_DOWN', 'CLOSE_PRICE', minF, def), ]; if (preset.sellAdx != null) { channelChildren.push(makeFilterCondition('ADX', 'GTE', 'ADX_VALUE', adxThresholdField(preset.sellAdx), def)); } branches.push(wrapAnd(channelChildren)); } return branches; } /** 33 EMA 상승 변곡 + (선택) 종가 EMA 위 + 33봉 종가 신고가 돌파 */ export function buildInflection33BuyTree( def: IndicatorPeriodDef, period = DEFAULT_INFLECTION_PERIOD, filterLevel: Inflection33FilterLevel = DEFAULT_INFLECTION33_FILTER_LEVEL, ): LogicNode { const p = Math.max(1, Math.round(period)); const level = resolveFilterLevel(filterLevel); const preset = FILTER_PRESETS[level]; return { id: genId(), type: 'AND', inflection33Pair: { mode: 'buy', period: p, filterLevel: level }, children: [ ...buildBuyPriceChildren(preset, p, def), ...buildFilterChildren('buy', preset, def), ], }; } /** (EMA 하락 변곡 ∧ 종가 EMA 아래) OR 33봉 종가 신저가 이탈 */ export function buildInflection33SellTree( def: IndicatorPeriodDef, period = DEFAULT_INFLECTION_PERIOD, filterLevel: Inflection33FilterLevel = DEFAULT_INFLECTION33_FILTER_LEVEL, ): LogicNode { const p = Math.max(1, Math.round(period)); const level = resolveFilterLevel(filterLevel); const preset = FILTER_PRESETS[level]; return { id: genId(), type: 'OR', inflection33Pair: { mode: 'sell', period: p, filterLevel: level }, children: buildSellBranchChildren(preset, p, def), }; } export function buildInflection33Tree( value: string, def: IndicatorPeriodDef, filterLevel: Inflection33FilterLevel = DEFAULT_INFLECTION33_FILTER_LEVEL, ): LogicNode | null { if (isInflection33BuyPaletteValue(value)) return buildInflection33BuyTree(def, DEFAULT_INFLECTION_PERIOD, filterLevel); if (isInflection33SellPaletteValue(value)) return buildInflection33SellTree(def, DEFAULT_INFLECTION_PERIOD, filterLevel); return null; } export function inflection33FilterLevelLabel(level: Inflection33FilterLevel): string { return INFLECTION_33_FILTER_OPTIONS.find(o => o.value === level)?.label ?? level; } export function inflection33FilterLevelDesc(level: Inflection33FilterLevel): string { return INFLECTION_33_FILTER_OPTIONS.find(o => o.value === level)?.desc ?? ''; } export function inflection33FilterSummary( mode: 'buy' | 'sell', level: Inflection33FilterLevel = DEFAULT_INFLECTION33_FILTER_LEVEL, ): string { const preset = FILTER_PRESETS[level]; const parts: string[] = []; parts.push(`EMA 변곡 ${preset.slopeBars}봉`); if (mode === 'buy') { if (preset.requireCloseAboveEma) parts.push('종가>EMA'); else parts.push('종가 EMA 필터 없음'); const adx = preset.buyAdx; if (adx != null) parts.push(`ADX≥${adx}`); else parts.push('ADX 필터 없음'); if (preset.volume) parts.push('거래량>MA'); else parts.push('거래량 필터 없음'); } else { if (preset.sellEmaBranch) { parts.push(preset.sellRequireCloseBelowEma ? 'EMA하락+종가EMA33'); lines.push(`${period}봉 종가최고 돌파`); if (preset.buyAdx != null) lines.push(`ADX≥${preset.buyAdx}`); if (preset.volume) lines.push('거래량>MA'); return lines.join(' AND '); } const parts: string[] = []; if (preset.sellEmaBranch) { const emaPart = preset.sellRequireCloseBelowEma ? `${ema} 하락전환 AND 종가 nodeContainsId(c, targetId)); } export function findInflection33PairRootContaining( node: LogicNode | null | undefined, targetId: string, ): LogicNode | null { if (!node) return null; if (isInflection33PairRoot(node) && nodeContainsId(node, targetId)) return node; for (const child of node.children ?? []) { const hit = findInflection33PairRootContaining(child, targetId); if (hit) return hit; } return null; } export function patchInflection33PairInTree( root: LogicNode | null, pairNodeId: string, filterLevel: Inflection33FilterLevel, def: IndicatorPeriodDef, ): LogicNode | null { if (!root) return null; if (root.id === pairNodeId && isInflection33PairRoot(root)) { return setInflection33PairFilterLevel(root, filterLevel, def); } if (!root.children?.length) return root; let changed = false; const children = root.children.map(c => { const patched = patchInflection33PairInTree(c, pairNodeId, filterLevel, def); if (patched !== c) changed = true; return patched ?? c; }); return changed ? { ...root, children } : root; }