diff --git a/backend/src/main/java/com/goldenchart/dto/CandleBarDto.java b/backend/src/main/java/com/goldenchart/dto/CandleBarDto.java index d6e7f7f..e078755 100644 --- a/backend/src/main/java/com/goldenchart/dto/CandleBarDto.java +++ b/backend/src/main/java/com/goldenchart/dto/CandleBarDto.java @@ -41,4 +41,7 @@ public class CandleBarDto { * "BUY" | "SELL" | "NONE" (null 이면 전략 체크 미설정) */ private String signal; + + /** 시그널이 발생한 평가 분봉 (예: 1m, 3m, 5m) */ + private String candleType; } diff --git a/backend/src/main/java/com/goldenchart/service/LiveStrategyEvaluator.java b/backend/src/main/java/com/goldenchart/service/LiveStrategyEvaluator.java index 9caed90..4bd0693 100644 --- a/backend/src/main/java/com/goldenchart/service/LiveStrategyEvaluator.java +++ b/backend/src/main/java/com/goldenchart/service/LiveStrategyEvaluator.java @@ -1,7 +1,5 @@ package com.goldenchart.service; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; import com.goldenchart.entity.GcLiveStrategySettings; import com.goldenchart.entity.GcStrategy; import com.goldenchart.repository.GcLiveStrategySettingsRepository; @@ -9,8 +7,9 @@ import com.goldenchart.repository.GcStrategyRepository; import com.goldenchart.storage.Ta4jStorage; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.ta4j.core.*; -import org.ta4j.core.rules.BooleanRule; +import org.ta4j.core.BarSeries; +import org.ta4j.core.BaseTradingRecord; +import org.ta4j.core.Rule; import org.springframework.stereotype.Service; @@ -51,17 +50,18 @@ public class LiveStrategyEvaluator { private final GcLiveStrategySettingsRepository settingsRepo; private final GcStrategyRepository strategyRepo; private final Ta4jStorage ta4jStorage; - private final StrategyDslToTa4jAdapter adapter; private final IndicatorSettingsService indicatorSettingsService; - private final ObjectMapper objectMapper; private final StrategySignalDeterminer determiner; private final StrategyConditionTimeframeService conditionTimeframes; + private final StrategyTriggerBranchEvaluator triggerBranchEvaluator; + private final StrategyBranchStateCache branchStateCache; /** - * Strategy 캐시: "market:candleType:strategyId" → Strategy - * (entryRule / exitRule 포함) + * 트리거 분봉별 Rule 캐시: "market:candleType:strategyId" → (entryRule, exitRule) */ - private final ConcurrentHashMap strategyCache = new ConcurrentHashMap<>(); + private final ConcurrentHashMap triggerRulesCache = new ConcurrentHashMap<>(); + + private record TriggerRules(Rule entryRule, Rule exitRule) {} /** * LONG_ONLY 모드용 TradingRecord 캐시: "market:candleType:strategyId" → TradingRecord @@ -157,12 +157,9 @@ public class LiveStrategyEvaluator { if (s.getStrategyId() == null) continue; if (!conditionTimeframes.usesTimeframe(s.getStrategyId(), candleType)) continue; - // ★ Ta4j CachedIndicator 캐시 무효화: - // updateLastBar 로 provisional 봉 가격이 바뀌어도 CCI 등 CachedIndicator 는 - // 이미 캐시된 구 값을 반환한다. 전략을 재빌드하면 새 인디케이터 인스턴스를 - // 생성(빈 캐시)하므로 getValue(currentIndex) 가 현재 가격으로 재계산된다. + // ★ Ta4j CachedIndicator 캐시 무효화 — Rule 트리 재빌드 String cacheKey = market + ":" + candleType + ":" + s.getStrategyId(); - strategyCache.remove(cacheKey); + triggerRulesCache.remove(cacheKey); String result = evaluate(market, candleType, s.getStrategyId(), s.getPositionMode(), currentIndex, @@ -205,7 +202,7 @@ public class LiveStrategyEvaluator { // CachedIndicator 캐시 무효화 — 확정봉의 최종 close 로 재계산 String cacheKey = market + ":" + candleType + ":" + s.getStrategyId(); - strategyCache.remove(cacheKey); + triggerRulesCache.remove(cacheKey); String result = evaluate(market, candleType, s.getStrategyId(), s.getPositionMode(), maturedIndex, @@ -222,10 +219,20 @@ public class LiveStrategyEvaluator { /** 설정 변경 시 해당 마켓 캐시 무효화 */ public void invalidateCache(String market) { - strategyCache.keySet().removeIf(k -> k.startsWith(market + ":")); + triggerRulesCache.keySet().removeIf(k -> k.startsWith(market + ":")); tradingRecordCache.keySet().removeIf(k -> k.startsWith(market + ":")); positionOpenCache.keySet().removeIf(k -> k.startsWith(market + ":")); realtimeSignaledIdx.keySet().removeIf(k -> k.startsWith(market + ":")); + branchStateCache.invalidateMarket(market); + } + + /** 전략 DSL 변경 시 Rule·분기 상태 캐시 무효화 */ + public void invalidateStrategy(long strategyId) { + String suffix = ":" + strategyId; + triggerRulesCache.keySet().removeIf(k -> k.endsWith(suffix)); + tradingRecordCache.keySet().removeIf(k -> k.endsWith(suffix)); + positionOpenCache.keySet().removeIf(k -> k.endsWith(suffix)); + branchStateCache.invalidateStrategy(strategyId); } // ── Private ─────────────────────────────────────────────────────────────── @@ -235,30 +242,27 @@ public class LiveStrategyEvaluator { String deviceId, Long userId) { String cacheKey = market + ":" + candleType + ":" + strategyId; - // computeIfAbsent 는 device context 전달이 불가하므 수동 분기 - Strategy strategy = strategyCache.get(cacheKey); - if (strategy == null) { - strategy = buildStrategy(market, candleType, strategyId, deviceId, userId); - if (strategy != null) strategyCache.put(cacheKey, strategy); + TriggerRules rules = triggerRulesCache.get(cacheKey); + if (rules == null) { + rules = buildTriggerRules(market, candleType, strategyId, deviceId, userId); + if (rules == null) return "NONE"; + triggerRulesCache.put(cacheKey, rules); } - if (strategy == null) return "NONE"; - try { String mode = positionMode != null ? positionMode : "LONG_ONLY"; if ("SIGNAL_ONLY".equals(mode)) { - // 포지션 락 우회 — 순수 Rule 충족 여부만 판단 - return determiner.determineSignal(strategy, null, index, "SIGNAL_ONLY"); + return determiner.determineSignalFromRules( + rules.entryRule(), rules.exitRule(), null, index, "SIGNAL_ONLY"); } - // LONG_ONLY — TradingRecord 상태를 유지하여 포지션 검증 BaseTradingRecord record = tradingRecordCache.computeIfAbsent( cacheKey, k -> new BaseTradingRecord()); boolean isOpen = Boolean.TRUE.equals(positionOpenCache.get(cacheKey)); - String signal = determiner.determineSignal(strategy, record, index, "LONG_ONLY"); + String signal = determiner.determineSignalFromRules( + rules.entryRule(), rules.exitRule(), record, index, "LONG_ONLY"); - // 시그널에 따라 TradingRecord 갱신 및 포지션 상태 추적 if ("BUY".equals(signal) && !isOpen) { BarSeries series = ta4jStorage.getOrCreate(market, candleType); org.ta4j.core.num.Num price = series.getBar(index).getClosePrice(); @@ -270,21 +274,20 @@ public class LiveStrategyEvaluator { record.exit(index, price, series.numFactory().numOf(1)); positionOpenCache.put(cacheKey, false); } else if ("BUY".equals(signal) || "SELL".equals(signal)) { - // 포지션 상태와 불일치 (이미 매수 중인데 또 BUY 등) → 신호 무시 signal = "NONE"; } return signal; } catch (Exception e) { log.warn("[Evaluator] 전략 판정 오류 key={} idx={}: {}", cacheKey, index, e.getMessage()); - strategyCache.remove(cacheKey); + triggerRulesCache.remove(cacheKey); tradingRecordCache.remove(cacheKey); } return "NONE"; } - private Strategy buildStrategy(String market, String candleType, long strategyId, - String deviceId, Long userId) { + private TriggerRules buildTriggerRules(String market, String candleType, long strategyId, + String deviceId, Long userId) { Optional strategyOpt = strategyRepo.findById(strategyId); if (strategyOpt.isEmpty()) return null; @@ -292,7 +295,6 @@ public class LiveStrategyEvaluator { if (!ta4jStorage.exists(market, candleType)) return null; BarSeries series = ta4jStorage.getOrCreate(market, candleType); - // 사용자·장치별 지표 파라미터 우선 로드 (없으면 빈 맵 → adapter 기본값 사용) Map> indicatorParams = indicatorSettingsService.getAll(userId, deviceId); @@ -302,29 +304,18 @@ public class LiveStrategyEvaluator { strategyId, market, barCount); return null; } - log.debug("[Evaluator] Strategy 빌드: strategyId={} market={} barCount={}", strategyId, market, barCount); try { - Rule entryRule = buildRule(strategy.getBuyConditionJson(), series, indicatorParams, market); - Rule exitRule = buildRule(strategy.getSellConditionJson(), series, indicatorParams, market); - BaseStrategy builtStrategy = new BaseStrategy(entryRule, exitRule); - return builtStrategy; + Rule entryRule = triggerBranchEvaluator.buildTriggerRule( + strategy.getBuyConditionJson(), market, strategyId, "buy", + candleType, indicatorParams, ta4jStorage); + Rule exitRule = triggerBranchEvaluator.buildTriggerRule( + strategy.getSellConditionJson(), market, strategyId, "sell", + candleType, indicatorParams, ta4jStorage); + return new TriggerRules(entryRule, exitRule); } catch (Exception e) { - log.error("[Evaluator] Strategy 빌드 실패 strategyId={}: {}", strategyId, e.getMessage()); + log.error("[Evaluator] Trigger Rule 빌드 실패 strategyId={}: {}", strategyId, e.getMessage()); return null; } } - - private Rule buildRule(String conditionJson, BarSeries series, - Map> params, - String market) { - if (conditionJson == null || conditionJson.isBlank()) return new BooleanRule(false); - try { - JsonNode node = objectMapper.readTree(conditionJson); - return adapter.toRule(node, series, params, market, ta4jStorage); - } catch (Exception e) { - log.warn("[Evaluator] 조건 JSON 파싱 실패: {}", e.getMessage()); - return new BooleanRule(false); - } - } } diff --git a/backend/src/main/java/com/goldenchart/service/StrategyBranchStateCache.java b/backend/src/main/java/com/goldenchart/service/StrategyBranchStateCache.java new file mode 100644 index 0000000..a0d858b --- /dev/null +++ b/backend/src/main/java/com/goldenchart/service/StrategyBranchStateCache.java @@ -0,0 +1,45 @@ +package com.goldenchart.service; + +import org.springframework.stereotype.Component; + +import java.util.concurrent.ConcurrentHashMap; + +/** + * 멀티 START(AND) 전략 — 각 분봉 분기의 마지막 마감봉 평가 결과 캐시. + * 비트리거 분봉은 자신의 마감 시점에만 갱신된다. + */ +@Component +public class StrategyBranchStateCache { + + public record BranchState(boolean satisfied, int barIndex) {} + + private final ConcurrentHashMap cache = new ConcurrentHashMap<>(); + + private static String key(String market, long strategyId, String side, String candleType) { + return market + ":" + strategyId + ":" + side + ":" + + LiveStrategyTimeframeService.normalize(candleType); + } + + public void put(String market, long strategyId, String side, String candleType, + boolean satisfied, int barIndex) { + cache.put(key(market, strategyId, side, candleType), + new BranchState(satisfied, barIndex)); + } + + public BranchState get(String market, long strategyId, String side, String candleType) { + return cache.get(key(market, strategyId, side, candleType)); + } + + public void invalidateMarket(String market) { + cache.keySet().removeIf(k -> k.startsWith(market + ":")); + } + + public void invalidateStrategy(long strategyId) { + String prefix = ":" + strategyId + ":"; + cache.keySet().removeIf(k -> k.contains(prefix)); + } + + public void invalidateAll() { + cache.clear(); + } +} diff --git a/backend/src/main/java/com/goldenchart/service/StrategyService.java b/backend/src/main/java/com/goldenchart/service/StrategyService.java index 3762eb2..687e8e7 100644 --- a/backend/src/main/java/com/goldenchart/service/StrategyService.java +++ b/backend/src/main/java/com/goldenchart/service/StrategyService.java @@ -21,6 +21,7 @@ public class StrategyService { private final GcStrategyRepository repository; private final ObjectMapper objectMapper; private final StrategyConditionTimeframeService conditionTimeframes; + private final LiveStrategyEvaluator liveStrategyEvaluator; // ── 조회 ────────────────────────────────────────────────────────────────── @@ -64,6 +65,7 @@ public class StrategyService { GcStrategy saved = repository.save(entity); conditionTimeframes.invalidate(saved.getId()); + liveStrategyEvaluator.invalidateStrategy(saved.getId()); return toDto(saved); } @@ -72,6 +74,7 @@ public class StrategyService { @Transactional public void delete(Long id) { conditionTimeframes.invalidate(id); + liveStrategyEvaluator.invalidateStrategy(id); repository.deleteById(id); } diff --git a/backend/src/main/java/com/goldenchart/service/StrategyTriggerBranchEvaluator.java b/backend/src/main/java/com/goldenchart/service/StrategyTriggerBranchEvaluator.java new file mode 100644 index 0000000..cf013cb --- /dev/null +++ b/backend/src/main/java/com/goldenchart/service/StrategyTriggerBranchEvaluator.java @@ -0,0 +1,194 @@ +package com.goldenchart.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.goldenchart.storage.Ta4jStorage; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.ta4j.core.BarSeries; +import org.ta4j.core.Rule; +import org.ta4j.core.TradingRecord; +import org.ta4j.core.rules.BooleanRule; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * START/TIMEFRAME 분기별 트리거 분봉 마감 평가. + * + *
    + *
  • OR / 단일 START: 트리거 분봉 마감 시 해당 분기만 평가
  • + *
  • AND + 다중 START: 트리거 분기만 갱신, 나머지는 각자 마감 시 캐시된 상태로 결합
  • + *
+ */ +@Service +@RequiredArgsConstructor +@Slf4j +public class StrategyTriggerBranchEvaluator { + + private final StrategyDslToTa4jAdapter adapter; + private final ObjectMapper objectMapper; + private final StrategyBranchStateCache branchCache; + + record BranchScope(String combineOp, List branches) {} + + record BranchDef(String candleType, JsonNode subtree) {} + + /** + * 트리거 분봉({@code triggerCandleType}) 마감 시 호출되는 Rule. + * {@code index}는 트리거 분봉 BarSeries의 확정봉 인덱스. + */ + public Rule buildTriggerRule(String conditionJson, + String market, + long strategyId, + String side, + String triggerCandleType, + Map> indicatorParams, + Ta4jStorage storage) { + BranchScope scope = parseScope(conditionJson); + if (scope.branches().isEmpty()) return new BooleanRule(false); + + String trigger = LiveStrategyTimeframeService.normalize(triggerCandleType); + StrategyDslToTa4jAdapter.RuleBuildContext ctx = + new StrategyDslToTa4jAdapter.RuleBuildContext( + storage.getOrCreate(market, trigger), + indicatorParams, + market, + storage); + + return new TriggerBranchRule(scope, market, strategyId, side, trigger, ctx); + } + + BranchScope parseScope(String conditionJson) { + if (conditionJson == null || conditionJson.isBlank()) { + return new BranchScope("SINGLE", List.of()); + } + try { + return parseScopeNode(objectMapper.readTree(conditionJson)); + } catch (Exception e) { + log.warn("[TriggerBranch] JSON 파싱 실패: {}", e.getMessage()); + return new BranchScope("SINGLE", List.of()); + } + } + + private BranchScope parseScopeNode(JsonNode node) { + if (node == null || node.isNull()) { + return new BranchScope("SINGLE", List.of()); + } + + String type = node.path("type").asText(""); + + if ("TIMEFRAME".equals(type)) { + String ct = LiveStrategyTimeframeService.normalize(node.path("candleType").asText("1m")); + JsonNode sub = firstChild(node); + return new BranchScope("SINGLE", List.of(new BranchDef(ct, sub))); + } + + if ("AND".equals(type) || "OR".equals(type)) { + JsonNode children = node.path("children"); + if (children.isArray() && !children.isEmpty() && allTimeframeChildren(children)) { + List branches = new ArrayList<>(); + for (JsonNode child : children) { + String ct = LiveStrategyTimeframeService.normalize( + child.path("candleType").asText("1m")); + branches.add(new BranchDef(ct, firstChild(child))); + } + return new BranchScope(type, branches); + } + } + + return new BranchScope("SINGLE", List.of(new BranchDef("1m", node))); + } + + private static JsonNode firstChild(JsonNode timeframeNode) { + JsonNode children = timeframeNode.path("children"); + if (children.isArray() && !children.isEmpty()) return children.get(0); + return timeframeNode.path("child"); + } + + private static boolean allTimeframeChildren(JsonNode children) { + for (JsonNode c : children) { + if (!"TIMEFRAME".equals(c.path("type").asText(""))) return false; + } + return true; + } + + private final class TriggerBranchRule implements Rule { + private final BranchScope scope; + private final String market; + private final long strategyId; + private final String side; + private final String triggerCandleType; + private final StrategyDslToTa4jAdapter.RuleBuildContext ctx; + + TriggerBranchRule(BranchScope scope, + String market, + long strategyId, + String side, + String triggerCandleType, + StrategyDslToTa4jAdapter.RuleBuildContext ctx) { + this.scope = scope; + this.market = market; + this.strategyId = strategyId; + this.side = side; + this.triggerCandleType = triggerCandleType; + this.ctx = ctx; + } + + @Override + public boolean isSatisfied(int index, TradingRecord tradingRecord) { + Optional triggerBranch = scope.branches().stream() + .filter(b -> b.candleType().equals(triggerCandleType)) + .findFirst(); + if (triggerBranch.isEmpty()) return false; + + BarSeries triggerSeries = ctx.resolveSeries(triggerCandleType); + if (triggerSeries.isEmpty()) return false; + + int evalIndex = Math.min(index, triggerSeries.getEndIndex()); + if (evalIndex < 0) return false; + + boolean triggerSat = evaluateBranch(triggerBranch.get(), evalIndex); + branchCache.put(market, strategyId, side, triggerCandleType, triggerSat, evalIndex); + + if ("OR".equals(scope.combineOp()) || "SINGLE".equals(scope.combineOp())) { + return triggerSat; + } + + for (BranchDef branch : scope.branches()) { + StrategyBranchStateCache.BranchState state = + branchCache.get(market, strategyId, side, branch.candleType()); + if (state == null || !state.satisfied()) return false; + } + return true; + } + + private boolean evaluateBranch(BranchDef branch, int index) { + JsonNode subtree = branch.subtree(); + if (subtree == null || subtree.isNull()) return false; + + BarSeries branchSeries = ctx.resolveSeries(branch.candleType()); + if (branchSeries.isEmpty()) return false; + + int branchIndex = branch.candleType().equals(triggerCandleType) + ? index + : branchSeries.getEndIndex(); + if (branchIndex < 0 || branchIndex >= branchSeries.getBarCount()) return false; + + StrategyDslToTa4jAdapter.RuleBuildContext branchCtx = + new StrategyDslToTa4jAdapter.RuleBuildContext( + branchSeries, ctx.indicatorParams(), ctx.market(), ctx.storage()); + try { + Rule rule = adapter.toRule(subtree, branchCtx); + return rule.isSatisfied(branchIndex, null); + } catch (Exception e) { + log.debug("[TriggerBranch] 분기 평가 실패 {} {}: {}", + market, branch.candleType(), e.getMessage()); + return false; + } + } + } +} diff --git a/backend/src/main/java/com/goldenchart/websocket/BarBuilder.java b/backend/src/main/java/com/goldenchart/websocket/BarBuilder.java index 0afd417..d22de11 100644 --- a/backend/src/main/java/com/goldenchart/websocket/BarBuilder.java +++ b/backend/src/main/java/com/goldenchart/websocket/BarBuilder.java @@ -218,6 +218,7 @@ public class BarBuilder { .close(bar.getClosePrice().doubleValue()) .volume(bar.getVolume().doubleValue()) .signal(signal) + .candleType(candleType) .build(); broker.publish(market, candleType, dto); log.info("[BarBuilder] bar-close signal={} market={} candleType={}", signal, market, candleType); diff --git a/backend/src/main/java/com/goldenchart/websocket/LiveStrategyScheduler.java b/backend/src/main/java/com/goldenchart/websocket/LiveStrategyScheduler.java index 7912fc0..006c695 100644 --- a/backend/src/main/java/com/goldenchart/websocket/LiveStrategyScheduler.java +++ b/backend/src/main/java/com/goldenchart/websocket/LiveStrategyScheduler.java @@ -85,6 +85,7 @@ public class LiveStrategyScheduler { .close(lastBar.getClosePrice().doubleValue()) .volume(lastBar.getVolume().doubleValue()) .signal(signal) + .candleType(candleType) .build(); broker.publish(market, candleType, dto); diff --git a/backend/src/test/java/com/goldenchart/service/StrategyTriggerBranchEvaluatorTest.java b/backend/src/test/java/com/goldenchart/service/StrategyTriggerBranchEvaluatorTest.java new file mode 100644 index 0000000..92d982b --- /dev/null +++ b/backend/src/test/java/com/goldenchart/service/StrategyTriggerBranchEvaluatorTest.java @@ -0,0 +1,176 @@ +package com.goldenchart.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.goldenchart.storage.Ta4jStorage; +import com.goldenchart.trading.pipeline.TradingPipelineProperties; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.ta4j.core.BarSeries; +import org.ta4j.core.BaseBarSeriesBuilder; +import org.ta4j.core.Rule; +import org.ta4j.core.bars.TimeBarBuilderFactory; +import org.ta4j.core.num.DoubleNumFactory; + +import java.time.Duration; +import java.time.Instant; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * START 분기별 트리거 분봉 평가 — OR/AND 분기 격리. + */ +class StrategyTriggerBranchEvaluatorTest { + + private static final String MARKET = "KRW-BTC"; + private static final long STRATEGY_ID = 42L; + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private StrategyTriggerBranchEvaluator evaluator; + private StrategyBranchStateCache branchCache; + private Ta4jStorage storage; + + @BeforeEach + void setUp() { + branchCache = new StrategyBranchStateCache(); + evaluator = new StrategyTriggerBranchEvaluator( + new StrategyDslToTa4jAdapter(), MAPPER, branchCache); + TradingPipelineProperties props = new TradingPipelineProperties(); + props.setEnabled(false); + storage = new Ta4jStorage(props); + seedStorage(MARKET, "1m", buildSyntheticSeries("1m", 260)); + seedStorage(MARKET, "3m", buildSyntheticSeries("3m", 120)); + seedStorage(MARKET, "5m", buildSyntheticSeries("5m", 80)); + } + + @Test + void orScope_1mTrigger_doesNotRequire5mBranch() throws Exception { + String json = """ + { + "type": "OR", + "children": [ + { "type": "TIMEFRAME", "candleType": "1m", "children": [{ + "type": "CONDITION", "condition": { + "indicatorType": "RSI", "conditionType": "GT", + "leftField": "RSI_VALUE", "rightField": "K_0", "period": 14 + } + }]}, + { "type": "TIMEFRAME", "candleType": "5m", "children": [{ + "type": "CONDITION", "condition": { + "indicatorType": "RSI", "conditionType": "LT", + "leftField": "RSI_VALUE", "rightField": "K_0", "period": 14 + } + }]} + ] + } + """; + + Rule rule1m = evaluator.buildTriggerRule(json, MARKET, STRATEGY_ID, "buy", + "1m", Map.of(), storage); + BarSeries s1m = storage.getOrCreate(MARKET, "1m"); + int idx = s1m.getEndIndex(); + assertDoesNotThrow(() -> rule1m.isSatisfied(idx, null)); + assertNotNull(branchCache.get(MARKET, STRATEGY_ID, "buy", "1m")); + assertNull(branchCache.get(MARKET, STRATEGY_ID, "buy", "5m"), + "1m 트리거 시 5m 분기는 평가·캐시되지 않아야 함"); + } + + @Test + void andScope_requiresAllBranchCaches() throws Exception { + String json = """ + { + "type": "AND", + "children": [ + { "type": "TIMEFRAME", "candleType": "1m", "children": [{ + "type": "CONDITION", "condition": { + "indicatorType": "RSI", "conditionType": "GT", + "leftField": "RSI_VALUE", "rightField": "K_0", "period": 14 + } + }]}, + { "type": "TIMEFRAME", "candleType": "3m", "children": [{ + "type": "CONDITION", "condition": { + "indicatorType": "RSI", "conditionType": "GT", + "leftField": "RSI_VALUE", "rightField": "K_0", "period": 14 + } + }]} + ] + } + """; + + Rule rule1m = evaluator.buildTriggerRule(json, MARKET, STRATEGY_ID, "sell", + "1m", Map.of(), storage); + BarSeries s1m = storage.getOrCreate(MARKET, "1m"); + int idx1m = s1m.getEndIndex(); + + rule1m.isSatisfied(idx1m, null); + assertNotNull(branchCache.get(MARKET, STRATEGY_ID, "sell", "1m")); + assertNull(branchCache.get(MARKET, STRATEGY_ID, "sell", "3m")); + + branchCache.put(MARKET, STRATEGY_ID, "sell", "3m", true, 10); + assertTrue(rule1m.isSatisfied(idx1m, null)); + } + + @Test + void single3mScope_onlyTriggersOn3mBranch() throws Exception { + String json = """ + { + "type": "TIMEFRAME", + "candleType": "3m", + "children": [{ + "type": "CONDITION", "condition": { + "indicatorType": "RSI", "conditionType": "GT", + "leftField": "RSI_VALUE", "rightField": "K_0", "period": 14 + } + }] + } + """; + + var scope = evaluator.parseScope(json); + assertEquals("SINGLE", scope.combineOp()); + assertEquals(1, scope.branches().size()); + assertEquals("3m", scope.branches().get(0).candleType()); + + Rule on3m = evaluator.buildTriggerRule(json, MARKET, STRATEGY_ID, "buy", + "3m", Map.of(), storage); + BarSeries s3m = storage.getOrCreate(MARKET, "3m"); + assertDoesNotThrow(() -> on3m.isSatisfied(s3m.getEndIndex(), null)); + + Rule on1m = evaluator.buildTriggerRule(json, MARKET, STRATEGY_ID, "buy", + "1m", Map.of(), storage); + assertFalse(on1m.isSatisfied(storage.getOrCreate(MARKET, "1m").getEndIndex(), null)); + } + + private static BarSeries buildSyntheticSeries(String timeframe, int barCount) { + Duration period = switch (timeframe) { + case "3m" -> Duration.ofMinutes(3); + case "5m" -> Duration.ofMinutes(5); + default -> Duration.ofMinutes(1); + }; + BarSeries series = new BaseBarSeriesBuilder() + .withName("test-" + timeframe) + .withNumFactory(DoubleNumFactory.getInstance()) + .withBarBuilderFactory(new TimeBarBuilderFactory()) + .build(); + TimeBarBuilderFactory factory = new TimeBarBuilderFactory(); + Instant base = Instant.parse("2024-06-01T00:00:00Z"); + for (int i = 0; i < barCount; i++) { + double wave = Math.sin(i * 0.07) * 8 + Math.cos(i * 0.02) * 4; + double close = 50_000_000 + i * 120 + wave * 1000; + Instant end = base.plus(period.multipliedBy(i + 1L)); + factory.createBarBuilder(series) + .timePeriod(period) + .endTime(end) + .openPrice(close - 500).highPrice(close + 800) + .lowPrice(close - 800).closePrice(close) + .volume(12.5 + i * 0.01) + .add(); + } + return series; + } + + private void seedStorage(String market, String candleType, BarSeries source) { + for (int i = source.getBeginIndex(); i <= source.getEndIndex(); i++) { + storage.addBar(market, candleType, source.getBar(i)); + } + } +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 023fabb..4b20056 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -50,7 +50,7 @@ import { mergeIndicators, reorderIndicatorGroup, splitIndicatorPane, - buildIndicatorConfigsFromTypes, + replaceIndicatorConfigsFromTypes, } from './utils/indicatorPaneMerge'; import { useAppSettings } from './hooks/useAppSettings'; import { clampVirtualTargetMax } from './utils/virtualTargetLimits'; @@ -1368,7 +1368,7 @@ function App() { }); }, [layoutDef.count, activeSlot, getParams, getVisualConfig]); - /** 지표 추가 팝업 — 탭 전체 추가 (한 번에 상태 반영) */ + /** 지표 추가 팝업 — 탭 전체 추가 (기존 보조지표 제거 후 탭 지표만 적용) */ const handleAddIndicators = useCallback((types: string[]) => { if (!types.length) return; if (layoutDef.count > 1) { @@ -1378,10 +1378,9 @@ function App() { return; } } - setIndicators(prev => { - const added = buildIndicatorConfigsFromTypes(types, prev, getParams, getVisualConfig, newIndId); - return added.length > 0 ? [...prev, ...added] : prev; - }); + setIndicators( + replaceIndicatorConfigsFromTypes(types, getParams, getVisualConfig, newIndId), + ); }, [layoutDef.count, activeSlot, getParams, getVisualConfig]); /** diff --git a/frontend/src/components/ChartSlot.tsx b/frontend/src/components/ChartSlot.tsx index a3f6101..32bca7b 100644 --- a/frontend/src/components/ChartSlot.tsx +++ b/frontend/src/components/ChartSlot.tsx @@ -32,7 +32,7 @@ import { mergeIndicators, reorderIndicatorGroup, splitIndicatorPane, - buildIndicatorConfigsFromTypes, + replaceIndicatorConfigsFromTypes, } from '../utils/indicatorPaneMerge'; import { saveSlot } from '../utils/backendApi'; import type { @@ -86,7 +86,7 @@ function newIndId() { return `sind_${++_slotIndCounter}_${Date.now()}`; } export interface ChartSlotHandle { /** 지표 추가 */ addIndicator: (type: string, fromConfig?: IndicatorConfig) => void; - /** 지표 일괄 추가 (탭 전체 추가) */ + /** 지표 일괄 추가 (탭 전체 추가 — 기존 지표 제거 후 탭 지표만 적용) */ addIndicators: (types: string[]) => void; /** 지표 제거 (type 기준) */ removeIndicatorByType: (type: string) => void; @@ -273,10 +273,9 @@ const ChartSlot = forwardRef(function ChartSlot }, addIndicators: (types: string[]) => { if (!types.length) return; - setIndicators(prev => { - const added = buildIndicatorConfigsFromTypes(types, prev, getParams, getVisualConfig, newIndId); - return added.length > 0 ? [...prev, ...added] : prev; - }); + setIndicators( + replaceIndicatorConfigsFromTypes(types, getParams, getVisualConfig, newIndId), + ); }, removeIndicatorByType: (type: string) => { setIndicators(prev => prev.filter(i => i.type !== type)); diff --git a/frontend/src/components/LiveSignalNotifier.tsx b/frontend/src/components/LiveSignalNotifier.tsx index 8284a4b..171f3f1 100644 --- a/frontend/src/components/LiveSignalNotifier.tsx +++ b/frontend/src/components/LiveSignalNotifier.tsx @@ -65,7 +65,7 @@ export const LiveSignalNotifier = forwardRef(fu candleTime: marker.time, strategyName: strategyNameByMarket?.[marker.market] ?? strategyName, executionType: executionTypeByMarket?.[marker.market] ?? executionType, - candleType: marketSubscriptions?.find(s => s.market === marker.market)?.candleType ?? '1m', + candleType: marker.candleType ?? '1m', }); // STOMP만 수신·DB 저장 누락 시 배지 동기화 window.setTimeout(() => { void refreshHistory(); }, 800); diff --git a/frontend/src/components/Toolbar.tsx b/frontend/src/components/Toolbar.tsx index 2cb6751..0a8fdb1 100644 --- a/frontend/src/components/Toolbar.tsx +++ b/frontend/src/components/Toolbar.tsx @@ -382,17 +382,16 @@ const IndDropdown: React.FC = ({ const validTypes = addAllContext.types.filter( type => INDICATOR_REGISTRY.some(d => d.type === type), ); - const toAdd = validTypes.filter(type => !isActive(type)); - if (toAdd.length === 0) { - window.alert('추가할 지표가 없습니다. (이미 모두 차트에 있습니다)'); + if (validTypes.length === 0) { + window.alert('추가할 지표가 없습니다.'); return; } - const msg = `${addAllContext.label} 탭에 있는 지표 ${toAdd.length}개를 차트에 추가하시겠습니까?`; + const msg = `기존 보조지표를 모두 제거하고 ${addAllContext.label} 탭의 지표 ${validTypes.length}개로 교체하시겠습니까?`; if (!window.confirm(msg)) return; if (onAddMany) { - onAddMany(toAdd); + onAddMany(validTypes); } else { - toAdd.forEach(type => onAdd(type)); + validTypes.forEach(type => onAdd(type)); } }; @@ -569,8 +568,8 @@ const IndDropdown: React.FC = ({ type="button" className="ind-panel-add-all" disabled={addAllDisabled} - title={addAllDisabled ? '전체 탭에서는 사용할 수 없습니다' : '현재 탭의 모든 지표 추가'} - aria-label="현재 탭의 모든 지표 추가" + title={addAllDisabled ? '전체 탭에서는 사용할 수 없습니다' : '기존 보조지표를 제거하고 현재 탭 지표로 교체'} + aria-label="현재 탭 지표로 보조지표 교체" onClick={handleAddAllInTab} > diff --git a/frontend/src/hooks/useLiveStrategyMarkers.ts b/frontend/src/hooks/useLiveStrategyMarkers.ts index 0f80081..8e26566 100644 --- a/frontend/src/hooks/useLiveStrategyMarkers.ts +++ b/frontend/src/hooks/useLiveStrategyMarkers.ts @@ -15,6 +15,7 @@ export interface LiveMarker { signal: 'BUY' | 'SELL'; price: number; market: string; + candleType: string; } export interface MarketCandleSub { @@ -54,12 +55,12 @@ export function useLiveStrategyMarkers({ const onSignalRef = useRef(onSignal); onSignalRef.current = onSignal; - const pushMarker = useCallback((market: string, data: { + const pushMarker = useCallback((market: string, candleType: string, data: { time: number; close: number; signal: string; }) => { if (data.signal !== 'BUY' && data.signal !== 'SELL') return; - const key = `${market}:${data.time}:${data.signal}`; + const key = `${market}:${candleType}:${data.time}:${data.signal}`; if (seenRef.current.has(key)) return; seenRef.current.add(key); @@ -68,6 +69,7 @@ export function useLiveStrategyMarkers({ signal: data.signal as 'BUY' | 'SELL', price: data.close, market, + candleType, }; const prev = markersByMarketRef.current[market] ?? []; @@ -117,9 +119,15 @@ export function useLiveStrategyMarkers({ time: number; close: number; signal?: string; + candleType?: string; }>(msg); if (!data?.signal || data.signal === 'NONE') return; - pushMarker(market, { time: data.time, close: data.close, signal: data.signal }); + const resolvedCandleType = data.candleType ?? ct; + pushMarker(market, resolvedCandleType, { + time: data.time, + close: data.close, + signal: data.signal, + }); }); }); diff --git a/frontend/src/utils/indicatorPaneMerge.ts b/frontend/src/utils/indicatorPaneMerge.ts index 1360e4f..561e9a4 100644 --- a/frontend/src/utils/indicatorPaneMerge.ts +++ b/frontend/src/utils/indicatorPaneMerge.ts @@ -153,6 +153,16 @@ type GetVisual = ( defaultHlines?: HLineDef[], ) => { plots: PlotDef[]; hlines: HLineDef[]; cloudColors?: IchimokuCloudColors }; +/** 탭 전체 추가 — 기존 차트 지표를 제거하고 types 목록만 새로 구성 */ +export function replaceIndicatorConfigsFromTypes( + types: string[], + getParams: GetParams, + getVisualConfig: GetVisual, + newId: () => string, +): IndicatorConfig[] { + return buildIndicatorConfigsFromTypes(types, [], getParams, getVisualConfig, newId); +} + /** 지표 타입 목록 → 차트에 추가할 IndicatorConfig 배열 (이미 활성인 type 제외) */ export function buildIndicatorConfigsFromTypes( types: string[],