/** * 9·20일 신고가 매수 / 9·20일 신저가 매도 — Ta4j NH_PRIOR·NL_PRIOR + 조절 가능 필터 */ import type { ConditionDSL, LogicNode } from './strategyTypes'; import { initConditionPeriodsInherit, type IndicatorPeriodDef } from './conditionPeriods'; import { genId } from './strategyNodeIds'; import { nhPriorField, nlPriorField, syncPriceExtremeSimpleFields } from './priceExtremeIndicators'; export const NEW_HIGH_9_20_BUY_VALUE = 'NEW_HIGH_9_20_BUY'; export const NEW_LOW_9_20_SELL_VALUE = 'NEW_LOW_9_20_SELL'; export const DEFAULT_SHORT_PERIOD = 9; export const DEFAULT_LONG_PERIOD = 20; /** 필터 강도 — 전략편집기에서 조절 */ export type PriceExtremeFilterLevel = 'strict' | 'balanced' | 'relaxed'; export const DEFAULT_FILTER_LEVEL: PriceExtremeFilterLevel = 'balanced'; export const PRICE_EXTREME_FILTER_OPTIONS: { value: PriceExtremeFilterLevel; label: string; desc: string; }[] = [ { value: 'strict', label: '강함', desc: 'ADX·거래량 필터 최대 — 횡보 구간 강력 제외', }, { value: 'balanced', label: '보통 (권장)', desc: 'ADX·거래량 완화 — 돌파 신호와 균형', }, { value: 'relaxed', label: '완화', desc: '가격 돌파만 — ADX·거래량 필터 없음', }, ]; type FilterPreset = { buyAdx: number | null; sellAdx: number | null; volume: boolean; /** strict — 단기(9) CROSS_UP 동시 확인 */ shortCrossConfirm: boolean; highLowConfirm: boolean; }; const FILTER_PRESETS: Record = { strict: { buyAdx: 25, sellAdx: 20, volume: true, shortCrossConfirm: true, highLowConfirm: true }, balanced: { buyAdx: 20, sellAdx: 15, volume: true, shortCrossConfirm: false, highLowConfirm: true }, relaxed: { buyAdx: null, sellAdx: null, volume: false, shortCrossConfirm: false, highLowConfirm: false }, }; export function isNewHigh920BuyPaletteValue(value: string): boolean { return value === NEW_HIGH_9_20_BUY_VALUE; } export function isNewLow920SellPaletteValue(value: string): boolean { return value === NEW_LOW_9_20_SELL_VALUE; } export function isPriceExtremeBreakoutPairPaletteValue(value: string): boolean { return isNewHigh920BuyPaletteValue(value) || isNewLow920SellPaletteValue(value); } export function isPriceExtremeBreakoutPairRoot(node: LogicNode): boolean { return node.type === 'AND' && !!node.priceExtremePair?.mode; } function adxThresholdField(threshold: number): string { return `ADX_${threshold}`; } function makePriceExtremeCondition( indicatorType: 'NEW_HIGH' | 'NEW_LOW', conditionType: string, leftField: string, rightField: string, period: number, ): LogicNode { const base: ConditionDSL = { indicatorType, conditionType, leftField, rightField, period, candleRange: 1, valuePeriodOverride: true, thresholdOverride: false, rightPeriodOverride: false, }; return { id: genId(), type: 'CONDITION', condition: syncPriceExtremeSimpleFields(base), }; } 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?: PriceExtremeFilterLevel): PriceExtremeFilterLevel { return level ?? DEFAULT_FILTER_LEVEL; } /** 저장된 트리에서 필터 강도 추론 (레거시 strict 호환) */ export function inferPriceExtremeFilterLevel(node: LogicNode): PriceExtremeFilterLevel { const stored = node.priceExtremePair?.filterLevel; if (stored) return stored; const children = node.children ?? []; const hasAdx = children.some(c => c.condition?.indicatorType === 'ADX'); const hasVol = children.some(c => c.condition?.indicatorType === 'VOLUME'); if (!hasAdx && !hasVol) return 'relaxed'; const adxCond = children.find(c => c.condition?.indicatorType === 'ADX')?.condition; const adxVal = adxCond?.targetValue ?? (adxCond?.rightField?.match(/ADX_(\d+)/)?.[1] ? parseInt(adxCond.rightField.match(/ADX_(\d+)/)![1], 10) : null); if (adxVal != null && adxVal >= 25) return 'strict'; if (adxVal != null && adxVal >= 20) return 'balanced'; return 'balanced'; } function buildPriceChildren( mode: 'buy' | 'sell', short: number, long: number, preset: FilterPreset, ): LogicNode[] { const ind = mode === 'buy' ? 'NEW_HIGH' : 'NEW_LOW'; const crossType = mode === 'buy' ? 'CROSS_UP' : 'CROSS_DOWN'; const priorShort = mode === 'buy' ? nhPriorField(short) : nlPriorField(short); const priorLong = mode === 'buy' ? nhPriorField(long) : nlPriorField(long); const nodes: LogicNode[] = []; if (preset.shortCrossConfirm) { nodes.push(makePriceExtremeCondition(ind, crossType, 'CLOSE_PRICE', priorShort, short)); } // 장기 신고가/신저가 — 돌파 순간 1봉 (GTE 아님 → 연속 시그널 방지) nodes.push(makePriceExtremeCondition(ind, crossType, 'CLOSE_PRICE', priorLong, long)); if (preset.highLowConfirm) { nodes.push(makePriceExtremeCondition( ind, mode === 'buy' ? 'GTE' : 'LTE', mode === 'buy' ? 'HIGH_PRICE' : 'LOW_PRICE', priorLong, long, )); } return nodes; } function buildFilterChildren( mode: 'buy' | 'sell', preset: FilterPreset, 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) { nodes.push(makeFilterCondition( 'VOLUME', 'GT', 'VOLUME_VALUE', 'VOLUME_MA', def, )); } return nodes; } /** 9·20일 신고가 매수 */ export function buildNewHigh920BuyTree( def: IndicatorPeriodDef, shortPeriod = DEFAULT_SHORT_PERIOD, longPeriod = DEFAULT_LONG_PERIOD, filterLevel: PriceExtremeFilterLevel = DEFAULT_FILTER_LEVEL, ): LogicNode { const short = Math.max(1, Math.round(shortPeriod)); const long = Math.max(short + 1, Math.round(longPeriod)); const level = resolveFilterLevel(filterLevel); const preset = FILTER_PRESETS[level]; return { id: genId(), type: 'AND', priceExtremePair: { mode: 'buy', shortPeriod: short, longPeriod: long, filterLevel: level }, children: [ ...buildPriceChildren('buy', short, long, preset), ...buildFilterChildren('buy', preset, def), ], }; } /** 9·20일 신저가 매도 */ export function buildNewLow920SellTree( def: IndicatorPeriodDef, shortPeriod = DEFAULT_SHORT_PERIOD, longPeriod = DEFAULT_LONG_PERIOD, filterLevel: PriceExtremeFilterLevel = DEFAULT_FILTER_LEVEL, ): LogicNode { const short = Math.max(1, Math.round(shortPeriod)); const long = Math.max(short + 1, Math.round(longPeriod)); const level = resolveFilterLevel(filterLevel); const preset = FILTER_PRESETS[level]; return { id: genId(), type: 'AND', priceExtremePair: { mode: 'sell', shortPeriod: short, longPeriod: long, filterLevel: level }, children: [ ...buildPriceChildren('sell', short, long, preset), ...buildFilterChildren('sell', preset, def), ], }; } export function buildPriceExtremeBreakoutTree( value: string, def: IndicatorPeriodDef, filterLevel: PriceExtremeFilterLevel = DEFAULT_FILTER_LEVEL, ): LogicNode | null { if (isNewHigh920BuyPaletteValue(value)) return buildNewHigh920BuyTree(def, DEFAULT_SHORT_PERIOD, DEFAULT_LONG_PERIOD, filterLevel); if (isNewLow920SellPaletteValue(value)) return buildNewLow920SellTree(def, DEFAULT_SHORT_PERIOD, DEFAULT_LONG_PERIOD, filterLevel); return null; } export function filterLevelLabel(level: PriceExtremeFilterLevel): string { return PRICE_EXTREME_FILTER_OPTIONS.find(o => o.value === level)?.label ?? level; } export function filterLevelDesc(level: PriceExtremeFilterLevel): string { return PRICE_EXTREME_FILTER_OPTIONS.find(o => o.value === level)?.desc ?? ''; } export function priceExtremePairDisplayName( mode: 'buy' | 'sell', short = DEFAULT_SHORT_PERIOD, long = DEFAULT_LONG_PERIOD, ): string { return mode === 'buy' ? `${short}·${long}일 신고가 매수` : `${short}·${long}일 신저가 매도`; } export function priceExtremeFilterSummary( mode: 'buy' | 'sell', level: PriceExtremeFilterLevel = DEFAULT_FILTER_LEVEL, ): string { const preset = FILTER_PRESETS[level]; const parts: string[] = []; const adx = mode === 'buy' ? preset.buyAdx : preset.sellAdx; if (adx != null) parts.push(`ADX≥${adx}`); else parts.push('ADX 필터 없음'); if (preset.volume) parts.push('거래량>MA'); else parts.push('거래량 필터 없음'); if (!preset.highLowConfirm) parts.push('고/저가 확인 생략'); return parts.join(', '); } export function priceExtremePairSummaryText( mode: 'buy' | 'sell', short = DEFAULT_SHORT_PERIOD, long = DEFAULT_LONG_PERIOD, filterLevel: PriceExtremeFilterLevel = DEFAULT_FILTER_LEVEL, ): string { const preset = FILTER_PRESETS[filterLevel]; const lines: string[] = []; if (preset.shortCrossConfirm) { lines.push(`종가 ${short}일 ${mode === 'buy' ? '신고가' : '신저가'} ${mode === 'buy' ? '상향돌파' : '하향이탈'}`); } lines.push(`종가 ${long}일 ${mode === 'buy' ? '신고가 상향돌파' : '신저가 하향이탈'}`); if (preset.highLowConfirm) { lines.push(`${mode === 'buy' ? '고가' : '저가'}${mode === 'buy' ? '≥' : '≤'}${long}일선`); } const adx = mode === 'buy' ? preset.buyAdx : preset.sellAdx; if (adx != null) lines.push(`ADX≥${adx}`); if (preset.volume) lines.push('거래량>MA'); return lines.join(' AND '); } export function priceExtremePairPaletteDesc(value: string): string { if (isNewHigh920BuyPaletteValue(value)) { return '9·20일 신고가 돌파 + 조절 가능 ADX·거래량 필터 (기본: 보통)'; } if (isNewLow920SellPaletteValue(value)) { return '9·20일 신저가 이탈 + 조절 가능 ADX·거래량 필터 (기본: 보통)'; } return ''; } /** 필터 강도 변경 — 노드 id·기간 유지, 자식 조건 재구성 */ export function setPriceExtremePairFilterLevel( node: LogicNode, filterLevel: PriceExtremeFilterLevel, def: IndicatorPeriodDef, ): LogicNode { if (!isPriceExtremeBreakoutPairRoot(node) || !node.priceExtremePair) return node; const { mode, shortPeriod, longPeriod } = node.priceExtremePair; const rebuilt = mode === 'buy' ? buildNewHigh920BuyTree(def, shortPeriod, longPeriod, filterLevel) : buildNewLow920SellTree(def, shortPeriod, longPeriod, filterLevel); return { ...rebuilt, id: node.id }; } function nodeContainsId(node: LogicNode, targetId: string): boolean { if (node.id === targetId) return true; return (node.children ?? []).some(c => nodeContainsId(c, targetId)); } export function findPriceExtremePairRootContaining( node: LogicNode | null | undefined, targetId: string, ): LogicNode | null { if (!node) return null; if (isPriceExtremeBreakoutPairRoot(node) && nodeContainsId(node, targetId)) return node; for (const child of node.children ?? []) { const hit = findPriceExtremePairRootContaining(child, targetId); if (hit) return hit; } return null; } export function findPriceExtremePairRootInForest( roots: (LogicNode | null | undefined)[], targetId: string, ): LogicNode | null { for (const root of roots) { const hit = findPriceExtremePairRootContaining(root, targetId); if (hit) return hit; } return null; } export function patchPriceExtremePairInTree( root: LogicNode | null, andNodeId: string, filterLevel: PriceExtremeFilterLevel, def: IndicatorPeriodDef, ): LogicNode | null { if (!root) return null; if (root.id === andNodeId && isPriceExtremeBreakoutPairRoot(root)) { return setPriceExtremePairFilterLevel(root, filterLevel, def); } if (!root.children?.length) return root; let changed = false; const children = root.children.map(c => { const patched = patchPriceExtremePairInTree(c, andNodeId, filterLevel, def); if (patched !== c) changed = true; return patched ?? c; }); return changed ? { ...root, children } : root; } /** GTE/LTE 종가 조건 → CROSS 돌파 1봉 (과다 시그널·레거시 DSL 수리) */ export function repairPriceExtremePairEntryEdges(node: LogicNode): LogicNode { if (!isPriceExtremeBreakoutPairRoot(node)) return node; const mode = node.priceExtremePair!.mode; const children = (node.children ?? []).map(child => { if (child.type !== 'CONDITION' || !child.condition) return child; const c = child.condition; if (c.indicatorType !== 'NEW_HIGH' && c.indicatorType !== 'NEW_LOW') return child; if (c.leftField === 'HIGH_PRICE' || c.leftField === 'LOW_PRICE') return child; if (c.leftField !== 'CLOSE_PRICE') return child; if (mode === 'buy' && c.conditionType === 'GTE') { return { ...child, condition: { ...c, conditionType: 'CROSS_UP' } }; } if (mode === 'sell' && c.conditionType === 'LTE') { return { ...child, condition: { ...c, conditionType: 'CROSS_DOWN' } }; } return child; }); return { ...node, children }; } function repairPriceExtremePairInTree(node: LogicNode): LogicNode { let next = isPriceExtremeBreakoutPairRoot(node) ? repairPriceExtremePairEntryEdges(node) : node; if (next.children?.length) { next = { ...next, children: next.children.map(repairPriceExtremePairInTree), }; } return next; } /** 전략 로드 시 9·20 복합 — GTE/LTE 종가 조건을 CROSS 로 수리 */ export function repairPriceExtremePairForest( root: LogicNode | null, orphans: LogicNode[] = [], ): { root: LogicNode | null; orphans: LogicNode[] } { return { root: root ? repairPriceExtremePairInTree(root) : null, orphans: orphans.map(repairPriceExtremePairInTree), }; }