diff --git a/frontend/src/utils/stableStrategyPairs.ts b/frontend/src/utils/stableStrategyPairs.ts index fe937a9..1e007fc 100644 --- a/frontend/src/utils/stableStrategyPairs.ts +++ b/frontend/src/utils/stableStrategyPairs.ts @@ -143,12 +143,12 @@ export const STABLE_STRATEGY_PALETTE_ITEMS: StableStrategyPaletteMeta[] = [ }, { value: ICHIMOKU_SANYAKU_BUY, label: '일목 삼역호전 매수', strategyId: 'ICHIMOKU_SANYAKU', mode: 'buy', - desc: '구름 상향 돌파 트리거 + 삼역호전 필터 (보통: 후행·양운, 강함: MA·거래량)', + desc: '구름 돌파 + 삼역 필터 — 최근 N봉 내 존재로 전후 봉 분산 조건 허용 (보통 20봉)', periodHint: '9 / 26 / 52', }, { value: ICHIMOKU_SANYAKU_SELL, label: '일목 삼역호전 매도', strategyId: 'ICHIMOKU_SANYAKU', mode: 'sell', - desc: '전환·기준·구름 이탈 OR — 돌파 우선, 이탈 유지 fallback', + desc: '전환·기준·구름 이탈 — N봉 내 돌파 존재 + 전환선 이탈 fallback', periodHint: '9 / 26 / 52', }, { @@ -228,15 +228,38 @@ function makeCondition( }; } +/** 최근 N봉(현재 봉 포함) 내 1회 이상 성립 — 전후 봉에 분산된 조건 허용 */ +function makeExistsIn( + indicatorType: string, + conditionType: string, + leftField: string, + rightField: string, + def: IndicatorPeriodDef, + window: number, + extra?: Partial, +): LogicNode { + return makeCondition(indicatorType, conditionType, leftField, rightField, def, { + candleRangeMode: 'EXISTS_IN', + candleRange: Math.max(2, window), + ...extra, + }); +} + function makeVolumeMaRatioFilter( def: IndicatorPeriodDef, ratio: number, maLength = 5, + window?: number, ): LogicNode { - return makeCondition('VOLUME', 'VOLUME_GT_MA_RATIO', 'VOLUME_VALUE', 'VOLUME_MA', def, { + const extra: Partial = { compareValue: ratio, params: { length: maLength }, - }); + }; + if (window != null) { + extra.candleRangeMode = 'EXISTS_IN'; + extra.candleRange = Math.max(2, window); + } + return makeCondition('VOLUME', 'VOLUME_GT_MA_RATIO', 'VOLUME_VALUE', 'VOLUME_MA', def, extra); } function makeAdxFilter(threshold: number, def: IndicatorPeriodDef): LogicNode { @@ -371,55 +394,58 @@ function buildIchimokuTrendSell(def: IndicatorPeriodDef, level: StableStrategyFi return wrapOrRoot(branches, pairMeta('ICHIMOKU_TREND', 'sell', level)); } -/** 일목 삼역호전 — 매수 트리거 (1회성 돌파) */ +/** 일목 삼역호전 — N봉 내 존재 윈도우 (3m 기준: 12≈36분, 20≈1h, 26≈78분) */ +function ichimokuSanyakuWindow(level: StableStrategyFilterLevel): number { + switch (level) { + case 'relaxed': return 12; + case 'balanced': return 20; + case 'strict': return 26; + } +} + +/** 일목 삼역호전 — 매수 트리거 (구름/후행 돌파, N봉 내 존재) */ function ichimokuSanyakuBuyTrigger(def: IndicatorPeriodDef, level: StableStrategyFilterLevel): LogicNode { + const w = ichimokuSanyakuWindow(level); if (level === 'strict') { return wrapOr([ - makeCondition('ICHIMOKU', 'CLOUD_BREAK_UP', 'NONE', 'NONE', def), - makeCondition('ICHIMOKU', 'CHIKOU_CROSS_ABOVE_PRIOR_CLOUD', 'NONE', 'NONE', def), + makeExistsIn('ICHIMOKU', 'CLOUD_BREAK_UP', 'NONE', 'NONE', def, w), + makeExistsIn('ICHIMOKU', 'CHIKOU_CROSS_ABOVE_PRIOR_CLOUD', 'NONE', 'NONE', def, w), ]); } - return makeCondition('ICHIMOKU', 'CLOUD_BREAK_UP', 'NONE', 'NONE', def); + return makeExistsIn('ICHIMOKU', 'CLOUD_BREAK_UP', 'NONE', 'NONE', def, w); } /** - * 구름 돌파 직후에는 전환>기준·양운이 아직 false인 경우가 많음(전형적 바닥 반등). - * 보통: 둘 중 하나만 충족하면 됨 / 강함: 둘 다 + 최근 N봉 내 양운 형성 허용 + * 구름 돌파 직후에는 전환>기준·양운이 아직 false인 경우가 많음. + * N봉 내 존재로 전후 봉에 분산된 추세 확인 허용. */ function ichimokuSanyakuTrendConfirm(def: IndicatorPeriodDef, level: StableStrategyFilterLevel): LogicNode[] { if (level === 'relaxed') return []; - if (level === 'balanced') { - return [ - wrapOr([ - makeCondition('ICHIMOKU', 'GT', 'CONVERSION_LINE', 'BASE_LINE', def), - makeCondition('ICHIMOKU', 'SPAN1_GT_SPAN2', 'LEADING_SPAN1', 'LEADING_SPAN2', def), - ]), - ]; - } + const w = ichimokuSanyakuWindow(level); return [ - makeCondition('ICHIMOKU', 'GT', 'CONVERSION_LINE', 'BASE_LINE', def), - makeCondition('ICHIMOKU', 'SPAN1_GT_SPAN2', 'LEADING_SPAN1', 'LEADING_SPAN2', def, { - candleRangeMode: 'EXISTS_IN', - candleRange: 26, - }), + wrapOr([ + makeExistsIn('ICHIMOKU', 'GT', 'CONVERSION_LINE', 'BASE_LINE', def, w), + makeExistsIn('ICHIMOKU', 'SPAN1_GT_SPAN2', 'LEADING_SPAN1', 'LEADING_SPAN2', def, w), + ]), ]; } -/** 일목 삼역호전 — 공통 추세·구름 필터 (돌파 봉 기준) */ +/** 일목 삼역호전 — 공통 추세·구름 필터 (N봉 내 존재) */ function ichimokuSanyakuCoreFilters(def: IndicatorPeriodDef, level: StableStrategyFilterLevel): LogicNode[] { + const w = ichimokuSanyakuWindow(level); if (level === 'relaxed') { return [ - makeCondition('ICHIMOKU', 'GT', 'CLOSE_PRICE', 'CONVERSION_LINE', def), + makeExistsIn('ICHIMOKU', 'GT', 'CLOSE_PRICE', 'CONVERSION_LINE', def, w), ]; } const nodes: LogicNode[] = [ - makeCondition('ICHIMOKU', 'GT', 'CLOSE_PRICE', 'CONVERSION_LINE', def), - makeCondition('ICHIMOKU', 'LAGGING_GT_PRICE', 'LAGGING_SPAN', 'CLOSE_PRICE', def), + makeExistsIn('ICHIMOKU', 'GT', 'CLOSE_PRICE', 'CONVERSION_LINE', def, w), + makeExistsIn('ICHIMOKU', 'LAGGING_GT_PRICE', 'LAGGING_SPAN', 'CLOSE_PRICE', def, w), ...ichimokuSanyakuTrendConfirm(def, level), ]; if (level === 'strict') { - nodes.splice(1, 0, makeCondition('ICHIMOKU', 'GT', 'CLOSE_PRICE', 'BASE_LINE', def)); - nodes.push(makeCondition('ICHIMOKU', 'LAGGING_ABOVE_PRIOR_CLOUD', 'NONE', 'NONE', def)); + nodes.splice(1, 0, makeExistsIn('ICHIMOKU', 'GT', 'CLOSE_PRICE', 'BASE_LINE', def, w)); + nodes.push(makeExistsIn('ICHIMOKU', 'LAGGING_ABOVE_PRIOR_CLOUD', 'NONE', 'NONE', def, w)); } return nodes; } @@ -431,19 +457,21 @@ function buildIchimokuSanyakuBuy(def: IndicatorPeriodDef, level: StableStrategyF ...ichimokuSanyakuCoreFilters(def, level), ]; if (level === 'strict') { - nodes.push(makeCondition('MA', 'GT', 'CLOSE_PRICE', maField(60), def)); - nodes.push(makeVolumeMaRatioFilter(def, 1.5, 5)); + const w = ichimokuSanyakuWindow(level); + nodes.push(makeExistsIn('MA', 'GT', 'CLOSE_PRICE', maField(60), def, w)); + nodes.push(makeVolumeMaRatioFilter(def, 1.5, 5, w)); } return wrapAnd(nodes, pairMeta('ICHIMOKU_SANYAKU', 'buy', level)); } -/** 일목 삼역호전 — 청산 (돌파 이벤트 + 이탈 fallback, 포지션 교대 시 1회) */ +/** 일목 삼역호전 — 청산 (돌파·이탈 N봉 내 존재 + 전환선 이탈 fallback) */ function buildIchimokuSanyakuSell(def: IndicatorPeriodDef, level: StableStrategyFilterLevel): LogicNode { + const w = ichimokuSanyakuWindow(level); const branches: LogicNode[] = [ - makeCondition('ICHIMOKU', 'CROSS_DOWN', 'CLOSE_PRICE', 'CONVERSION_LINE', def), - makeCondition('ICHIMOKU', 'CROSS_DOWN', 'CLOSE_PRICE', 'BASE_LINE', def), - makeCondition('ICHIMOKU', 'CROSS_DOWN', 'CONVERSION_LINE', 'BASE_LINE', def), - makeCondition('ICHIMOKU', 'CLOUD_BREAK_DOWN', 'CLOSE_PRICE', 'NONE', def), + makeExistsIn('ICHIMOKU', 'CROSS_DOWN', 'CLOSE_PRICE', 'CONVERSION_LINE', def, w), + makeExistsIn('ICHIMOKU', 'CROSS_DOWN', 'CLOSE_PRICE', 'BASE_LINE', def, w), + makeExistsIn('ICHIMOKU', 'CROSS_DOWN', 'CONVERSION_LINE', 'BASE_LINE', def, w), + makeExistsIn('ICHIMOKU', 'CLOUD_BREAK_DOWN', 'CLOSE_PRICE', 'NONE', def, w), ]; if (level !== 'strict') { branches.push(makeCondition('ICHIMOKU', 'LT', 'CLOSE_PRICE', 'CONVERSION_LINE', def)); @@ -572,9 +600,10 @@ export function stableStrategyFilterSummary( } if (strategyId === 'ICHIMOKU_TREND' && level === 'relaxed') parts.push('구름필터 없음'); if (strategyId === 'ICHIMOKU_SANYAKU') { - if (level === 'strict') parts.push('삼역 전체·MA60·거래량150%'); - else if (level === 'balanced') parts.push('구름돌파·(전환>기준 OR 양운)'); - else parts.push('구름돌파·종가>전환'); + const w = ichimokuSanyakuWindow(level); + if (level === 'strict') parts.push(`N${w}봉·삼역·MA60·거래량`); + else if (level === 'balanced') parts.push(`N${w}봉·구름돌파·(전환>기준 OR 양운)`); + else parts.push(`N${w}봉·구름돌파·종가>전환`); } if (strategyId === 'MACD_MOMENTUM' && level === 'relaxed') parts.push('EMA60·ADX 없음'); if (strategyId === 'MA_TREND' && level === 'relaxed') parts.push('ADX 없음'); @@ -607,6 +636,7 @@ export function inferStableStrategyFilterLevel(node: LogicNode): StableStrategyF return 'relaxed'; } if (id === 'ICHIMOKU_SANYAKU') { + const hasWindow = conds.some(c => c.candleRangeMode === 'EXISTS_IN'); const hasCloudBreak = conds.some(c => c.conditionType === 'CLOUD_BREAK_UP'); const hasChikouCross = conds.some(c => c.conditionType === 'CHIKOU_CROSS_ABOVE_PRIOR_CLOUD'); const hasLaggingCloud = conds.some(c => c.conditionType === 'LAGGING_ABOVE_PRIOR_CLOUD'); @@ -615,6 +645,7 @@ export function inferStableStrategyFilterLevel(node: LogicNode): StableStrategyF const hasLagging = conds.some(c => c.conditionType === 'LAGGING_GT_PRICE'); if (hasVolRatio || hasLaggingCloud) return 'strict'; if (hasMa) return 'strict'; + if (hasLagging && (hasCloudBreak || hasChikouCross) && hasWindow) return 'balanced'; if (hasLagging && (hasCloudBreak || hasChikouCross)) return 'balanced'; if (hasCloudBreak || hasChikouCross) return 'relaxed'; }