/** * 상향 돌파 경로 판별 — 복합(AND) 조건 프리셋 * @see 상향돌파 케이스별 판별.md */ import type { ConditionDSL } from './strategyTypes'; import type { DefType } from './strategyEditorShared'; import { getFieldOpts } from './strategyEditorShared'; import { THRESHOLD_HL_MID, THRESHOLD_HL_OVER, THRESHOLD_HL_UNDER, } from './thresholdSymbols'; import { thresholdField } from './conditionPeriods'; import type { ConditionPreset, ConditionPresetGraph } from './conditionPresets'; const DEFAULT_PATH_LOOKBACK = 30; /** 상향 돌파 경로 판별 지원 지표 */ const PATH_ELIGIBLE = new Set([ 'RSI', 'CCI', 'STOCHASTIC', 'WILLIAMS_R', 'PSYCHOLOGICAL', 'NEW_PSYCHOLOGICAL', 'INVEST_PSYCHOLOGICAL', 'VR', 'BWI', 'TRIX', ]); type PathLineConfig = { valueField: string; targetField: string; floorField: string; }; function pathLines(indicatorType: string): PathLineConfig | null { switch (indicatorType) { case 'RSI': return { valueField: 'RSI_VALUE', targetField: THRESHOLD_HL_MID, floorField: THRESHOLD_HL_UNDER }; case 'CCI': return { valueField: 'CCI_VALUE', targetField: thresholdField(50), floorField: THRESHOLD_HL_MID }; case 'STOCHASTIC': return { valueField: 'STOCH_K', targetField: THRESHOLD_HL_MID, floorField: THRESHOLD_HL_UNDER }; case 'WILLIAMS_R': return { valueField: 'WILLIAMS_R_VALUE', targetField: THRESHOLD_HL_MID, floorField: THRESHOLD_HL_UNDER }; case 'PSYCHOLOGICAL': case 'INVEST_PSYCHOLOGICAL': return { valueField: 'PSY_VALUE', targetField: THRESHOLD_HL_MID, floorField: THRESHOLD_HL_UNDER }; case 'NEW_PSYCHOLOGICAL': return { valueField: 'NEW_PSY_VALUE', targetField: THRESHOLD_HL_MID, floorField: THRESHOLD_HL_UNDER }; case 'VR': return { valueField: 'VR_VALUE', targetField: THRESHOLD_HL_MID, floorField: THRESHOLD_HL_UNDER }; case 'BWI': return { valueField: 'BWI_VALUE', targetField: THRESHOLD_HL_MID, floorField: THRESHOLD_HL_UNDER }; case 'TRIX': return { valueField: 'TRIX_VALUE', targetField: THRESHOLD_HL_MID, floorField: THRESHOLD_HL_UNDER }; default: return null; } } function fieldLabel( indicatorType: string, field: string, def: DefType, signalType: 'buy' | 'sell', mock: ConditionDSL, ): string { return getFieldOpts(indicatorType, signalType, def, mock).find(o => o.value === field)?.label ?? field; } function withLookback(patch: Partial, n = DEFAULT_PATH_LOOKBACK): Partial { return { ...patch, lookbackPeriod: n }; } function withExistsIn(patch: Partial, n = DEFAULT_PATH_LOOKBACK): Partial { return { ...patch, candleRangeMode: 'EXISTS_IN', candleRange: n }; } function withNotExistsIn(patch: Partial, n = DEFAULT_PATH_LOOKBACK): Partial { return { ...patch, candleRangeMode: 'NOT_EXISTS_IN', candleRange: n }; } /** ① 0(바닥선) 위에서 목표선 첫 상향 돌파 */ function buildPathFirstCross(cfg: PathLineConfig): Partial[] { const { valueField, targetField, floorField } = cfg; return [ { conditionType: 'CROSS_UP', leftField: valueField, rightField: targetField }, withLookback({ conditionType: 'LOWEST_GTE', leftField: valueField, rightField: floorField }), withNotExistsIn({ conditionType: 'GT', leftField: valueField, rightField: targetField }), ]; } /** ② 목표선 위→아래 조정(바닥선 유지) 후 목표선 재상향 돌파 */ function buildPathPullbackRecross(cfg: PathLineConfig): Partial[] { const { valueField, targetField, floorField } = cfg; return [ { conditionType: 'CROSS_UP', leftField: valueField, rightField: targetField }, withLookback({ conditionType: 'LOWEST_GTE', leftField: valueField, rightField: floorField }), withExistsIn({ conditionType: 'GT', leftField: valueField, rightField: targetField }), withExistsIn({ conditionType: 'LT', leftField: valueField, rightField: targetField }), ]; } /** ③ 바닥선 이탈(침체) 후 변곡·목표선 상향 돌파 */ function buildPathDeepRecovery(cfg: PathLineConfig): Partial[] { const { valueField, targetField, floorField } = cfg; return [ { conditionType: 'CROSS_UP', leftField: valueField, rightField: targetField }, withLookback({ conditionType: 'LOWEST_LTE', leftField: valueField, rightField: floorField }), ]; } function enrichPathPreset( preset: ConditionPreset, indicatorType: string, def: DefType, signalType: 'buy' | 'sell', cfg: PathLineConfig, ): ConditionPreset { const mockCross: ConditionDSL = { indicatorType, conditionType: 'CROSS_UP', leftField: cfg.valueField, rightField: cfg.targetField, }; const left = fieldLabel(indicatorType, cfg.valueField, def, signalType, mockCross); const target = fieldLabel(indicatorType, cfg.targetField, def, signalType, mockCross); const floor = fieldLabel(indicatorType, cfg.floorField, def, signalType, { ...mockCross, conditionType: 'LOWEST_GTE', rightField: cfg.floorField, }); const n = DEFAULT_PATH_LOOKBACK; const descById: Record = { path_first_cross: { label: `① ${target} 첫 상향 돌파 (${floor} 이상 유지)`, description: `${left}이(가) 최근 ${n}봉 동안 ${floor} 아래로 내려간 적 없고, ${target} 위 체류 이력도 없을 때 ${target}을(를) 처음 상향 돌파 (AND ${preset.compound?.length ?? 3}조건)`, }, path_pullback_recross: { label: `② ${target} 되돌림 후 재상향 돌파`, description: `${left}이(가) ${floor} 위를 유지한 채 ${target} 위·아래를 오간 뒤, 다시 ${target}을(를) 상향 돌파 (AND ${preset.compound?.length ?? 4}조건)`, }, path_deep_recovery: { label: `③ ${floor} 침체 후 ${target} 상향 돌파`, description: `최근 ${n}봉 내 ${left}이(가) ${floor} 이하(침체) 구간을 거친 뒤, ${target}을(를) 상향 돌파하는 V자 반등 (AND ${preset.compound?.length ?? 2}조건)`, }, }; const extra = descById[preset.id]; if (!extra) return preset; return { ...preset, label: extra.label, description: extra.description }; } export function getPathBreakoutPresets( indicatorType: string, def: DefType, signalType: 'buy' | 'sell', ): ConditionPreset[] { if (!PATH_ELIGIBLE.has(indicatorType)) return []; const cfg = pathLines(indicatorType); if (!cfg) return []; const base = (id: string, graph: ConditionPresetGraph, compound: Partial[]): ConditionPreset => ({ id, label: id, description: '', graph, patch: {}, category: 'path', compound, tags: ['buy'], }); const presets: ConditionPreset[] = [ base('path_first_cross', 'path_first_cross', buildPathFirstCross(cfg)), base('path_pullback_recross', 'path_pullback_recross', buildPathPullbackRecross(cfg)), base('path_deep_recovery', 'path_deep_recovery', buildPathDeepRecovery(cfg)), ]; return presets.map(p => enrichPathPreset(p, indicatorType, def, signalType, cfg)); } export function isCompoundConditionPreset(preset: ConditionPreset): boolean { return !!preset.compound && preset.compound.length > 0; }