/** 조건 노드별 지표 기간·임계값 — 차트 DEF 기본값 대비 노드 단위 오버라이드 */ import type { ConditionDSL } from './strategyTypes'; import { compositeFieldLabel, compositeValueField, getCompositeDefaultPeriods, getDonchianFieldOpts, isDonchianComposite, isThresholdField, parseDonchianPeriod, parsePeriodFromCompositeField, syncCompositeFields, type CompositePeriodDef, } from './compositeIndicators'; import { isPriceExtremeIndicator, migratePriceExtremeCondition, parsePriorExtremePeriod, syncPriceExtremeSimpleFields, } from './priceExtremeIndicators'; import { isThresholdFieldOrSymbol, isThresholdSymbol, inferThresholdRoleForValue, resolveInheritedThresholdField, resolveThresholdFromDef, THRESHOLD_HL_MID, THRESHOLD_HL_OVER, THRESHOLD_HL_UNDER, } from './thresholdSymbols'; import { DEFAULT_START_CANDLE, normalizeStartCandleType } from './strategyStartNodes'; import { getIndicatorPrimaryPeriod, formatIndicatorPeriodLabel, } from './strategyIndicatorPeriods'; import type { DefType } from './strategyEditorShared'; export interface IndicatorPeriodDef { rsiPeriod: number; cciPeriod: number; adxPeriod: number; williamsR: number; trixPeriod: number; vrPeriod: number; newPsy: number; psyPeriod: number; investPsy: number; } export const VALUE_FIELD_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', }; export function getDefaultIndicatorPeriod( indicatorType: string, def: IndicatorPeriodDef | DefType, ): number { return getIndicatorPrimaryPeriod(indicatorType, def as DefType); } /** @internal DefType 전체 필드 — 팔레트 라벨 등 */ export function getDefaultIndicatorPeriodLabel( indicatorType: string, def: IndicatorPeriodDef | DefType, ): string { return formatIndicatorPeriodLabel(indicatorType, def as DefType); } function parseFieldPeriod(field?: string): number | null { if (!field || isThresholdField(field)) return null; const m = field.match(/_(\d+)$/); if (!m) return null; const n = parseInt(m[1], 10); return Number.isFinite(n) && n > 0 ? n : null; } /** 조건 노드의 RSI 등 값(좌측) 기간 — true=전략 전용, false/미설정=보조지표 설정(DEF) 상속 */ export function isValuePeriodOverridden(cond: ConditionDSL): boolean { return cond.valuePeriodOverride === true; } export function isRightPeriodOverridden(cond: ConditionDSL): boolean { return cond.rightPeriodOverride === true; } export function isThresholdOverridden(cond: ConditionDSL): boolean { return cond.thresholdOverride === true; } /** 조건 노드의 RSI 등 값(좌측) 기간 */ export function getConditionValuePeriod(cond: ConditionDSL, def: IndicatorPeriodDef): number { if (isPriceExtremeIndicator(cond.indicatorType)) { if (isValuePeriodOverridden(cond)) { if (cond.period && cond.period > 0) return cond.period; const fromRight = parsePriorExtremePeriod(cond.rightField); if (fromRight) return fromRight; } return getDefaultIndicatorPeriod(cond.indicatorType, def); } if (cond.composite) { if (isValuePeriodOverridden(cond)) { if (cond.leftPeriod && cond.leftPeriod > 0) return cond.leftPeriod; const fromField = parsePeriodFromCompositeField(cond.indicatorType, cond.leftField); if (fromField) return fromField; } const { short } = getCompositeDefaultPeriods(cond.indicatorType, def as CompositePeriodDef); return short; } if (isValuePeriodOverridden(cond)) { if (cond.period && cond.period > 0) return cond.period; const fromField = parseFieldPeriod(cond.leftField); if (fromField) return fromField; } return getDefaultIndicatorPeriod(cond.indicatorType, def); } export function getConditionRightPeriod(cond: ConditionDSL, def: IndicatorPeriodDef): number { if (cond.composite) { if (isRightPeriodOverridden(cond)) { if (cond.rightPeriod && cond.rightPeriod > 0) return cond.rightPeriod; const fromField = parsePeriodFromCompositeField(cond.indicatorType, cond.rightField); if (fromField) return fromField; } const { long } = getCompositeDefaultPeriods(cond.indicatorType, def as CompositePeriodDef); return long; } if (isValuePeriodOverridden(cond)) { const fromField = parseFieldPeriod(cond.rightField); if (fromField) return fromField; } return getDefaultIndicatorPeriod(cond.indicatorType, def); } /** 조건대상 직접입력 — 기간(일) 또는 임계값 */ export function resolveFieldCustomKind( indicatorType: string, field: string | undefined, ): 'period' | 'threshold' | 'none' { if (!field) return 'none'; if (isThresholdField(field)) return 'threshold'; const prefix = VALUE_FIELD_PREFIX[indicatorType]; if (prefix && (field === prefix || field.startsWith(`${prefix}_`))) return 'period'; return 'none'; } const INDICATORS_WITH_RIGHT_THRESHOLD_INPUT = new Set([ 'RSI', 'STOCHASTIC', 'CCI', 'ADX', 'DMI', 'WILLIAMS_R', 'TRIX', 'PSYCHOLOGICAL', 'NEW_PSYCHOLOGICAL', 'INVEST_PSYCHOLOGICAL', 'BWI', 'VR', 'VOLUME_OSC', 'DISPARITY', ]); /** 조건대상2 — 임계값 직접입력 노출 여부 */ export function resolveRightFieldCustomKind( indicatorType: string, field: string | undefined, ): 'period' | 'threshold' | 'none' { const base = resolveFieldCustomKind(indicatorType, field); if (base !== 'none') return base; if (parseThresholdField(field) != null) return 'threshold'; if (isThresholdFieldOrSymbol(field)) return 'threshold'; if (INDICATORS_WITH_RIGHT_THRESHOLD_INPUT.has(indicatorType)) return 'threshold'; return 'none'; } export function isValuePeriodFieldStored(indicatorType: string, field?: string): boolean { const prefix = VALUE_FIELD_PREFIX[indicatorType]; if (!prefix || !field) return false; return field.startsWith(`${prefix}_`) && field.length > prefix.length + 1; } export function usesValuePeriodField(cond: ConditionDSL): boolean { if (isPriceExtremeIndicator(cond.indicatorType)) return true; if (cond.composite) return true; const prefix = VALUE_FIELD_PREFIX[cond.indicatorType]; if (!prefix) return false; const lf = cond.leftField ?? ''; return lf === prefix || lf.startsWith(`${prefix}_`); } export function parseThresholdField(field?: string): number | null { if (!field?.startsWith('K_')) return null; const n = parseFloat(field.slice(2)); return Number.isFinite(n) ? n : null; } export function thresholdField(value: number): string { return `K_${value}`; } export function hasEditableThreshold(cond: ConditionDSL): boolean { return isThresholdFieldOrSymbol(cond.rightField) || isThresholdFieldOrSymbol(cond.leftField) || parseThresholdField(cond.rightField) != null; } /** 보조지표 차트(DB) hline 기준값 — 전략 오버라이드와 무관하게 항상 DEF에서 조회 */ export function getChartReferenceThreshold( cond: ConditionDSL, side: 'left' | 'right', inheritedField: string | undefined, def: { hlThresh: Record }, ): number | null { const raw = side === 'left' ? cond.leftField : (inheritedField ?? cond.rightField); if (!raw) return null; const field = isThresholdOverridden(cond) ? raw : resolveInheritedThresholdField(raw, cond.indicatorType, def.hlThresh); if (!field) return null; return resolveThresholdFromDef(field, cond.indicatorType, def.hlThresh); } /** * 전략 평가용 임계값 — thresholdOverride=true 이면 targetValue/K_, 아니면 차트 DEF. */ export function getConditionThreshold( cond: ConditionDSL, side: 'left' | 'right', inheritedField?: string, def?: { hlThresh: Record }, ): number | null { if (isThresholdOverridden(cond) && cond.targetValue != null && Number.isFinite(cond.targetValue)) { return cond.targetValue; } if (!isThresholdOverridden(cond)) { const rawField = inheritedField ?? (side === 'left' ? cond.leftField : cond.rightField); const roleField = def ? resolveInheritedThresholdField(rawField, cond.indicatorType, def.hlThresh) : rawField; if (def && roleField) { const fromDef = resolveThresholdFromDef(roleField, cond.indicatorType, def.hlThresh); if (fromDef != null) return fromDef; } if (inheritedField && def) { const inheritedRole = resolveInheritedThresholdField( inheritedField, cond.indicatorType, def.hlThresh, ); const fromInherited = resolveThresholdFromDef( inheritedRole, cond.indicatorType, def.hlThresh, ); if (fromInherited != null) return fromInherited; } } const field = side === 'left' ? cond.leftField : cond.rightField; return parseThresholdField(field); } export function setConditionThreshold( cond: ConditionDSL, side: 'left' | 'right', value: number, def?: { hlThresh: Record }, ): ConditionDSL { const fieldKey = side === 'left' ? 'leftField' : 'rightField'; const current = cond[fieldKey]; if (!isThresholdFieldOrSymbol(current)) { if (side === 'right' && INDICATORS_WITH_RIGHT_THRESHOLD_INPUT.has(cond.indicatorType)) { return { ...cond, [fieldKey]: thresholdField(value), targetValue: value, thresholdOverride: true, }; } return cond; } const inheritedField = side === 'right' ? (current?.startsWith('K_') || isThresholdSymbol(current) ? current : THRESHOLD_HL_OVER) : current; const chartRef = def ? getChartReferenceThreshold(cond, side, inheritedField, def) : null; const matchedRole = def ? inferThresholdRoleForValue(value, cond.indicatorType, def.hlThresh) : null; if (matchedRole != null) { const { targetValue: _t, ...rest } = cond; return { ...rest, [fieldKey]: matchedRole, thresholdOverride: false }; } if (chartRef != null && Math.abs(chartRef - value) < 0.0001) { const role = isThresholdSymbol(inheritedField) ? inheritedField : (side === 'right' ? THRESHOLD_HL_OVER : inheritedField); const { targetValue: _t, ...rest } = cond; return { ...rest, [fieldKey]: role, thresholdOverride: false }; } return { ...cond, [fieldKey]: thresholdField(value), targetValue: value, thresholdOverride: true, }; } export function setConditionValuePeriod( cond: ConditionDSL, period: number, _def: IndicatorPeriodDef, ): ConditionDSL { const p = Math.max(1, Math.min(500, Math.round(period))); if (isPriceExtremeIndicator(cond.indicatorType)) { return syncPriceExtremeSimpleFields({ ...cond, period: p, valuePeriodOverride: true }); } if (cond.composite) { return syncCompositeFields({ ...cond, composite: true, leftPeriod: p, valuePeriodOverride: true }); } const prefix = VALUE_FIELD_PREFIX[cond.indicatorType]; const next: ConditionDSL = { ...cond, period: p, valuePeriodOverride: true }; if (prefix && (cond.leftField === prefix || cond.leftField?.startsWith(`${prefix}_`))) { next.leftField = `${prefix}_${p}`; } return next; } export function setConditionRightPeriod(cond: ConditionDSL, period: number): ConditionDSL { const p = Math.max(1, Math.min(500, Math.round(period))); if (!cond.composite) return cond; return syncCompositeFields({ ...cond, composite: true, rightPeriod: p, rightPeriodOverride: true }); } /** 신규 조건 노드 — 보조지표 설정 기본값 상속(오버라이드 없음) */ export function initConditionPeriodsInherit( indicatorType: string, condition: ConditionDSL, def: IndicatorPeriodDef, ): ConditionDSL { const base = { ...condition, valuePeriodOverride: false as const, thresholdOverride: false as const, rightPeriodOverride: false as const, }; if (isPriceExtremeIndicator(indicatorType)) { const period = getDefaultIndicatorPeriod(indicatorType, def); return syncPriceExtremeSimpleFields({ ...base, period }); } if (condition.composite) return base; const prefix = VALUE_FIELD_PREFIX[indicatorType]; if (!prefix) { const { period: _p, ...rest } = base; return rest; } const lf = condition.leftField ?? ''; if (lf === prefix || lf.startsWith(`${prefix}_`)) { const { period: _p, ...rest } = base; return { ...rest, leftField: prefix }; } const { period: _p, ...rest } = base; return rest; } export function initConditionPeriods( indicatorType: string, condition: ConditionDSL, def: IndicatorPeriodDef, ): ConditionDSL { if (isPriceExtremeIndicator(indicatorType)) { const period = condition.period ?? getDefaultIndicatorPeriod(indicatorType, def); return syncPriceExtremeSimpleFields({ ...condition, period }); } if (condition.composite) return condition; const prefix = VALUE_FIELD_PREFIX[indicatorType]; if (!prefix) return condition; const period = getDefaultIndicatorPeriod(indicatorType, def); const lf = condition.leftField ?? ''; if (lf === prefix || lf.startsWith(`${prefix}_`)) { return { ...condition, period: condition.period ?? period, leftField: `${prefix}_${condition.period ?? period}` }; } return { ...condition, period: condition.period ?? period }; } export function hasNodeSettings(cond: ConditionDSL): boolean { return usesValuePeriodField(cond) || hasEditableThreshold(cond) || !!cond.composite; } const COMMON_PERIODS = [5, 7, 9, 10, 12, 13, 14, 20, 21, 26, 28, 50, 60, 120]; export function getPeriodPresetOptions(indicatorType: string, def: IndicatorPeriodDef): number[] { const base = getDefaultIndicatorPeriod(indicatorType, def); if (isPriceExtremeIndicator(indicatorType)) { return [...new Set([base, 5, 9, 10, 20, 55, ...COMMON_PERIODS])].sort((a, b) => a - b); } return [...new Set([base, ...COMMON_PERIODS])].sort((a, b) => a - b); } export function getPeriodSettingsLabel(cond: ConditionDSL): string | null { if (isPriceExtremeIndicator(cond.indicatorType)) { return cond.indicatorType === 'NEW_LOW' ? '신저가 기간 (봉)' : '신고가 기간 (봉)'; } return null; } /** 복합지표 요소1(단기)·요소2(장기) 기간 프리셋 */ export function getCompositePeriodPresetOptions( indicatorType: string, def: IndicatorPeriodDef, side: 'left' | 'right', ): number[] { const { short, long } = getCompositeDefaultPeriods(indicatorType, def as CompositePeriodDef); const base = side === 'left' ? short : long; return [...new Set([base, short, long, ...COMMON_PERIODS])].sort((a, b) => a - b); } /** 복합지표 조건대상1/2 드롭다운 — CCI 1 라인(9일) 형식 */ export function getCompositeLeftCandleType(cond: ConditionDSL): string { return normalizeStartCandleType(cond.leftCandleType ?? DEFAULT_START_CANDLE); } export function getCompositeRightCandleType(cond: ConditionDSL): string { return normalizeStartCandleType(cond.rightCandleType ?? DEFAULT_START_CANDLE); } export function setCompositeLeftCandleType(cond: ConditionDSL, candleType: string): ConditionDSL { return syncCompositeFields({ ...cond, composite: true, leftCandleType: normalizeStartCandleType(candleType), }); } export function setCompositeRightCandleType(cond: ConditionDSL, candleType: string): ConditionDSL { return syncCompositeFields({ ...cond, composite: true, rightCandleType: normalizeStartCandleType(candleType), }); } export function getCompositeFieldOpts( indicatorType: string, slot: 1 | 2, def: IndicatorPeriodDef, cond: ConditionDSL, ): { value: string; label: string }[] { const side = slot === 1 ? 'left' : 'right'; const current = slot === 1 ? getConditionValuePeriod(cond, def) : getConditionRightPeriod(cond, def); const presets = getCompositePeriodPresetOptions(indicatorType, def, side); const periods = [...new Set([...presets, current])].sort((a, b) => a - b); if (isDonchianComposite(indicatorType)) { const opts = getDonchianFieldOpts(periods); const cur = slot === 1 ? cond.leftField : cond.rightField; if (cur && !opts.some(o => o.value === cur)) { const p = parseDonchianPeriod(cur); const label = p != null ? compositeFieldLabel(indicatorType, slot, p) : cur; return [{ value: cur, label }, ...opts]; } return opts; } return periods.map(p => ({ value: compositeValueField(indicatorType, p), label: compositeFieldLabel(indicatorType, slot, p), })); } export function getThresholdPresetOptions(indicatorType: string): number[] { switch (indicatorType) { case 'RSI': case 'PSYCHOLOGICAL': case 'INVEST_PSYCHOLOGICAL': return [20, 25, 30, 50, 70, 75, 80]; case 'NEW_PSYCHOLOGICAL': return [-50, -25, 0, 25, 50]; case 'STOCHASTIC': return [20, 50, 80]; case 'CCI': return [-200, -100, -50, 0, 50, 100, 150, 200]; case 'WILLIAMS_R': return [-80, -50, -20]; case 'ADX': case 'DMI': return [20, 25, 30, 40, 50]; case 'VR': return [70, 100, 150, 200, 250]; default: return [-100, -50, 0, 50, 100]; } } export function getThresholdBounds(indicatorType: string): { min: number; max: number } { switch (indicatorType) { case 'RSI': case 'PSYCHOLOGICAL': case 'INVEST_PSYCHOLOGICAL': case 'STOCHASTIC': return { min: 0, max: 100 }; case 'NEW_PSYCHOLOGICAL': return { min: -100, max: 100 }; case 'WILLIAMS_R': return { min: -100, max: 0 }; case 'CCI': return { min: -500, max: 500 }; default: return { min: -1000, max: 1000 }; } }