/** 신고가·신저가 — 단일(보조) 지표: 종가/고가 vs 직전 N봉 최고·최저 */ import type { ConditionDSL } from './strategyTypes'; import { parseDonchianPeriod } from './compositeIndicators'; export function isPriceExtremeIndicator(ind?: string): boolean { return ind === 'NEW_HIGH' || ind === 'NEW_LOW'; } export function nhPriorField(period: number): string { return `NH_PRIOR_${Math.max(1, Math.round(period))}`; } export function nlPriorField(period: number): string { return `NL_PRIOR_${Math.max(1, Math.round(period))}`; } export function parsePriorExtremePeriod(field?: string): number | null { if (!field) return null; const m = field.match(/^(?:NH|NL)_PRIOR_(\d+)$/); if (m) { const n = parseInt(m[1], 10); return Number.isFinite(n) && n > 0 ? n : null; } return parseDonchianPeriod(field); } export function priorExtremeField(ind: string, period: number): string { return ind === 'NEW_LOW' ? nlPriorField(period) : nhPriorField(period); } /** 단일 지표 — 기간에 맞춰 직전 N봉 신고가/신저가선 필드 동기화 */ export function syncPriceExtremeSimpleFields(cond: ConditionDSL): ConditionDSL { if (!isPriceExtremeIndicator(cond.indicatorType)) return cond; const period = cond.period ?? parsePriorExtremePeriod(cond.rightField) ?? parsePriorExtremePeriod(cond.leftField) ?? 9; const p = Math.max(1, Math.min(500, Math.round(period))); const band = priorExtremeField(cond.indicatorType, p); const priceFields = new Set(['CLOSE_PRICE', 'HIGH_PRICE', 'LOW_PRICE', 'OPEN_PRICE']); let leftField = cond.leftField; let rightField = cond.rightField; if (cond.indicatorType === 'NEW_HIGH') { leftField = leftField && priceFields.has(leftField) ? leftField : 'CLOSE_PRICE'; rightField = band; } else { leftField = leftField && priceFields.has(leftField) ? leftField : 'CLOSE_PRICE'; rightField = band; } return { ...cond, composite: false, period: p, leftPeriod: undefined, rightPeriod: undefined, leftCandleType: undefined, rightCandleType: undefined, leftField, rightField, }; } /** 저장 DSL — 복합지표 승격·DC_UPPER_N 필드를 단일 신고가/신저가로 정규화 */ export function migratePriceExtremeCondition(cond: ConditionDSL): ConditionDSL { const ind = cond.indicatorType; if (!isPriceExtremeIndicator(ind)) return cond; if (cond.composite && isPriceExtremeIndicator(ind)) { const period = cond.leftPeriod ?? cond.rightPeriod ?? 9; const priceFields = new Set(['CLOSE_PRICE', 'HIGH_PRICE', 'LOW_PRICE', 'OPEN_PRICE']); const leftField = cond.leftField && priceFields.has(cond.leftField) ? cond.leftField : 'CLOSE_PRICE'; const rightField = priorExtremeField(ind, period); let conditionType = cond.conditionType; if (conditionType === 'CROSS_UP' && ind === 'NEW_HIGH' && !rightField.startsWith('NH_PRIOR_')) { conditionType = 'GTE'; } else if (conditionType === 'CROSS_DOWN' && ind === 'NEW_LOW' && !rightField.startsWith('NL_PRIOR_')) { conditionType = 'LTE'; } return syncPriceExtremeSimpleFields({ ...cond, composite: false, leftField, rightField, period, conditionType, }); } if (!isPriceExtremeIndicator(ind)) return cond; const fromRight = parsePriorExtremePeriod(cond.rightField); const fromLeft = parsePriorExtremePeriod(cond.leftField); const period = cond.period ?? fromRight ?? fromLeft ?? 9; let migrated = syncPriceExtremeSimpleFields({ ...cond, period }); if (migrated.rightField?.startsWith('DC_UPPER_') || migrated.rightField?.startsWith('DC_LOWER_')) { migrated = syncPriceExtremeSimpleFields({ ...migrated, period: parseDonchianPeriod(migrated.rightField) ?? period, }); } if (migrated.conditionType === 'CROSS_UP' && ind === 'NEW_HIGH' && !migrated.rightField?.startsWith('NH_PRIOR_')) { migrated = { ...migrated, conditionType: 'GTE' }; } if (migrated.conditionType === 'CROSS_DOWN' && ind === 'NEW_LOW' && !migrated.rightField?.startsWith('NL_PRIOR_')) { migrated = { ...migrated, conditionType: 'LTE' }; } // 종가 vs NH/NL_PRIOR — GTE/LTE 유지형은 봉마다 시그널 → CROSS 돌파 1봉으로 정규화 if (ind === 'NEW_HIGH' && migrated.leftField === 'CLOSE_PRICE' && migrated.conditionType === 'GTE' && migrated.rightField?.startsWith('NH_PRIOR_')) { migrated = { ...migrated, conditionType: 'CROSS_UP' }; } if (ind === 'NEW_LOW' && migrated.leftField === 'CLOSE_PRICE' && migrated.conditionType === 'LTE' && migrated.rightField?.startsWith('NL_PRIOR_')) { migrated = { ...migrated, conditionType: 'CROSS_DOWN' }; } return migrated; } export function priceExtremePeriodLabel(ind: string, period: number): string { return ind === 'NEW_LOW' ? `${period}일 신저가선` : `${period}일 신고가선`; }