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.LiveConditionRowDto; import com.goldenchart.dto.LiveConditionStatusDto; 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("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; @Transactional(readOnly = true) public LiveConditionStatusDto evaluate(Long userId, String deviceId, String market, long strategyId) { Optional opt = strategyRepo.findById(strategyId); if (opt.isEmpty()) { return empty(market, strategyId); } GcStrategy strategy = opt.get(); Map> params = indicatorSettingsService.getAll(userId, deviceId); 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); } // 데이터 수신 보장 — 평가 전 해당 종목·봉 타입 pin + 동기 warm-up Set timeframes = new LinkedHashSet<>(); for (PendingCond p : pending) { String tf = LiveStrategyTimeframeService.normalize(p.timeframe()); timeframes.add(tf); subscriptionManager.ensureBarsReadySync(market, tf, 60); } subscriptionManager.ensureBarsReadySync(market, "1m", 60); List rows = new ArrayList<>(); int met = 0; int evaluable = 0; 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 = series.getEndIndex(); 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); return LiveConditionStatusDto.builder() .market(market) .strategyId(strategyId) .timeframe(tfSummary) .matchRate(matchRate) .rows(rows) .updatedAt(System.currentTimeMillis()) .build(); } private LiveConditionStatusDto empty(String market, long strategyId) { return LiveConditionStatusDto.builder() .market(market) .strategyId(strategyId) .timeframe("") .matchRate(0) .rows(List.of()) .updatedAt(System.currentTimeMillis()) .build(); } 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; String type = node.path("type").asText(""); 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)); } } } 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 condLabel = CONDITION_LABEL.getOrDefault(condType, condType); Double target = readTargetNumeric(c, visual); return LiveConditionRowDto.builder() .id(p.id()) .indicatorType(indType) .displayName(indType) .conditionType(condType) .conditionLabel(condLabel) .thresholdLabel(formatThresholdStatic(c, target)) .currentValue(null) .targetValue(target) .satisfied(null) .timeframe(p.timeframe()) .side(p.side()) .plotKey(plotKey) .build(); } private String formatThresholdStatic(JsonNode cond, Double target) { 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 rf.replace('_', ' '); } 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 condLabel = CONDITION_LABEL.getOrDefault(condType, condType); 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); return switch (ct) { case "LT", "CROSS_DOWN" -> "< " + v; case "GT", "CROSS_UP" -> "> " + v; case "GTE" -> "≥ " + v; case "LTE" -> "≤ " + v; case "EQ" -> "= " + v; default -> rf.replace('_', ' ') + " (" + v + ")"; }; } return rf.replace('_', ' '); } 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; } }