package com.goldenchart.service; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ObjectNode; import com.goldenchart.dto.BacktestResponse; import com.goldenchart.dto.LiveConditionRowDto; import com.goldenchart.dto.LiveConditionStatusDto; import com.goldenchart.dto.OhlcvBar; import com.goldenchart.entity.GcStrategy; import com.goldenchart.repository.GcStrategyRepository; import com.goldenchart.storage.Ta4jStorage; import com.goldenchart.websocket.DynamicSubscriptionManager; 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.springframework.transaction.annotation.Transactional; import java.util.*; /** * 가상투자 — 종목×전략별 조건 충족 현황 (Ta4j Rule.isSatisfied). */ @Service @RequiredArgsConstructor @Slf4j public class LiveConditionStatusService { private static final Map CONDITION_LABEL = Map.ofEntries( Map.entry("GT", "초과(>)"), Map.entry("LT", "미만(<)"), Map.entry("GTE", "이상(≥)"), Map.entry("LTE", "이하(≤)"), Map.entry("EQ", "같음(=)"), Map.entry("NEQ", "다름(≠)"), Map.entry("CROSS_UP", "상향 돌파"), Map.entry("CROSS_DOWN", "하향 돌파"), Map.entry("SLOPE_UP", "상승 중"), Map.entry("LOWEST_LTE", "N봉 최저 ≤"), Map.entry("LOWEST_GTE", "N봉 최저 ≥"), Map.entry("SLOPE_DOWN", "하락 중") ); private record PendingCond(String id, JsonNode condition, String timeframe, String side) {} private final GcStrategyRepository strategyRepo; private final Ta4jStorage ta4jStorage; private final StrategyDslToTa4jAdapter adapter; private final IndicatorSettingsService indicatorSettingsService; private final ObjectMapper objectMapper; private final DynamicSubscriptionManager subscriptionManager; private final StrategyDslTimeframeNormalizer timeframeNormalizer; @Transactional(readOnly = true) public LiveConditionStatusDto evaluate(Long userId, String deviceId, String market, long strategyId) { return evaluate(userId, deviceId, market, strategyId, null); } @Transactional(readOnly = true) public LiveConditionStatusDto evaluate(Long userId, String deviceId, String market, long strategyId, Long barTimeSec) { return evaluate(userId, deviceId, market, strategyId, barTimeSec, null); } @Transactional(readOnly = true) public LiveConditionStatusDto evaluate(Long userId, String deviceId, String market, long strategyId, Long barTimeSec, Map> indicatorParamsOverride) { return evaluate(userId, deviceId, market, strategyId, barTimeSec, indicatorParamsOverride, null, null); } @Transactional(readOnly = true) public LiveConditionStatusDto evaluate(Long userId, String deviceId, String market, long strategyId, Long barTimeSec, Map> indicatorParamsOverride, List chartBars, String chartTimeframe) { Optional opt = strategyRepo.findById(strategyId); if (opt.isEmpty()) { return empty(market, strategyId); } GcStrategy strategy = opt.get(); Map> params = mergeIndicatorParams( indicatorSettingsService.getAll(userId, deviceId), indicatorParamsOverride); Map> visual = indicatorSettingsService.getAllVisual(userId, deviceId); List pending = new ArrayList<>(); collectConditions(strategy.getBuyConditionJson(), "1m", "buy", pending); collectConditions(strategy.getSellConditionJson(), "1m", "sell", pending); if (pending.isEmpty()) { return empty(market, strategyId); } if (chartBars != null && !chartBars.isEmpty() && chartTimeframe != null && !chartTimeframe.isBlank()) { return evaluateOnChartBars(strategy, market, strategyId, chartBars, chartTimeframe, barTimeSec, params, visual); } // 마켓 구독 + warm-up — 과거 봉 평가 시 더 많은 이력 확보 int warmupBars = barTimeSec != null ? 500 : 60; Set timeframes = new LinkedHashSet<>(); for (PendingCond p : pending) { String tf = LiveStrategyTimeframeService.normalize(p.timeframe()); timeframes.add(tf); subscriptionManager.ensureBarsReadySync(market, tf, warmupBars); } subscriptionManager.ensureBarsReadySync(market, "1m", warmupBars); List rows = new ArrayList<>(); int met = 0; int evaluable = 0; Integer primaryEvalIndex = null; for (PendingCond p : pending) { String tf = LiveStrategyTimeframeService.normalize(p.timeframe()); if (!ta4jStorage.exists(market, tf)) { rows.add(toRowUnevaluated(p, visual)); continue; } BarSeries series = ta4jStorage.getOrCreate(market, tf); if (series.isEmpty()) { rows.add(toRowUnevaluated(p, visual)); continue; } int index = resolveBarIndex(series, barTimeSec); if (primaryEvalIndex == null) primaryEvalIndex = index; try { ObjectNode wrapper = objectMapper.createObjectNode(); wrapper.put("type", "CONDITION"); wrapper.set("condition", p.condition()); StrategyDslToTa4jAdapter.RuleBuildContext ctx = new StrategyDslToTa4jAdapter.RuleBuildContext( series, params, visual, market, ta4jStorage, false, java.util.Map.of()); Rule rule = adapter.toRule(wrapper, ctx); boolean satisfied = rule.isSatisfied(index, null); Double current = adapter.readConditionFieldValue( p.condition(), series, params, index, true); Double target = readTargetNumeric(p.condition(), visual); if (target == null) { target = adapter.readConditionFieldValue( p.condition(), series, params, index, false); } rows.add(toRow(p, current, target, satisfied, series, params, index)); evaluable++; if (satisfied) met++; } catch (Exception e) { log.debug("[LiveCondition] eval fail {} {}: {}", market, p.id(), e.getMessage()); rows.add(toRowUnevaluated(p, visual)); } } int matchRate = evaluable > 0 ? Math.round(100f * met / evaluable) : 0; String tfSummary = String.join(", ", timeframes); // [항목6] 전체 논리 트리 평가 — matchRate 와 달리 AND/OR/NOT 게이트 완전 반영 Boolean overallEntryMet = evaluateOverallRule( strategy.getBuyConditionJson(), market, params, visual, barTimeSec); Boolean overallExitMet = evaluateOverallRule( strategy.getSellConditionJson(), market, params, visual, barTimeSec); return LiveConditionStatusDto.builder() .market(market) .strategyId(strategyId) .timeframe(tfSummary) .matchRate(matchRate) .overallEntryMet(overallEntryMet) .overallExitMet(overallExitMet) .rows(rows) .updatedAt(System.currentTimeMillis()) .barTimeSec(barTimeSec) .evalBarIndex(primaryEvalIndex) .build(); } /** * barTimeSec(초)에 가장 가까운 bar index — null 이면 최신 봉. * 차트·업비트 API 와 동일하게 봉 시작(open) 시각 기준으로 매칭한다. */ private int resolveBarIndex(BarSeries series, Long barTimeSec) { if (barTimeSec == null) return series.getEndIndex(); int bestIdx = series.getEndIndex(); long bestDist = Long.MAX_VALUE; for (int i = series.getBeginIndex(); i <= series.getEndIndex(); i++) { long openSec = barOpenEpochSec(series, i); long dist = Math.abs(openSec - barTimeSec); if (dist < bestDist) { bestDist = dist; bestIdx = i; } } return bestIdx; } /** Ta4j Bar → 차트용 봉 시작 시각(초) — BacktestingService.barStartEpoch 와 동일 */ private static long barOpenEpochSec(BarSeries series, int index) { var bar = series.getBar(index); return bar.getEndTime().getEpochSecond() - bar.getTimePeriod().getSeconds(); } /** * 차트·백테스트와 동일 OHLCV 로 조건 평가 — Ta4jStorage 와의 불일치 방지. */ private LiveConditionStatusDto evaluateOnChartBars( GcStrategy strategy, String market, long strategyId, List chartBars, String chartTimeframe, Long barTimeSec, Map> params, Map> visual) { try { JsonNode buyDslRaw = timeframeNormalizer.normalize( objectMapper.readTree( strategy.getBuyConditionJson() != null ? strategy.getBuyConditionJson() : "null"), strategy.getName()); JsonNode sellDslRaw = timeframeNormalizer.normalize( objectMapper.readTree( strategy.getSellConditionJson() != null ? strategy.getSellConditionJson() : "null"), strategy.getName()); String primaryTf = OhlcvBarSeriesSupport.normalizeTf(chartTimeframe); JsonNode buyDsl = timeframeNormalizer.remapForChartTimeframe(buyDslRaw, primaryTf); JsonNode sellDsl = timeframeNormalizer.remapForChartTimeframe(sellDslRaw, primaryTf); List pending = new ArrayList<>(); collectConditionsFromNode(buyDsl, "1m", "buy", pending); collectConditionsFromNode(sellDsl, "1m", "sell", pending); BarSeries primarySeries = OhlcvBarSeriesSupport.buildSeries(chartBars, primaryTf); if (primarySeries.isEmpty()) { return empty(market, strategyId); } Map seriesOverrides = OhlcvBarSeriesSupport.buildSeriesOverrides( primarySeries, primaryTf, buyDsl, sellDsl); List rows = new ArrayList<>(); int met = 0; int evaluable = 0; Integer primaryEvalIndex = OhlcvBarSeriesSupport.resolveBarIndex(primarySeries, barTimeSec); for (PendingCond p : pending) { String tf = LiveStrategyTimeframeService.normalize(p.timeframe()); BarSeries series = resolveChartSeries(tf, primaryTf, primarySeries, seriesOverrides, market); if (series == null || series.isEmpty()) { rows.add(toRowUnevaluated(p, visual)); continue; } int index = OhlcvBarSeriesSupport.resolveBarIndex(series, barTimeSec); try { ObjectNode wrapper = objectMapper.createObjectNode(); wrapper.put("type", "CONDITION"); wrapper.set("condition", p.condition()); StrategyDslToTa4jAdapter.RuleBuildContext ctx = new StrategyDslToTa4jAdapter.RuleBuildContext( series, params, visual, market, null, false, seriesOverrides); Rule rule = adapter.toRule(wrapper, ctx); boolean satisfied = rule.isSatisfied(index, null); Double current = adapter.readConditionFieldValue( p.condition(), series, params, index, true); Double target = readTargetNumeric(p.condition(), visual); if (target == null) { target = adapter.readConditionFieldValue( p.condition(), series, params, index, false); } rows.add(toRow(p, current, target, satisfied, series, params, index)); evaluable++; if (satisfied) met++; } catch (Exception e) { log.debug("[LiveCondition:chart] eval fail {} {}: {}", market, p.id(), e.getMessage()); rows.add(toRowUnevaluated(p, visual)); } } int matchRate = evaluable > 0 ? Math.round(100f * met / evaluable) : 0; Boolean overallEntryMet = evaluateOverallRuleOnChart( buyDsl, primarySeries, seriesOverrides, market, params, visual, barTimeSec); Boolean overallExitMet = evaluateOverallRuleOnChart( sellDsl, primarySeries, seriesOverrides, market, params, visual, barTimeSec); return LiveConditionStatusDto.builder() .market(market) .strategyId(strategyId) .timeframe(primaryTf) .matchRate(matchRate) .overallEntryMet(overallEntryMet) .overallExitMet(overallExitMet) .rows(rows) .updatedAt(System.currentTimeMillis()) .barTimeSec(barTimeSec) .evalBarIndex(primaryEvalIndex) .build(); } catch (Exception e) { log.warn("[LiveCondition:chart] evaluate fail market={}: {}", market, e.getMessage()); return empty(market, strategyId); } } private BarSeries resolveChartSeries(String tf, String primaryTf, BarSeries primarySeries, Map overrides, String market) { String normalized = OhlcvBarSeriesSupport.normalizeTf(tf); if (overrides.containsKey(normalized)) { return overrides.get(normalized); } if (normalized.equals(primaryTf)) { return primarySeries; } // 차트봉 평가 — overrides 가 있으면 storage 와 혼합하지 않음 if (!overrides.isEmpty()) { return primarySeries; } if (ta4jStorage.exists(market, normalized)) { return ta4jStorage.getOrCreate(market, normalized); } return primarySeries; } /** 백테스트·scan-signals 와 동일 — 마지막 evalCount 봉만 스캔 */ private static int resolveChartScanStartIndex(BarSeries series, int evalCount) { if (series == null || series.isEmpty() || evalCount <= 0) { return series != null ? series.getBeginIndex() : 0; } int total = series.getBarCount(); if (evalCount >= total) return series.getBeginIndex(); return series.getEndIndex() - evalCount + 1; } private Boolean evaluateOverallRuleOnChart(JsonNode dsl, BarSeries primarySeries, Map seriesOverrides, String market, Map> params, Map> visual, Long barTimeSec) { if (dsl == null || dsl.isNull()) return null; try { if (primarySeries == null || primarySeries.isEmpty()) return null; StrategyDslToTa4jAdapter.RuleBuildContext ctx = new StrategyDslToTa4jAdapter.RuleBuildContext( primarySeries, params, visual, market, null, false, seriesOverrides); Rule rule = adapter.toRule(dsl, ctx); int index = OhlcvBarSeriesSupport.resolveBarIndex(primarySeries, barTimeSec); return rule.isSatisfied(index, null); } catch (Exception e) { log.debug("[LiveCondition:chart] overall rule fail market={}: {}", market, e.getMessage()); return null; } } private void collectConditionsFromNode(JsonNode node, String timeframe, String side, List out) { if (node == null || node.isNull()) return; walk(node, timeframe, side, out, new HashSet<>()); } private LiveConditionStatusDto empty(String market, long strategyId) { return LiveConditionStatusDto.builder() .market(market) .strategyId(strategyId) .timeframe("") .matchRate(0) .rows(List.of()) .updatedAt(System.currentTimeMillis()) .build(); } // ── [항목6] 전체 논리 트리 평가 ──────────────────────────────────────────── /** * 전략 DSL 전체를 Ta4j Rule 로 변환하여 현재 시계열에 대해 평가한다. * *

기존 {@code matchRate} 는 단순 CONDITION 리프 충족 비율이지만, * 이 메서드는 AND/OR/NOT 게이트까지 완전히 반영하여 실제 시그널과 동일한 * 논리 결과를 반환한다. * * @return {@code true} = 현재 조건 충족, {@code false} = 미충족, * {@code null} = 데이터 부족·평가 불가 */ private Boolean evaluateOverallRule(String conditionJson, String market, Map> params, Map> visual, Long barTimeSec) { if (conditionJson == null || conditionJson.isBlank()) return null; try { com.fasterxml.jackson.databind.JsonNode dsl = objectMapper.readTree(conditionJson); if (dsl == null || dsl.isNull()) return null; // DSL 루트의 주 시간봉 추출 (TIMEFRAME 노드가 있으면 해당 봉 사용) // 5m 전략에서 1m 시리즈로 평가하면 CROSS_UP 등이 부정확해지는 버그 수정 String primaryTf = extractPrimaryTimeframe(dsl); if (!ta4jStorage.exists(market, primaryTf)) { // 지정 봉이 없으면 1m으로 폴백 primaryTf = "1m"; if (!ta4jStorage.exists(market, "1m")) return null; } BarSeries series = ta4jStorage.getOrCreate(market, primaryTf); if (series.isEmpty()) return null; StrategyDslToTa4jAdapter.RuleBuildContext ctx = new StrategyDslToTa4jAdapter.RuleBuildContext( series, params, visual, market, ta4jStorage, false, java.util.Map.of()); org.ta4j.core.Rule rule = adapter.toRule(dsl, ctx); return rule.isSatisfied(resolveBarIndex(series, barTimeSec), null); } catch (Exception e) { log.debug("[LiveCondition] overall rule eval fail market={}: {}", market, e.getMessage()); return null; } } /** * DSL 트리에서 주 시간봉을 추출한다. * TIMEFRAME 노드가 있으면 그 candleType을 반환, 없으면 "1m". * 복수의 TIMEFRAME이 있으면 가장 길게 등장하는 봉 우선 (다수결). */ private String extractPrimaryTimeframe(com.fasterxml.jackson.databind.JsonNode dsl) { Map tfCount = new java.util.LinkedHashMap<>(); collectTimeframes(dsl, tfCount); if (tfCount.isEmpty()) return "1m"; return tfCount.entrySet().stream() .max(java.util.Map.Entry.comparingByValue()) .map(java.util.Map.Entry::getKey) .orElse("1m"); } private void collectTimeframes(com.fasterxml.jackson.databind.JsonNode node, Map out) { if (node == null || node.isNull()) return; String type = node.path("type").asText("CONDITION"); if ("TIMEFRAME".equals(type)) { String tf = LiveStrategyTimeframeService.normalize( node.path("candleType").asText("1m")); out.merge(tf, 1, Integer::sum); } com.fasterxml.jackson.databind.JsonNode children = node.path("children"); if (children.isArray()) { for (com.fasterxml.jackson.databind.JsonNode c : children) collectTimeframes(c, out); } com.fasterxml.jackson.databind.JsonNode child = node.path("child"); if (!child.isMissingNode()) collectTimeframes(child, out); } private void collectConditions(String json, String timeframe, String side, List out) { if (json == null || json.isBlank()) return; try { walk(objectMapper.readTree(json), timeframe, side, out, new HashSet<>()); } catch (Exception e) { log.warn("[LiveCondition] JSON walk fail: {}", e.getMessage()); } } private void walk(JsonNode node, String timeframe, String side, List out, Set seen) { if (node == null || node.isNull()) return; // StrategyDslToTa4jAdapter 와 동일하게 type 미설정 시 "CONDITION" 기본값 적용 String type = node.path("type").asText("CONDITION"); if ("TIMEFRAME".equals(type)) { String tf = LiveStrategyTimeframeService.normalize( node.path("candleType").asText(timeframe)); JsonNode children = node.path("children"); if (children.isArray()) { for (JsonNode c : children) walk(c, tf, side, out, seen); } return; } if ("AND".equals(type) || "OR".equals(type)) { JsonNode children = node.path("children"); if (children.isArray()) { for (JsonNode c : children) walk(c, timeframe, side, out, seen); } return; } if ("NOT".equals(type)) { walk(node.path("child"), timeframe, side, out, seen); return; } if ("CONDITION".equals(type) && node.has("condition")) { JsonNode cond = node.path("condition"); String nodeId = node.path("id").asText(""); String id = !nodeId.isBlank() ? nodeId + "-" + side : side + ":" + timeframe + ":" + cond.hashCode(); String key = id + ":" + cond.toString(); if (seen.add(key)) { out.add(new PendingCond(id, cond, timeframe, side)); } return; } // START·구버전 DSL 등 — 자식 노드 재귀 (프론트 extractVirtualConditions 와 동일) JsonNode children = node.path("children"); if (children.isArray()) { for (JsonNode c : children) walk(c, timeframe, side, out, seen); } } private LiveConditionRowDto toRowUnevaluated(PendingCond p, Map> visual) { JsonNode c = p.condition(); String indType = c.path("indicatorType").asText(""); String plotKey = plotKeyFromCondition(c, indType); String condType = c.path("conditionType").asText("GT"); String baseLabel = CONDITION_LABEL.getOrDefault(condType, condType); String rangePrefix = StrategyDslToTa4jAdapter.candleRangeConditionPrefix(c); String condLabel = rangePrefix.isBlank() ? baseLabel : rangePrefix + baseLabel; Double target = readTargetNumeric(c, visual); return LiveConditionRowDto.builder() .id(p.id()) .indicatorType(indType) .displayName(indType) .conditionType(condType) .conditionLabel(condLabel) .thresholdLabel(formatThresholdStatic(c, target, visual)) .currentValue(null) .targetValue(target) .satisfied(null) .timeframe(p.timeframe()) .side(p.side()) .plotKey(plotKey) .build(); } private String formatThresholdStatic(JsonNode cond, Double target, Map> params) { if (target != null && !target.isNaN()) { String ct = cond.path("conditionType").asText("GT"); String v = formatNum(target); return switch (ct) { case "LT", "CROSS_DOWN" -> "< " + v; case "GT", "CROSS_UP" -> "> " + v; case "GTE" -> "≥ " + v; case "LTE" -> "≤ " + v; case "EQ" -> "= " + v; default -> cond.path("rightField").asText(v); }; } String rf = cond.path("rightField").asText(""); if (rf != null && !rf.isBlank() && !"NONE".equals(rf)) { return adapter.formatMovingAverageFieldLabel(rf, params); } return "—"; } private LiveConditionRowDto toRow(PendingCond p, Double current, Double target, Boolean satisfied, BarSeries series, Map> params, int index) { JsonNode c = p.condition(); String indType = c.path("indicatorType").asText(""); String plotKey = plotKeyFromCondition(c, indType); String condType = c.path("conditionType").asText("GT"); String baseLabel = CONDITION_LABEL.getOrDefault(condType, condType); String rangePrefix = StrategyDslToTa4jAdapter.candleRangeConditionPrefix(c); String condLabel = rangePrefix.isBlank() ? baseLabel : rangePrefix + baseLabel; return LiveConditionRowDto.builder() .id(p.id()) .indicatorType(indType) .displayName(indType) .conditionType(condType) .conditionLabel(condLabel) .thresholdLabel(formatThreshold(c, target, series, params, index)) .currentValue(current) .targetValue(target) .satisfied(satisfied) .timeframe(p.timeframe()) .side(p.side()) .plotKey(plotKey) .build(); } private String formatThreshold(JsonNode cond, Double target, BarSeries series, Map> params, int index) { String ct = cond.path("conditionType").asText("GT"); if (target != null && !target.isNaN()) { String v = formatNum(target); return switch (ct) { case "LT", "CROSS_DOWN" -> "< " + v; case "GT", "CROSS_UP" -> "> " + v; case "GTE" -> "≥ " + v; case "LTE" -> "≤ " + v; case "EQ" -> "= " + v; default -> cond.path("rightField").asText(v); }; } String rf = cond.path("rightField").asText(""); if (rf != null && !rf.isBlank() && !"NONE".equals(rf)) { Double rightLive = adapter.readConditionFieldValue(cond, series, params, index, false); if (rightLive != null && !rightLive.isNaN()) { String v = formatNum(rightLive); String fieldLabel = adapter.formatMovingAverageFieldLabel(rf, params); return switch (ct) { case "LT", "CROSS_DOWN" -> "< " + v; case "GT", "CROSS_UP" -> "> " + v; case "GTE" -> "≥ " + v; case "LTE" -> "≤ " + v; case "EQ" -> "= " + v; default -> fieldLabel + " (" + v + ")"; }; } return adapter.formatMovingAverageFieldLabel(rf, params); } return "—"; } private static String formatNum(double v) { if (Math.abs(v) >= 1000) return String.format("%.0f", v); return String.format("%.2f", v); } private Double readTargetNumeric(JsonNode cond, Map> visual) { String indType = cond.path("indicatorType").asText(""); String rf = cond.path("rightField").asText(""); Double resolved = IndicatorHlineResolver.resolveThresholdField(cond, rf, indType, visual); if (resolved != null) return resolved; if (cond.has("targetValue") && !cond.path("targetValue").isNull()) { return cond.path("targetValue").asDouble(); } if (cond.has("compareValue") && !cond.path("compareValue").isNull()) { return cond.path("compareValue").asDouble(); } if (rf.startsWith("K_")) { try { return Double.parseDouble(rf.substring(2)); } catch (NumberFormatException ignored) {} } return null; } private static String plotKeyFromCondition(JsonNode cond, String indicatorType) { String left = cond.path("leftField").asText(""); if (left != null && !left.isBlank() && !"none".equalsIgnoreCase(left)) { return left; } return indicatorType; } private static Map> mergeIndicatorParams( Map> base, Map> override) { if (override == null || override.isEmpty()) return base; if (base == null || base.isEmpty()) return override; Map> merged = new LinkedHashMap<>(base); override.forEach((type, overrideParams) -> { Map baseParams = merged.get(type); if (baseParams == null || baseParams.isEmpty()) { merged.put(type, overrideParams); } else { Map inner = new LinkedHashMap<>(baseParams); inner.putAll(overrideParams); merged.put(type, inner); } }); return merged; } /** * 차트 봉 구간을 스캔하여 매수/매도 시그널 목록을 반환한다. * {@link #evaluateOnChartBars} 의 {@code overallEntryMet}/{@code overallExitMet} 와 * 동일 Rule 엔진을 사용하므로 조건 패널 "충족" 과 차트 마커가 일치한다. */ @Transactional(readOnly = true) public List scanSignalsOnChartBars( long userId, String deviceId, String market, long strategyId, List chartBars, String chartTimeframe, Map> indicatorParamsOverride, Integer evaluationBarCount) { Optional opt = strategyRepo.findById(strategyId); if (opt.isEmpty() || chartBars == null || chartBars.isEmpty() || chartTimeframe == null || chartTimeframe.isBlank()) { return List.of(); } GcStrategy strategy = opt.get(); Map> params = mergeIndicatorParams( indicatorSettingsService.getAll(userId, deviceId), indicatorParamsOverride); Map> visual = indicatorSettingsService.getAllVisual(userId, deviceId); try { String strategyName = strategy.getName(); JsonNode buyDslRaw = timeframeNormalizer.normalize( objectMapper.readTree( strategy.getBuyConditionJson() != null ? strategy.getBuyConditionJson() : "null"), strategyName); JsonNode sellDslRaw = timeframeNormalizer.normalize( objectMapper.readTree( strategy.getSellConditionJson() != null ? strategy.getSellConditionJson() : "null"), strategyName); String primaryTf = OhlcvBarSeriesSupport.normalizeTf(chartTimeframe); JsonNode buyDsl = timeframeNormalizer.remapForChartTimeframe(buyDslRaw, primaryTf); JsonNode sellDsl = timeframeNormalizer.remapForChartTimeframe(sellDslRaw, primaryTf); BarSeries primarySeries = OhlcvBarSeriesSupport.buildSeries(chartBars, primaryTf); if (primarySeries.isEmpty()) { return List.of(); } Map seriesOverrides = OhlcvBarSeriesSupport.buildSeriesOverrides( primarySeries, primaryTf, buyDsl, sellDsl); int evalCount = evaluationBarCount != null && evaluationBarCount > 0 ? evaluationBarCount : primarySeries.getBarCount(); int startIdx = resolveChartScanStartIndex(primarySeries, evalCount); StrategyDslToTa4jAdapter.RuleBuildContext ruleCtx = new StrategyDslToTa4jAdapter.RuleBuildContext( primarySeries, params, visual, market, null, false, seriesOverrides); Rule entryRule = (buyDsl != null && !buyDsl.isNull()) ? adapter.toRule(buyDsl, ruleCtx) : new org.ta4j.core.rules.BooleanRule(false); Rule exitRule = (sellDsl != null && !sellDsl.isNull()) ? adapter.toRule(sellDsl, ruleCtx) : new org.ta4j.core.rules.BooleanRule(false); List signals = new ArrayList<>(); int buyHits = 0; int sellHits = 0; for (int i = startIdx; i <= primarySeries.getEndIndex(); i++) { long barTimeSec = barOpenEpochSec(primarySeries, i); double close = primarySeries.getBar(i).getClosePrice().doubleValue(); if (entryRule.isSatisfied(i, null)) { buyHits++; signals.add(BacktestResponse.Signal.builder() .time(barTimeSec) .type("BUY") .price(close) .barIndex(i) .quantity(0) .build()); } if (exitRule.isSatisfied(i, null)) { sellHits++; signals.add(BacktestResponse.Signal.builder() .time(barTimeSec) .type("SELL") .price(close) .barIndex(i) .quantity(0) .build()); } } if (signals.isEmpty()) { log.info("[LiveCondition:scan] 0 signals market={} strategy={} tf={} bars={} eval={}..{}", market, strategyId, primaryTf, primarySeries.getBarCount(), startIdx, primarySeries.getEndIndex()); } else { log.debug("[LiveCondition:scan] market={} strategy={} tf={} buy={} sell={}", market, strategyId, primaryTf, buyHits, sellHits); } return signals; } catch (Exception e) { log.warn("[LiveCondition:scan] fail market={} strategy={}: {}", market, strategyId, e.getMessage(), e); return List.of(); } } }