/** 복합지표 — 동일 지표 2개(서로 다른 기간) 간 교차·비교 조건 */ import type { ConditionDSL } from './strategyTypes'; import { DEFAULT_START_CANDLE, normalizeStartCandleType } from './strategyStartNodes'; import { migratePriceExtremeCondition } from './priceExtremeIndicators'; export interface CompositePeriodDef { rsiPeriod: number; cciPeriod: number; adxPeriod: number; williamsR: number; trixPeriod: number; vrPeriod: number; psyPeriod: number; newPsy: number; investPsy: number; dispShort: number; dispMid: number; } export interface CompositeIndicatorItem { value: string; label: string; desc: string; } /** 돈치안 채널 — 단기·장기 고가/저가선 비교 */ export function isDonchianComposite(ind: string): boolean { return ind === 'DONCHIAN'; } /** @deprecated DONCHIAN 전용 — isDonchianComposite 사용 */ export function isPriceExtremeComposite(ind: string): boolean { return isDonchianComposite(ind); } export function donchianUpperField(period: number): string { return `DC_UPPER_${period}`; } export function donchianLowerField(period: number): string { return `DC_LOWER_${period}`; } export function donchianMiddleField(period: number): string { return `DC_MIDDLE_${period}`; } /** DC_UPPER_9 / DC_LOWER_20 등에서 기간 추출 */ export function parseDonchianPeriod(field?: string): number | null { if (!field) return null; const m = field.match(/^DC_(?:UPPER|LOWER|MIDDLE)_(\d+)$/); if (!m) return null; const n = parseInt(m[1], 10); return Number.isFinite(n) && n > 0 ? n : null; } /** 복합지표 팔레트 */ export const COMPOSITE_INDICATOR_ITEMS: CompositeIndicatorItem[] = [ { value: 'DONCHIAN', label: '돈치안 채널', desc: '단기·장기 고가/저가 채널 비교 (9·20일)' }, { value: 'RSI', label: 'RSI', desc: 'RSI 기간 교차' }, { value: 'CCI', label: 'CCI', desc: 'CCI 기간 교차' }, { value: 'ADX', label: 'ADX', desc: 'ADX 기간 교차' }, { value: 'WILLIAMS_R', label: 'Williams %R', desc: 'Williams %R 교차' }, { value: 'TRIX', label: 'TRIX', desc: 'TRIX 기간 교차' }, { value: 'VR', label: 'VR', desc: 'VR 기간 교차' }, { value: 'PSYCHOLOGICAL', label: '심리도', desc: '심리도 기간 교차' }, { value: 'INVEST_PSYCHOLOGICAL', label: '투자심리도', desc: '투자심리도 교차' }, { value: 'DISPARITY', label: '이격도', desc: '이격도 기간 교차' }, ]; export function getCompositeDefaultPeriods(ind: string, def: CompositePeriodDef): { short: number; long: number } { switch (ind) { case 'DONCHIAN': return { short: 9, long: 20 }; case 'RSI': return { short: def.rsiPeriod, long: Math.max(def.rsiPeriod * 2, 20) }; case 'CCI': return { short: def.cciPeriod, long: Math.max(def.cciPeriod * 2, 26) }; case 'ADX': return { short: def.adxPeriod, long: Math.max(def.adxPeriod * 2, 28) }; case 'WILLIAMS_R': return { short: def.williamsR, long: Math.max(def.williamsR * 2, 28) }; case 'TRIX': return { short: def.trixPeriod, long: Math.max(def.trixPeriod * 2, 24) }; case 'VR': return { short: def.vrPeriod, long: Math.max(def.vrPeriod * 2, 20) }; case 'PSYCHOLOGICAL': return { short: def.psyPeriod, long: Math.max(def.psyPeriod * 2, 24) }; case 'NEW_PSYCHOLOGICAL': return { short: def.newPsy, long: Math.max(def.newPsy * 2, 20) }; case 'INVEST_PSYCHOLOGICAL': return { short: def.investPsy, long: Math.max(def.investPsy * 2, 20) }; case 'DISPARITY': return { short: def.dispShort, long: def.dispMid }; default: return { short: 9, long: 20 }; } } export function compositeValueField(ind: string, period: number): string { switch (ind) { case 'DONCHIAN': return donchianUpperField(period); case 'RSI': return `RSI_VALUE_${period}`; case 'CCI': return `CCI_VALUE_${period}`; case 'ADX': return `ADX_VALUE_${period}`; case 'WILLIAMS_R': return `WILLIAMS_R_VALUE_${period}`; case 'TRIX': return `TRIX_VALUE_${period}`; case 'VR': return `VR_VALUE_${period}`; case 'PSYCHOLOGICAL': return `PSY_VALUE_${period}`; case 'NEW_PSYCHOLOGICAL': return `NEW_PSY_VALUE_${period}`; case 'INVEST_PSYCHOLOGICAL': return `INVEST_PSY_VALUE_${period}`; case 'DISPARITY': return `DISPARITY${period}`; default: return `${ind}_VALUE_${period}`; } } /** K_ 접두 임계값·hline 역할 심볼 — 기간 파싱·복합지표 승격에서 제외 */ export function isThresholdField(field?: string): boolean { return !!field && (field.startsWith('K_') || field === 'HL_OVER' || field === 'HL_MID' || field === 'HL_UNDER'); } const COMPOSITE_VALUE_PREFIX: 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', }; /** 복합지표용 값 라인 필드(CCI_VALUE_9 등)인지 — K_100 임계값은 false */ export function isCompositeValueField(ind: string, field?: string): boolean { if (!field || isThresholdField(field)) return false; if (isDonchianComposite(ind)) { return parseDonchianPeriod(field) != null; } if (ind === 'DISPARITY') { return field.startsWith('DISPARITY') && field.length > 'DISPARITY'.length && /^\d+$/.test(field.slice('DISPARITY'.length)); } const prefix = COMPOSITE_VALUE_PREFIX[ind]; if (!prefix) return false; return field === prefix || field.startsWith(`${prefix}_`); } export function parsePeriodFromCompositeField(ind: string, field?: string): number | null { if (!field || isThresholdField(field)) return null; if (isDonchianComposite(ind)) { return parseDonchianPeriod(field); } if (ind === 'DISPARITY' && field.startsWith('DISPARITY')) { const n = parseInt(field.slice('DISPARITY'.length), 10); return Number.isFinite(n) && n > 0 ? n : null; } const suffix = field.match(/_(\d+)$/); if (!suffix) return null; const n = parseInt(suffix[1], 10); return Number.isFinite(n) && n > 0 ? n : null; } /** 돈치안 — 단기·장기 고가/저가선 비교 */ export function syncDonchianCompositeFields(cond: ConditionDSL): ConditionDSL { const leftP = cond.leftPeriod ?? 9; const rightP = cond.rightPeriod ?? 20; const leftCt = normalizeStartCandleType(cond.leftCandleType ?? DEFAULT_START_CANDLE); const rightCt = normalizeStartCandleType(cond.rightCandleType ?? DEFAULT_START_CANDLE); const defaultBand = (period: number) => donchianUpperField(period); return { ...cond, composite: true, leftPeriod: leftP, rightPeriod: rightP, leftField: cond.leftField ?? defaultBand(leftP), rightField: cond.rightField ?? defaultBand(rightP), leftCandleType: leftCt, rightCandleType: rightCt, period: undefined, }; } export function syncCompositeFields(cond: ConditionDSL): ConditionDSL { if (!cond.composite) return cond; if (isDonchianComposite(cond.indicatorType)) { return syncDonchianCompositeFields(cond); } const leftP = cond.leftPeriod ?? parsePeriodFromCompositeField(cond.indicatorType, cond.leftField) ?? undefined; const rightP = cond.rightPeriod ?? parsePeriodFromCompositeField(cond.indicatorType, cond.rightField) ?? undefined; if (leftP == null || rightP == null) { return { ...cond, composite: true, ...(leftP != null ? { leftPeriod: leftP } : null), ...(rightP != null ? { rightPeriod: rightP } : null), }; } const leftCt = normalizeStartCandleType(cond.leftCandleType ?? DEFAULT_START_CANDLE); const rightCt = normalizeStartCandleType(cond.rightCandleType ?? DEFAULT_START_CANDLE); return { ...cond, composite: true, leftPeriod: leftP, rightPeriod: rightP, leftField: compositeValueField(cond.indicatorType, leftP), rightField: compositeValueField(cond.indicatorType, rightP), leftCandleType: leftCt, rightCandleType: rightCt, period: undefined, }; } export function normalizeCompositeCondition(cond: ConditionDSL): ConditionDSL { const migrated = migratePriceExtremeCondition(cond); if (migrated !== cond) return migrated; if (cond.composite) { if (isThresholdField(cond.leftField) || isThresholdField(cond.rightField)) { return { ...cond, composite: false, leftPeriod: undefined, rightPeriod: undefined, }; } return syncCompositeFields(cond); } const ind = cond.indicatorType; if (parseDonchianPeriod(cond.leftField) != null && parseDonchianPeriod(cond.rightField) != null) { const leftP = parseDonchianPeriod(cond.leftField)!; const rightP = parseDonchianPeriod(cond.rightField)!; if (leftP !== rightP) { return syncDonchianCompositeFields({ ...cond, composite: true, indicatorType: 'DONCHIAN', leftPeriod: leftP, rightPeriod: rightP, }); } } if (!isCompositeValueField(ind, cond.leftField) || !isCompositeValueField(ind, cond.rightField)) { return migratePriceExtremeCondition(cond); } const leftP = parsePeriodFromCompositeField(ind, cond.leftField); const rightP = parsePeriodFromCompositeField(ind, cond.rightField); if (leftP && rightP && leftP !== rightP) { return syncCompositeFields({ ...cond, composite: true, leftPeriod: leftP, rightPeriod: rightP, }); } return migratePriceExtremeCondition(cond); } export function makeCompositeCondition( ind: string, signalType: 'buy' | 'sell', def: CompositePeriodDef, ): ConditionDSL { const { short, long } = getCompositeDefaultPeriods(ind, def); if (isDonchianComposite(ind)) { return syncDonchianCompositeFields({ indicatorType: ind, conditionType: signalType === 'buy' ? 'CROSS_UP' : 'CROSS_DOWN', composite: true, leftPeriod: short, rightPeriod: long, leftCandleType: DEFAULT_START_CANDLE, rightCandleType: DEFAULT_START_CANDLE, candleRange: 1, }); } return syncCompositeFields({ indicatorType: ind, conditionType: signalType === 'buy' ? 'CROSS_UP' : 'CROSS_DOWN', composite: true, leftPeriod: short, rightPeriod: long, leftCandleType: DEFAULT_START_CANDLE, rightCandleType: DEFAULT_START_CANDLE, candleRange: 1, }); } /** 돈치안 복합 — 조건대상 드롭다운 옵션 */ export function getDonchianFieldOpts( allPeriods: number[], ): { value: string; label: string }[] { return [ ...allPeriods.map(p => ({ value: donchianUpperField(p), label: `${p}일 최고가선` })), ...allPeriods.map(p => ({ value: donchianLowerField(p), label: `${p}일 최저가선` })), ]; } export function compositePeriodLabel(ind: string, def: CompositePeriodDef): string { const { short, long } = getCompositeDefaultPeriods(ind, def); return `${short} / ${long}`; } export function compositeDisplayName(ind: string): string { const item = COMPOSITE_INDICATOR_ITEMS.find(i => i.value === ind); if (ind === 'DONCHIAN') return '돈치안 채널 (9·20일)'; if (item) return `${item.label} + ${item.label}`; return `${ind} + ${ind}`; } export function compositeElementLabel(ind: string, slot: 1 | 2): string { if (ind === 'DONCHIAN') { return slot === 1 ? '채널 단기' : '채널 장기'; } const item = COMPOSITE_INDICATOR_ITEMS.find(i => i.value === ind); const name = item?.label ?? ind; return `${name} ${slot}`; } /** 복합지표 조건대상 라벨 */ export function compositeFieldLabel(ind: string, slot: 1 | 2, period: number): string { if (ind === 'DONCHIAN') { return `${period}일 ${slot === 1 ? '단기' : '장기'} 고가선`; } return `${compositeElementLabel(ind, slot)} 라인(${period}일)`; }