diff --git a/backend/src/main/java/com/goldenchart/service/StrategyDslToTa4jAdapter.java b/backend/src/main/java/com/goldenchart/service/StrategyDslToTa4jAdapter.java index 9cf6c36..a39965f 100644 --- a/backend/src/main/java/com/goldenchart/service/StrategyDslToTa4jAdapter.java +++ b/backend/src/main/java/com/goldenchart/service/StrategyDslToTa4jAdapter.java @@ -501,8 +501,17 @@ public class StrategyDslToTa4jAdapter { case "SPAN1_LT_SPAN2" -> new UnderIndicatorRule(ichimokuSpanA(series, indParams), ichimokuSpanB(series, indParams)); case "SPAN1_CROSS_UP_SPAN2" -> new CrossedUpIndicatorRule(ichimokuSpanA(series, indParams), ichimokuSpanB(series, indParams)); case "SPAN1_CROSS_DOWN_SPAN2" -> new CrossedDownIndicatorRule(ichimokuSpanA(series, indParams), ichimokuSpanB(series, indParams)); - case "LAGGING_GT_PRICE" -> new OverIndicatorRule(ichimokuLagging(series, indParams), new ClosePriceIndicator(series)); - case "LAGGING_LT_PRICE" -> new UnderIndicatorRule(ichimokuLagging(series, indParams), new ClosePriceIndicator(series)); + case "LAGGING_GT_PRICE" -> buildChikouVsPriorCloseRule(series, indParams, true); + case "LAGGING_LT_PRICE" -> buildChikouVsPriorCloseRule(series, indParams, false); + case "LAGGING_ABOVE_PRIOR_CLOUD" -> buildChikouAbovePriorCloudRule(series, indParams); + case "VOLUME_GT_MA_RATIO" -> { + double ratio = cond.path("compareValue").asDouble(1.5); + int len = intP(indParams, "length", 5); + VolumeIndicator vol = new VolumeIndicator(series, 1); + SMAIndicator volMa = new SMAIndicator(vol, len); + Indicator threshold = NumericIndicator.of(volMa).multipliedBy(ratio); + yield new OverIndicatorRule(vol, threshold); + } default -> { log.warn("[Adapter] 미지원 conditionType: {}", condType); yield new BooleanRule(false); @@ -1026,6 +1035,25 @@ public class StrategyDslToTa4jAdapter { return new IchimokuChikouSpanIndicator(s, ichimokuChikouDisplacement(p)); } + /** 후행스팬(현재 종가) vs chikouDisplacement 봉 전 종가 — 삼역호전 3-1 */ + private Rule buildChikouVsPriorCloseRule(BarSeries s, Map p, boolean above) { + int d = ichimokuChikouDisplacement(p); + Indicator close = new ClosePriceIndicator(s); + Indicator priorClose = new PreviousValueIndicator(close, d); + return above + ? new OverIndicatorRule(close, priorClose) + : new UnderIndicatorRule(close, priorClose); + } + + /** 후행스팬(현재 종가) vs chikouDisplacement 봉 전 구름 상단 — 삼역호전 3-2 */ + private Rule buildChikouAbovePriorCloudRule(BarSeries s, Map p) { + int d = ichimokuChikouDisplacement(p); + Indicator close = new ClosePriceIndicator(s); + NumericIndicator cloudTop = NumericIndicator.of(ichimokuSpanA(s, p)).max(ichimokuSpanB(s, p)); + Indicator priorCloudTop = new PreviousValueIndicator(cloudTop, d); + return new OverIndicatorRule(close, priorCloudTop); + } + /** 종가가 구름 위 */ private Rule buildCloudAboveRule(BarSeries s, Map p) { NumericIndicator cloudTop = NumericIndicator.of(ichimokuSpanA(s, p)).max(ichimokuSpanB(s, p)); diff --git a/frontend/src/components/StrategyPage.tsx b/frontend/src/components/StrategyPage.tsx index 82e5f2f..de206bd 100644 --- a/frontend/src/components/StrategyPage.tsx +++ b/frontend/src/components/StrategyPage.tsx @@ -123,8 +123,9 @@ const ICHIMOKU_CONDS = [ { v: 'SPAN1_LT_SPAN2', l: '선행스팬1 < 선행스팬2' }, { v: 'SPAN1_CROSS_UP_SPAN2', l: '선행스팬1 상향돌파 선행스팬2' }, { v: 'SPAN1_CROSS_DOWN_SPAN2', l: '선행스팬1 하향돌파 선행스팬2' }, - { v: 'LAGGING_GT_PRICE', l: '후행스팬 > 가격' }, - { v: 'LAGGING_LT_PRICE', l: '후행스팬 < 가격' }, + { v: 'LAGGING_GT_PRICE', l: '후행스팬 > 26봉전 종가' }, + { v: 'LAGGING_LT_PRICE', l: '후행스팬 < 26봉전 종가' }, + { v: 'LAGGING_ABOVE_PRIOR_CLOUD', l: '후행스팬 > 26봉전 구름' }, ]; // ─── 지표별 fieldOptions 빌더 ──────────────────────────────────────────────── diff --git a/frontend/src/utils/stableStrategyPairs.ts b/frontend/src/utils/stableStrategyPairs.ts index a5d8e09..6175422 100644 --- a/frontend/src/utils/stableStrategyPairs.ts +++ b/frontend/src/utils/stableStrategyPairs.ts @@ -15,6 +15,7 @@ export type StableStrategyId = | 'DONCHIAN_20' | 'MA_TREND' | 'ICHIMOKU_TREND' + | 'ICHIMOKU_SANYAKU' | 'MACD_MOMENTUM' | 'BOLLINGER_RANGE'; @@ -54,6 +55,8 @@ export const MA_TREND_BUY = 'MA_TREND_BUY'; export const MA_TREND_SELL = 'MA_TREND_SELL'; export const ICHIMOKU_TREND_BUY = 'ICHIMOKU_TREND_BUY'; export const ICHIMOKU_TREND_SELL = 'ICHIMOKU_TREND_SELL'; +export const ICHIMOKU_SANYAKU_BUY = 'ICHIMOKU_SANYAKU_BUY'; +export const ICHIMOKU_SANYAKU_SELL = 'ICHIMOKU_SANYAKU_SELL'; export const MACD_MOMENTUM_BUY = 'MACD_MOMENTUM_BUY'; export const MACD_MOMENTUM_SELL = 'MACD_MOMENTUM_SELL'; export const BOLLINGER_RANGE_BUY = 'BOLLINGER_RANGE_BUY'; @@ -70,6 +73,8 @@ const VALUE_TO_META: Record', LT: '<', GTE: '≥', LTE: '≤', ABOVE_CLOUD: '구름 위', BELOW_CLOUD: '구름 아래', + SPAN1_GT_SPAN2: '양운(선행1>2)', LAGGING_GT_PRICE: '후행>26봉전 종가', + LAGGING_ABOVE_PRIOR_CLOUD: '후행>26봉전 구름', VOLUME_GT_MA_RATIO: '거래량≥MA×비율', }; const fmt = (n: LogicNode): string => { if (n.type === 'CONDITION' && n.condition) { @@ -459,6 +527,11 @@ export function stableStrategyFilterSummary( else parts.push('ADX 횡보필터 없음'); } 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('MA60·거래량>MA5'); + else parts.push('MA60·거래량 없음'); + } if (strategyId === 'MACD_MOMENTUM' && level === 'relaxed') parts.push('EMA60·ADX 없음'); if (strategyId === 'MA_TREND' && level === 'relaxed') parts.push('ADX 없음'); return parts.join(', '); @@ -489,6 +562,14 @@ export function inferStableStrategyFilterLevel(node: LogicNode): StableStrategyF if (id === 'ICHIMOKU_TREND' && !conds.some(c => c.conditionType === 'ABOVE_CLOUD' || c.conditionType === 'BELOW_CLOUD')) { return 'relaxed'; } + if (id === 'ICHIMOKU_SANYAKU') { + const hasMa = conds.some(c => c.indicatorType === 'MA' && c.rightField === maField(60)); + const hasVolRatio = conds.some(c => c.conditionType === 'VOLUME_GT_MA_RATIO'); + const hasVol = conds.some(c => c.indicatorType === 'VOLUME'); + if (!hasMa && !hasVol) return 'relaxed'; + if (hasVolRatio) return 'strict'; + if (hasMa && hasVol) return 'balanced'; + } if (id === 'MACD_MOMENTUM' && !conds.some(c => c.rightField === emaField(60))) return 'relaxed'; if (id === 'MA_TREND' && !conds.some(c => c.indicatorType === 'ADX')) return 'relaxed'; if (id === 'BOLLINGER_RANGE') { diff --git a/frontend/src/utils/strategyDescriptionNarrative.ts b/frontend/src/utils/strategyDescriptionNarrative.ts index 764dfbb..5e43fb2 100644 --- a/frontend/src/utils/strategyDescriptionNarrative.ts +++ b/frontend/src/utils/strategyDescriptionNarrative.ts @@ -135,8 +135,10 @@ function describeConditionType( case 'SPAN1_LT_SPAN2': return '선행스팬1이 선행스팬2보다 아래에 있는 경우'; case 'SPAN1_CROSS_UP_SPAN2': return '선행스팬1이 선행스팬2를 상향 돌파하는 경우'; case 'SPAN1_CROSS_DOWN_SPAN2': return '선행스팬1이 선행스팬2를 하향 돌파하는 경우'; - case 'LAGGING_GT_PRICE': return '후행스팬이 종가보다 위에 있는 경우'; - case 'LAGGING_LT_PRICE': return '후행스팬이 종가보다 아래에 있는 경우'; + case 'LAGGING_GT_PRICE': return '후행스팬(현재 종가)이 26봉 전 종가보다 위에 있는 경우'; + case 'LAGGING_LT_PRICE': return '후행스팬(현재 종가)이 26봉 전 종가보다 아래에 있는 경우'; + case 'LAGGING_ABOVE_PRIOR_CLOUD': return '후행스팬이 26봉 전 구름대(선행스팬)보다 위에 있는 경우'; + case 'VOLUME_GT_MA_RATIO': return '거래량이 거래량 이동평균 대비 설정 비율 이상인 경우'; default: { const label = CONDITION_LABEL[conditionType] ?? conditionType; if (right && right !== '선택안함') return `${left} — ${label} — ${right}`; diff --git a/frontend/src/utils/strategyEditorShared.tsx b/frontend/src/utils/strategyEditorShared.tsx index 5b010e7..08c9c65 100644 --- a/frontend/src/utils/strategyEditorShared.tsx +++ b/frontend/src/utils/strategyEditorShared.tsx @@ -429,8 +429,9 @@ const ICHIMOKU_CONDS = [ { v: 'SPAN1_LT_SPAN2', l: '선행스팬1 < 선행스팬2' }, { v: 'SPAN1_CROSS_UP_SPAN2', l: '선행스팬1 상향돌파 선행스팬2' }, { v: 'SPAN1_CROSS_DOWN_SPAN2', l: '선행스팬1 하향돌파 선행스팬2' }, - { v: 'LAGGING_GT_PRICE', l: '후행스팬 > 가격' }, - { v: 'LAGGING_LT_PRICE', l: '후행스팬 < 가격' }, + { v: 'LAGGING_GT_PRICE', l: '후행스팬 > 26봉전 종가' }, + { v: 'LAGGING_LT_PRICE', l: '후행스팬 < 26봉전 종가' }, + { v: 'LAGGING_ABOVE_PRIOR_CLOUD', l: '후행스팬 > 26봉전 구름' }, ]; // ─── 지표별 fieldOptions 빌더 ──────────────────────────────────────────────── @@ -871,8 +872,16 @@ export const applyCondTypeDefaults = (cond: ConditionDSL, newCondType: string, D updated.leftField = 'LEADING_SPAN1'; updated.rightField = 'LEADING_SPAN2'; } else if (['LAGGING_GT_PRICE','LAGGING_LT_PRICE'].includes(newCondType)) { updated.leftField = 'LAGGING_SPAN'; updated.rightField = 'CLOSE_PRICE'; + } else if (newCondType === 'LAGGING_ABOVE_PRIOR_CLOUD') { + updated.leftField = 'NONE'; updated.rightField = 'NONE'; } } + if (cond.indicatorType === 'VOLUME' && newCondType === 'VOLUME_GT_MA_RATIO') { + updated.leftField = 'VOLUME_VALUE'; + updated.rightField = 'VOLUME_MA'; + updated.compareValue = updated.compareValue ?? 1.5; + updated.params = { length: 5, ...(updated.params ?? {}) }; + } if (isPriceExtremeIndicator(updated.indicatorType)) { return syncPriceExtremeSimpleFields(updated); } diff --git a/frontend/src/utils/strategyTypes.ts b/frontend/src/utils/strategyTypes.ts index 7f11a7c..f40fba9 100644 --- a/frontend/src/utils/strategyTypes.ts +++ b/frontend/src/utils/strategyTypes.ts @@ -20,6 +20,8 @@ export interface ConditionDSL { leftField?: string; rightField?: string; compareValue?: number; + /** 조건 노드 전용 지표 파라미터 (예: VOLUME MA length) */ + params?: Record; slopePeriod?: number; holdDays?: number; /** LOWEST_LTE/GTE — 최근 N봉 rolling min/max 비교 */ @@ -98,7 +100,9 @@ export const CONDITION_LABEL: Record = { CLOUD_BREAK_UP: '구름 상향 돌파', CLOUD_BREAK_DOWN: '구름 하향 돌파', SPAN1_GT_SPAN2: '선행1>선행2', SPAN1_LT_SPAN2: '선행1<선행2', SPAN1_CROSS_UP_SPAN2: '선행1 상향교차', SPAN1_CROSS_DOWN_SPAN2: '선행1 하향교차', - LAGGING_GT_PRICE: '후행>종가', LAGGING_LT_PRICE: '후행<종가', + LAGGING_GT_PRICE: '후행>26봉전 종가', LAGGING_LT_PRICE: '후행<26봉전 종가', + LAGGING_ABOVE_PRIOR_CLOUD: '후행>26봉전 구름', + VOLUME_GT_MA_RATIO: '거래량 ≥ MA×비율', }; const COMMON_CONDITIONS = ['GT', 'LT', 'GTE', 'LTE', 'EQ', 'NEQ', 'CROSS_UP', 'CROSS_DOWN', 'SLOPE_UP', 'SLOPE_DOWN', 'DIFF_GT', 'DIFF_LT', 'HOLD_N_DAYS']; @@ -112,7 +116,7 @@ export const INDICATOR_CONDITIONS: Record = { TRIX: COMMON_CONDITIONS, VOLUME_OSC: COMMON_CONDITIONS, VR: COMMON_CONDITIONS, DISPARITY: COMMON_CONDITIONS, PSYCHOLOGICAL: COMMON_CONDITIONS, NEW_PSYCHOLOGICAL: COMMON_CONDITIONS, INVEST_PSYCHOLOGICAL: COMMON_CONDITIONS, - VOLUME: COMMON_CONDITIONS, + VOLUME: [...COMMON_CONDITIONS, 'VOLUME_GT_MA_RATIO'], MA: COMMON_CONDITIONS, EMA: COMMON_CONDITIONS, BOLLINGER: COMMON_CONDITIONS, DONCHIAN: COMMON_CONDITIONS, @@ -122,7 +126,7 @@ export const INDICATOR_CONDITIONS: Record = { ...COMMON_CONDITIONS, 'ABOVE_CLOUD', 'BELOW_CLOUD', 'IN_CLOUD', 'CLOUD_BREAK_UP', 'CLOUD_BREAK_DOWN', 'SPAN1_GT_SPAN2', 'SPAN1_LT_SPAN2', 'SPAN1_CROSS_UP_SPAN2', 'SPAN1_CROSS_DOWN_SPAN2', - 'LAGGING_GT_PRICE', 'LAGGING_LT_PRICE', + 'LAGGING_GT_PRICE', 'LAGGING_LT_PRICE', 'LAGGING_ABOVE_PRIOR_CLOUD', ], };