/** Stochastic 과열(≥80) × 보조지표 중앙선 — 순차 OR 복합 매수 조건 */ import type { ConditionDSL, LogicNode } from './strategyTypes'; import { initConditionPeriodsInherit, type IndicatorPeriodDef } from './conditionPeriods'; import { genId } from './strategyNodeIds'; import { THRESHOLD_HL_MID, THRESHOLD_HL_OVER } from './thresholdSymbols'; export const STOCH_OVERBOUGHT_PAIR_VALUE = 'STOCH_OVERBOUGHT_PAIR'; export const STOCH_PAIR_SECONDARY_OPTIONS: { value: string; label: string }[] = [ { value: 'CCI', label: 'CCI' }, { value: 'RSI', label: 'RSI' }, { value: 'ADX', label: 'ADX' }, { value: 'WILLIAMS_R', label: 'Williams %R' }, { value: 'TRIX', label: 'TRIX' }, { value: 'VR', label: 'VR' }, { value: 'PSYCHOLOGICAL', label: '심리도' }, { value: 'NEW_PSYCHOLOGICAL', label: '신심리도' }, { value: 'INVEST_PSYCHOLOGICAL', label: '투자심리도' }, { value: 'BWI', label: 'BWI' }, { value: 'VOLUME_OSC', label: 'Volume OSC' }, ]; const SECONDARY_VALUE_FIELD: Record = { RSI: 'RSI_VALUE', CCI: 'CCI_VALUE', ADX: 'ADX_VALUE', WILLIAMS_R: 'WILLIAMS_R_VALUE', TRIX: 'TRIX_VALUE', VR: 'VR_VALUE', PSYCHOLOGICAL: 'PSY_VALUE', NEW_PSYCHOLOGICAL: 'NEW_PSY_VALUE', INVEST_PSYCHOLOGICAL: 'INVEST_PSY_VALUE', BWI: 'BWI_VALUE', VOLUME_OSC: 'VOLUME_OSC_VALUE', }; function secondaryLabel(indicatorType: string): string { return STOCH_PAIR_SECONDARY_OPTIONS.find(o => o.value === indicatorType)?.label ?? indicatorType; } export function isStochOverboughtPairPaletteValue(value: string): boolean { return value === STOCH_OVERBOUGHT_PAIR_VALUE; } export function isStochOverboughtPairRoot(node: LogicNode): boolean { return node.type === 'OR' && !!node.stochPair?.secondaryIndicatorType; } export function getSecondaryValueField(indicatorType: string): string { return SECONDARY_VALUE_FIELD[indicatorType] ?? `${indicatorType}_VALUE`; } function makePairCondition( indicatorType: string, conditionType: string, leftField: string, rightField: string, def: IndicatorPeriodDef, ): LogicNode { const base: ConditionDSL = { indicatorType, conditionType, leftField, rightField, candleRange: 1, valuePeriodOverride: false, thresholdOverride: false, rightPeriodOverride: false, }; return { id: genId(), type: 'CONDITION', condition: initConditionPeriodsInherit(indicatorType, base, def), }; } /** (Stoch≥과열 AND 보조 CROSS_UP 중앙) OR (보조≥중앙 AND Stoch CROSS_UP 과열) */ export function buildStochOverboughtPairTree( secondaryIndicator: string, def: IndicatorPeriodDef, ): LogicNode { const sec = STOCH_PAIR_SECONDARY_OPTIONS.some(o => o.value === secondaryIndicator) ? secondaryIndicator : 'CCI'; const secField = getSecondaryValueField(sec); return { id: genId(), type: 'OR', stochPair: { secondaryIndicatorType: sec }, children: [ { id: genId(), type: 'AND', children: [ makePairCondition('STOCHASTIC', 'GTE', 'STOCH_K', THRESHOLD_HL_OVER, def), makePairCondition(sec, 'CROSS_UP', secField, THRESHOLD_HL_MID, def), ], }, { id: genId(), type: 'AND', children: [ makePairCondition(sec, 'GTE', secField, THRESHOLD_HL_MID, def), makePairCondition('STOCHASTIC', 'CROSS_UP', 'STOCH_K', THRESHOLD_HL_OVER, def), ], }, ], }; } /** 보조지표 변경 시 기존 OR·AND 노드 id 유지 */ export function setStochPairSecondary( root: LogicNode, secondaryIndicator: string, def: IndicatorPeriodDef, ): LogicNode { if (!isStochOverboughtPairRoot(root)) return root; const rebuilt = buildStochOverboughtPairTree(secondaryIndicator, def); const preserveIds = (next: LogicNode, prev: LogicNode | undefined): LogicNode => ({ ...next, id: prev?.id ?? next.id, children: next.children?.map((child, i) => preserveIds(child, prev?.children?.[i]), ), }); return preserveIds(rebuilt, root); } export function stochPairDisplayName(secondaryIndicator: string): string { return `Stoch 과열×${secondaryLabel(secondaryIndicator)}`; } export function stochPairSummaryText(secondaryIndicator: string): string { const sec = secondaryLabel(secondaryIndicator); return `(Stoch≥과열 AND ${sec} 중앙 상향) OR (${sec}≥중앙 AND Stoch 과열 상향)`; } export function stochPairPaletteDesc(secondaryIndicator: string): string { const sec = secondaryLabel(secondaryIndicator); return `Stoch≥80 + ${sec} 0선(중앙) 돌파 또는 ${sec}≥중앙 + Stoch 과열 돌파`; } function nodeContainsId(node: LogicNode, targetId: string): boolean { if (node.id === targetId) return true; return (node.children ?? []).some(c => nodeContainsId(c, targetId)); } /** nodeId를 포함하는 Stoch 과열×보조 OR 루트 탐색 */ export function findStochPairRootContaining( node: LogicNode | null | undefined, targetId: string, ): LogicNode | null { if (!node) return null; if (isStochOverboughtPairRoot(node) && nodeContainsId(node, targetId)) { return node; } for (const child of node.children ?? []) { const hit = findStochPairRootContaining(child, targetId); if (hit) return hit; } return null; } export function findStochPairRootInForest( roots: (LogicNode | null | undefined)[], targetId: string, ): LogicNode | null { for (const root of roots) { const hit = findStochPairRootContaining(root, targetId); if (hit) return hit; } return null; } /** OR 서브트리 전체를 새 보조지표로 재구성 (노드 id 유지) */ export function patchStochPairInTree( root: LogicNode | null, orNodeId: string, secondaryIndicator: string, def: IndicatorPeriodDef, ): LogicNode | null { if (!root) return null; if (root.id === orNodeId && isStochOverboughtPairRoot(root)) { return setStochPairSecondary(root, secondaryIndicator, def); } if (!root.children?.length) return root; return { ...root, children: root.children.map(c => patchStochPairInTree(c, orNodeId, secondaryIndicator, def) ?? c), }; }