diff --git a/backend/src/main/java/com/goldenchart/service/LiveConditionStatusService.java b/backend/src/main/java/com/goldenchart/service/LiveConditionStatusService.java index 9460778..36cb634 100644 --- a/backend/src/main/java/com/goldenchart/service/LiveConditionStatusService.java +++ b/backend/src/main/java/com/goldenchart/service/LiveConditionStatusService.java @@ -695,13 +695,17 @@ public class LiveConditionStatusService { primarySeries.getEndIndex() - evalCount + 1); List signals = new ArrayList<>(); + boolean inPosition = false; for (int i = startIdx; i <= primarySeries.getEndIndex(); i++) { long barTimeSec = barOpenEpochSec(primarySeries, i); double close = primarySeries.getBar(i).getClosePrice().doubleValue(); Boolean entryMet = evaluateOverallRuleOnChart( buyDsl, primarySeries, seriesOverrides, market, params, visual, barTimeSec); - if (Boolean.TRUE.equals(entryMet)) { + Boolean exitMet = evaluateOverallRuleOnChart( + sellDsl, primarySeries, seriesOverrides, market, params, visual, barTimeSec); + + if (!inPosition && Boolean.TRUE.equals(entryMet)) { signals.add(BacktestResponse.Signal.builder() .time(barTimeSec) .type("BUY") @@ -709,11 +713,8 @@ public class LiveConditionStatusService { .barIndex(i) .quantity(0) .build()); - } - - Boolean exitMet = evaluateOverallRuleOnChart( - sellDsl, primarySeries, seriesOverrides, market, params, visual, barTimeSec); - if (Boolean.TRUE.equals(exitMet)) { + inPosition = true; + } else if (inPosition && Boolean.TRUE.equals(exitMet)) { signals.add(BacktestResponse.Signal.builder() .time(barTimeSec) .type("SELL") @@ -721,6 +722,7 @@ public class LiveConditionStatusService { .barIndex(i) .quantity(0) .build()); + inPosition = false; } } return signals; diff --git a/backend/src/main/java/com/goldenchart/service/StrategyDslToTa4jAdapter.java b/backend/src/main/java/com/goldenchart/service/StrategyDslToTa4jAdapter.java index a39965f..975c842 100644 --- a/backend/src/main/java/com/goldenchart/service/StrategyDslToTa4jAdapter.java +++ b/backend/src/main/java/com/goldenchart/service/StrategyDslToTa4jAdapter.java @@ -504,6 +504,7 @@ public class StrategyDslToTa4jAdapter { 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 "CHIKOU_CROSS_ABOVE_PRIOR_CLOUD" -> buildChikouCrossAbovePriorCloudRule(series, indParams); case "VOLUME_GT_MA_RATIO" -> { double ratio = cond.path("compareValue").asDouble(1.5); int len = intP(indParams, "length", 5); @@ -1054,6 +1055,15 @@ public class StrategyDslToTa4jAdapter { return new OverIndicatorRule(close, priorCloudTop); } + /** 후행스팬이 26봉 전 구름 상단을 상향 돌파하는 순간 (1회성 진입 트리거) */ + private Rule buildChikouCrossAbovePriorCloudRule(BarSeries s, Map p) { + int d = ichimokuChikouDisplacement(p); + ClosePriceIndicator close = new ClosePriceIndicator(s); + NumericIndicator cloudTop = NumericIndicator.of(ichimokuSpanA(s, p)).max(ichimokuSpanB(s, p)); + Indicator priorCloudTop = new PreviousValueIndicator(cloudTop, d); + return new CrossedUpIndicatorRule(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 de206bd..9c0b0a8 100644 --- a/frontend/src/components/StrategyPage.tsx +++ b/frontend/src/components/StrategyPage.tsx @@ -126,6 +126,7 @@ const ICHIMOKU_CONDS = [ { v: 'LAGGING_GT_PRICE', l: '후행스팬 > 26봉전 종가' }, { v: 'LAGGING_LT_PRICE', l: '후행스팬 < 26봉전 종가' }, { v: 'LAGGING_ABOVE_PRIOR_CLOUD', l: '후행스팬 > 26봉전 구름' }, + { v: 'CHIKOU_CROSS_ABOVE_PRIOR_CLOUD', l: '후행스팬 26봉전 구름 상향돌파' }, ]; // ─── 지표별 fieldOptions 빌더 ──────────────────────────────────────────────── diff --git a/frontend/src/utils/stableStrategyPairs.ts b/frontend/src/utils/stableStrategyPairs.ts index 6175422..0918fe7 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: '4단계 Full-AND — 전환·기준·구름·후행·MA60·거래량 (일목 산인 삼역好轉)', + desc: '4단계 Full-AND — 후행 구름 돌파 1회 트리거 + 삼역호전 필터', periodHint: '9 / 26 / 52', }, { value: ICHIMOKU_SANYAKU_SELL, label: '일목 삼역호전 매도', strategyId: 'ICHIMOKU_SANYAKU', mode: 'sell', - desc: '분할 청산 OR — 전환선 이탈 / 기준선·데드크로스 / 구름 하방', + desc: '전환·기준·구름 하향 돌파 이벤트 OR (유지조건 제외)', periodHint: '9 / 26 / 52', }, { @@ -371,22 +371,23 @@ function buildIchimokuTrendSell(def: IndicatorPeriodDef, level: StableStrategyFi return wrapOrRoot(branches, pairMeta('ICHIMOKU_TREND', 'sell', level)); } -/** 일목 삼역호전(三役好轉) — 4단계 Full-AND 매수 */ +/** 일목 삼역호전(三役好轉) — 4단계 Full-AND 매수 (1회성 트리거) */ function buildIchimokuSanyakuBuy(def: IndicatorPeriodDef, level: StableStrategyFilterLevel): LogicNode { const nodes: LogicNode[] = [ - // [1단계] 주가·단기 추세 + // [트리거] 후행스팬 26봉전 구름 상향 돌파 — 삼역호전 최종 잠금 순간 1회 + makeCondition('ICHIMOKU', 'CHIKOU_CROSS_ABOVE_PRIOR_CLOUD', 'NONE', 'NONE', def), + // [1단계] 주가·단기 추세 (진입 봉 동시 충족) makeCondition('ICHIMOKU', 'GT', 'CLOSE_PRICE', 'CONVERSION_LINE', def), makeCondition('ICHIMOKU', 'GT', 'CLOSE_PRICE', 'BASE_LINE', def), makeCondition('ICHIMOKU', 'GT', 'CONVERSION_LINE', 'BASE_LINE', def), // [2단계] 구름대·양운 makeCondition('ICHIMOKU', 'ABOVE_CLOUD', 'CLOSE_PRICE', 'NONE', def), makeCondition('ICHIMOKU', 'SPAN1_GT_SPAN2', 'LEADING_SPAN1', 'LEADING_SPAN2', def), - // [3단계] 후행스팬 2중 잠금 + // [3단계] 후행스팬 호전 유지 makeCondition('ICHIMOKU', 'LAGGING_GT_PRICE', 'LAGGING_SPAN', 'CLOSE_PRICE', def), makeCondition('ICHIMOKU', 'LAGGING_ABOVE_PRIOR_CLOUD', 'NONE', 'NONE', def), ]; if (level !== 'relaxed') { - // [4단계] MA60 대세 + 거래량 nodes.push(makeCondition('MA', 'GT', 'CLOSE_PRICE', maField(60), def)); if (level === 'strict') { nodes.push(makeVolumeMaRatioFilter(def, 1.5, 5)); @@ -397,16 +398,16 @@ function buildIchimokuSanyakuBuy(def: IndicatorPeriodDef, level: StableStrategyF return wrapAnd(nodes, pairMeta('ICHIMOKU_SANYAKU', 'buy', level)); } -/** 일목 삼역호전 — 분할 매도 OR (전환선 → 기준선/데드 → 구름) */ +/** 일목 삼역호전 — 분할 청산 OR (돌파 이벤트만, 유지조건 제외) */ function buildIchimokuSanyakuSell(def: IndicatorPeriodDef, level: StableStrategyFilterLevel): LogicNode { void level; return wrapOrRoot([ - makeCondition('ICHIMOKU', 'LT', 'CLOSE_PRICE', 'CONVERSION_LINE', def), + makeCondition('ICHIMOKU', 'CROSS_DOWN', 'CLOSE_PRICE', 'CONVERSION_LINE', def), wrapOr([ - makeCondition('ICHIMOKU', 'LT', 'CLOSE_PRICE', 'BASE_LINE', def), + makeCondition('ICHIMOKU', 'CROSS_DOWN', 'CLOSE_PRICE', 'BASE_LINE', def), makeCondition('ICHIMOKU', 'CROSS_DOWN', 'CONVERSION_LINE', 'BASE_LINE', def), ]), - makeCondition('ICHIMOKU', 'BELOW_CLOUD', 'CLOSE_PRICE', 'NONE', def), + makeCondition('ICHIMOKU', 'CLOUD_BREAK_DOWN', 'CLOSE_PRICE', 'NONE', def), ], pairMeta('ICHIMOKU_SANYAKU', 'sell', level)); } @@ -563,9 +564,11 @@ export function inferStableStrategyFilterLevel(node: LogicNode): StableStrategyF return 'relaxed'; } if (id === 'ICHIMOKU_SANYAKU') { + const hasTrigger = conds.some(c => c.conditionType === 'CHIKOU_CROSS_ABOVE_PRIOR_CLOUD'); 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 (!hasTrigger) return 'balanced'; if (!hasMa && !hasVol) return 'relaxed'; if (hasVolRatio) return 'strict'; if (hasMa && hasVol) return 'balanced'; diff --git a/frontend/src/utils/strategyDescriptionNarrative.ts b/frontend/src/utils/strategyDescriptionNarrative.ts index 5e43fb2..aefb907 100644 --- a/frontend/src/utils/strategyDescriptionNarrative.ts +++ b/frontend/src/utils/strategyDescriptionNarrative.ts @@ -138,6 +138,7 @@ function describeConditionType( case 'LAGGING_GT_PRICE': return '후행스팬(현재 종가)이 26봉 전 종가보다 위에 있는 경우'; case 'LAGGING_LT_PRICE': return '후행스팬(현재 종가)이 26봉 전 종가보다 아래에 있는 경우'; case 'LAGGING_ABOVE_PRIOR_CLOUD': return '후행스팬이 26봉 전 구름대(선행스팬)보다 위에 있는 경우'; + case 'CHIKOU_CROSS_ABOVE_PRIOR_CLOUD': return '후행스팬이 26봉 전 구름 상단을 상향 돌파하는 순간'; case 'VOLUME_GT_MA_RATIO': return '거래량이 거래량 이동평균 대비 설정 비율 이상인 경우'; default: { const label = CONDITION_LABEL[conditionType] ?? conditionType; diff --git a/frontend/src/utils/strategyEditorShared.tsx b/frontend/src/utils/strategyEditorShared.tsx index 08c9c65..dcd1d5f 100644 --- a/frontend/src/utils/strategyEditorShared.tsx +++ b/frontend/src/utils/strategyEditorShared.tsx @@ -432,6 +432,7 @@ const ICHIMOKU_CONDS = [ { v: 'LAGGING_GT_PRICE', l: '후행스팬 > 26봉전 종가' }, { v: 'LAGGING_LT_PRICE', l: '후행스팬 < 26봉전 종가' }, { v: 'LAGGING_ABOVE_PRIOR_CLOUD', l: '후행스팬 > 26봉전 구름' }, + { v: 'CHIKOU_CROSS_ABOVE_PRIOR_CLOUD', l: '후행스팬 26봉전 구름 상향돌파' }, ]; // ─── 지표별 fieldOptions 빌더 ──────────────────────────────────────────────── @@ -874,6 +875,8 @@ export const applyCondTypeDefaults = (cond: ConditionDSL, newCondType: string, D updated.leftField = 'LAGGING_SPAN'; updated.rightField = 'CLOSE_PRICE'; } else if (newCondType === 'LAGGING_ABOVE_PRIOR_CLOUD') { updated.leftField = 'NONE'; updated.rightField = 'NONE'; + } else if (newCondType === 'CHIKOU_CROSS_ABOVE_PRIOR_CLOUD') { + updated.leftField = 'NONE'; updated.rightField = 'NONE'; } } if (cond.indicatorType === 'VOLUME' && newCondType === 'VOLUME_GT_MA_RATIO') { diff --git a/frontend/src/utils/strategyTypes.ts b/frontend/src/utils/strategyTypes.ts index f40fba9..d0cf18f 100644 --- a/frontend/src/utils/strategyTypes.ts +++ b/frontend/src/utils/strategyTypes.ts @@ -102,6 +102,7 @@ export const CONDITION_LABEL: Record = { SPAN1_CROSS_UP_SPAN2: '선행1 상향교차', SPAN1_CROSS_DOWN_SPAN2: '선행1 하향교차', LAGGING_GT_PRICE: '후행>26봉전 종가', LAGGING_LT_PRICE: '후행<26봉전 종가', LAGGING_ABOVE_PRIOR_CLOUD: '후행>26봉전 구름', + CHIKOU_CROSS_ABOVE_PRIOR_CLOUD: '후행 구름 상향돌파', VOLUME_GT_MA_RATIO: '거래량 ≥ MA×비율', }; @@ -127,6 +128,7 @@ export const INDICATOR_CONDITIONS: Record = { '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_ABOVE_PRIOR_CLOUD', + 'CHIKOU_CROSS_ABOVE_PRIOR_CLOUD', ], };