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 분기별 트리거 분봉 마감 평가. * * */ @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)) { List types = readTimeframeCandleTypes(node); JsonNode sub = firstChild(node); if (types.size() <= 1) { String ct = types.isEmpty() ? "1m" : types.get(0); return new BranchScope("SINGLE", List.of(new BranchDef(ct, sub))); } List branches = new ArrayList<>(); for (String ct : types) { branches.add(new BranchDef(ct, sub)); } return new BranchScope("OR", branches); } 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) { JsonNode sub = firstChild(child); for (String ct : readTimeframeCandleTypes(child)) { branches.add(new BranchDef(ct, sub)); } } return new BranchScope(type, branches); } } JsonNode wrapped = findTimeframeWrapper(node); if (wrapped != null) { List types = readTimeframeCandleTypes(wrapped); JsonNode sub = firstChild(wrapped); JsonNode subtree = sub != null && !sub.isNull() ? sub : node; if (types.size() <= 1) { String ct = types.isEmpty() ? "1m" : types.get(0); return new BranchScope("SINGLE", List.of(new BranchDef(ct, subtree))); } List branches = new ArrayList<>(); for (String ct : types) { branches.add(new BranchDef(ct, subtree)); } return new BranchScope("OR", branches); } String inferred = StrategyDslTimeframeNormalizer.inferPrimaryCandleType(node); return new BranchScope("SINGLE", List.of(new BranchDef(inferred, node))); } /** 레거시 DSL — 루트가 TIMEFRAME이 아닐 때 하위 TIMEFRAME 래퍼 탐색 */ private static JsonNode findTimeframeWrapper(JsonNode node) { if (node == null || node.isNull()) return null; if ("TIMEFRAME".equals(node.path("type").asText(""))) return node; JsonNode children = node.path("children"); if (children.isArray()) { for (JsonNode child : children) { JsonNode found = findTimeframeWrapper(child); if (found != null) return found; } } JsonNode child = node.path("child"); if (!child.isMissingNode() && !child.isNull()) { return findTimeframeWrapper(child); } return null; } 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; } /** START 다중 분봉 — 각 마감봉마다 동일 조건 트리를 독립 평가 */ private static List readTimeframeCandleTypes(JsonNode timeframeNode) { return new ArrayList<>(StrategyDslTimeframeNormalizer.resolveStartCandleTypes(timeframeNode)); } 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; } } } }