package com.goldenchart.service; import com.goldenchart.entity.GcLiveStrategySettings; import com.goldenchart.entity.GcStrategy; import com.goldenchart.repository.GcLiveStrategySettingsRepository; import com.goldenchart.repository.GcStrategyRepository; import com.goldenchart.storage.Ta4jStorage; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.ta4j.core.BarSeries; import org.ta4j.core.BaseTradingRecord; import org.ta4j.core.Rule; import org.springframework.stereotype.Service; import java.util.List; import java.util.Map; import java.util.Optional; import java.util.concurrent.ConcurrentHashMap; /** * 실시간 전략 체크 평가기. * *

두 가지 방식으로 전략 만족 여부를 판정한다: *

* *

포지션 종속성 모드: *

* *

동시성 안전: *

*/ @Service @RequiredArgsConstructor @Slf4j public class LiveStrategyEvaluator { private final GcLiveStrategySettingsRepository settingsRepo; private final GcStrategyRepository strategyRepo; private final Ta4jStorage ta4jStorage; private final IndicatorSettingsService indicatorSettingsService; private final StrategySignalDeterminer determiner; private final StrategyConditionTimeframeService conditionTimeframes; private final StrategyTriggerBranchEvaluator triggerBranchEvaluator; private final StrategyBranchStateCache branchStateCache; /** * 트리거 분봉별 Rule 캐시: "market:candleType:strategyId" → (entryRule, exitRule) */ private final ConcurrentHashMap triggerRulesCache = new ConcurrentHashMap<>(); private record TriggerRules(Rule entryRule, Rule exitRule) {} /** * LONG_ONLY 모드용 TradingRecord 캐시: "market:candleType:strategyId" → TradingRecord * 시그널이 발생할 때마다 Record 를 갱신하여 포지션 상태를 유지한다. */ private final ConcurrentHashMap tradingRecordCache = new ConcurrentHashMap<>(); /** * LONG_ONLY 모드용 포지션 오픈 여부 추적: Ta4j API 버전 차이 대응. */ private final ConcurrentHashMap positionOpenCache = new ConcurrentHashMap<>(); /** * REALTIME_TICK 중복 시그널 방지: "market:candleType" → 마지막으로 시그널을 낸 seriesIndex. * 동일 provisional 봉에서 3초 스케줄러가 반복 실행되더라도 이미 시그널을 낸 인덱스면 무시. * 새 provisional 봉(다음 분)이 시작되면 자동으로 다른 index 이므로 갱신된다. */ private final ConcurrentHashMap realtimeSignaledIdx = new ConcurrentHashMap<>(); // ── 공개 API ───────────────────────────────────────────────────────────── /** * 방식 A — 봉 마감 직후 호출. * * @param market 마켓 코드 * @param candleType 캔들 타입 * @param maturedIndex 확정된 봉의 BarSeries 인덱스 * @return "BUY" | "SELL" | "NONE" */ public String evaluateCandleClose(String market, String candleType, int maturedIndex) { List settings = settingsRepo.findActiveByMarket(market); if (settings.isEmpty()) return "NONE"; if (!ta4jStorage.exists(market, candleType)) return "NONE"; log.debug("[Evaluator] CANDLE_CLOSE 평가: {} {} idx={} barCount={}", market, candleType, maturedIndex, ta4jStorage.getOrCreate(market, candleType).getBarCount()); for (GcLiveStrategySettings s : settings) { String result = evaluateSettingOnCandleClose(s, market, candleType, maturedIndex); if (!"NONE".equals(result)) return result; } return "NONE"; } /** * 단일 live 설정 — 봉 마감(CANDLE_CLOSE) 평가. */ public String evaluateSettingOnCandleClose(GcLiveStrategySettings s, String market, String candleType, int maturedIndex) { if (!Boolean.TRUE.equals(s.getIsLiveCheck())) return "NONE"; if (!"CANDLE_CLOSE".equals(s.getExecutionType())) return "NONE"; if (s.getStrategyId() == null) return "NONE"; if (!ta4jStorage.exists(market, candleType)) return "NONE"; if (!conditionTimeframes.usesTimeframe(s.getStrategyId(), candleType)) return "NONE"; String result = evaluate(market, candleType, s.getStrategyId(), s.getPositionMode(), maturedIndex, s.getDeviceId(), s.getUserId()); if (!"NONE".equals(result)) { log.info("[Evaluator] CANDLE_CLOSE strategyId={} {} {} idx={} mode={} → {}", s.getStrategyId(), market, candleType, maturedIndex, s.getPositionMode(), result); } return result; } /** * 방식 B — 3초 스케줄러에서 호출. * *

동일 provisional 봉(endIndex)에서 CROSS 시그널이 중복 발화되지 않도록 * 마지막 시그널 인덱스를 추적한다. 새 분이 시작되면 endIndex가 증가하므로 * 자동으로 다음 교차 이벤트를 감지할 수 있다. * *

캐시 무효화 정책 (Ta4j CachedIndicator memoization 문제 대응)
* Ta4j 의 {@code CachedIndicator} 구현체(CCIIndicator, RSIIndicator 등)는 * {@code getValue(index)} 결과를 내부 리스트에 영구 저장한다. * {@code Ta4jStorage.updateLastBar} 가 provisional 봉의 close price 를 갱신해도 * 이미 캐시된 값은 변하지 않으므로, CrossedDown/Up Rule 이 구 가격으로 판정한다. * 이를 방지하기 위해 REALTIME_TICK 평가마다 {@code strategyCache} 를 지워 * 인디케이터 인스턴스를 강제 재생성한다. * * @param market 마켓 코드 * @param candleType 캔들 타입 * @return "BUY" | "SELL" | "NONE" */ public String evaluateRealtimeTick(String market, String candleType) { List settings = settingsRepo.findActiveByMarket(market); if (settings.isEmpty()) return "NONE"; for (GcLiveStrategySettings s : settings) { String result = evaluateSettingRealtimeTick(s, market, candleType); if (!"NONE".equals(result)) return result; } return "NONE"; } /** * 단일 live 설정 — 3초 스케줄러(REALTIME_TICK) 평가. */ public String evaluateSettingRealtimeTick(GcLiveStrategySettings s, String market, String candleType) { if (!Boolean.TRUE.equals(s.getIsLiveCheck())) return "NONE"; if (!"REALTIME_TICK".equals(s.getExecutionType())) return "NONE"; if (s.getStrategyId() == null) return "NONE"; if (!ta4jStorage.exists(market, candleType)) return "NONE"; BarSeries series = ta4jStorage.getOrCreate(market, candleType); if (series.isEmpty()) return "NONE"; int currentIndex = series.getEndIndex(); if (!conditionTimeframes.usesTimeframe(s.getStrategyId(), candleType)) return "NONE"; String dedupeKey = market + ":" + candleType + ":" + s.getStrategyId(); Integer lastSignaledIdx = realtimeSignaledIdx.get(dedupeKey); if (lastSignaledIdx != null && lastSignaledIdx == currentIndex) return "NONE"; String cacheKey = market + ":" + candleType + ":" + s.getStrategyId(); triggerRulesCache.remove(cacheKey); String result = evaluate(market, candleType, s.getStrategyId(), s.getPositionMode(), currentIndex, s.getDeviceId(), s.getUserId()); if (!"NONE".equals(result)) { log.info("[Evaluator] REALTIME_TICK strategyId={} {} {} idx={} mode={} → {}", s.getStrategyId(), market, candleType, currentIndex, s.getPositionMode(), result); realtimeSignaledIdx.put(dedupeKey, currentIndex); } return result; } /** * 봉 마감 직후 REALTIME_TICK 설정에 대해 확정봉 인덱스로 전략을 평가한다. * *

배경: CrossedDown/Up 교차 이벤트가 봉 마감 시점에 발생하면, 3초 스케줄러는 * 이미 다음 분의 provisional 봉(index N+1)을 평가하기 때문에 교차를 감지하지 못한다. * (CrossedDown(N+1) → CCI(N) < threshold, CCI(N+1) < threshold → 교차 없음) * *

이 메서드를 {@code BarBuilder.commitBar} 에서 호출하면 CANDLE_CLOSE 와 동일한 * 타이밍에 REALTIME_TICK 전략도 확정봉 인덱스(N)로 평가하여 누락을 방지한다. * * @param market 마켓 코드 * @param candleType 캔들 타입 * @param maturedIndex 확정된 봉의 BarSeries 인덱스 * @return "BUY" | "SELL" | "NONE" */ public String evaluateRealtimeAtClose(String market, String candleType, int maturedIndex) { List settings = settingsRepo.findActiveByMarket(market); if (settings.isEmpty()) return "NONE"; for (GcLiveStrategySettings s : settings) { String result = evaluateSettingRealtimeAtClose(s, market, candleType, maturedIndex); if (!"NONE".equals(result)) return result; } return "NONE"; } /** * 단일 live 설정 — 봉 마감 시점 REALTIME_TICK 평가(교차 누락 방지). */ public String evaluateSettingRealtimeAtClose(GcLiveStrategySettings s, String market, String candleType, int maturedIndex) { if (!Boolean.TRUE.equals(s.getIsLiveCheck())) return "NONE"; if (!"REALTIME_TICK".equals(s.getExecutionType())) return "NONE"; if (s.getStrategyId() == null) return "NONE"; if (!ta4jStorage.exists(market, candleType)) return "NONE"; if (!conditionTimeframes.usesTimeframe(s.getStrategyId(), candleType)) return "NONE"; String cacheKey = market + ":" + candleType + ":" + s.getStrategyId(); triggerRulesCache.remove(cacheKey); String result = evaluate(market, candleType, s.getStrategyId(), s.getPositionMode(), maturedIndex, s.getDeviceId(), s.getUserId()); if (!"NONE".equals(result)) { log.info("[Evaluator] REALTIME_TICK @candle_close strategyId={} {} {} idx={} mode={} → {}", s.getStrategyId(), market, candleType, maturedIndex, s.getPositionMode(), result); realtimeSignaledIdx.put(market + ":" + candleType + ":" + s.getStrategyId(), maturedIndex); } return result; } /** 설정 변경 시 해당 마켓 캐시 무효화 */ public void invalidateCache(String 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 ─────────────────────────────────────────────────────────────── private String evaluate(String market, String candleType, long strategyId, String positionMode, int index, String deviceId, Long userId) { String cacheKey = market + ":" + candleType + ":" + strategyId; TriggerRules rules = triggerRulesCache.get(cacheKey); if (rules == null) { rules = buildTriggerRules(market, candleType, strategyId, deviceId, userId); if (rules == null) return "NONE"; triggerRulesCache.put(cacheKey, rules); } try { String mode = positionMode != null ? positionMode : "LONG_ONLY"; if ("SIGNAL_ONLY".equals(mode)) { return determiner.determineSignalFromRules( rules.entryRule(), rules.exitRule(), null, index, "SIGNAL_ONLY"); } BaseTradingRecord record = tradingRecordCache.computeIfAbsent( cacheKey, k -> new BaseTradingRecord()); boolean isOpen = Boolean.TRUE.equals(positionOpenCache.get(cacheKey)); String signal = determiner.determineSignalFromRules( rules.entryRule(), rules.exitRule(), record, index, "LONG_ONLY"); if ("BUY".equals(signal) && !isOpen) { BarSeries series = ta4jStorage.getOrCreate(market, candleType); org.ta4j.core.num.Num price = series.getBar(index).getClosePrice(); record.enter(index, price, series.numFactory().numOf(1)); positionOpenCache.put(cacheKey, true); } else if ("SELL".equals(signal) && isOpen) { BarSeries series = ta4jStorage.getOrCreate(market, candleType); org.ta4j.core.num.Num price = series.getBar(index).getClosePrice(); record.exit(index, price, series.numFactory().numOf(1)); positionOpenCache.put(cacheKey, false); } else if ("BUY".equals(signal) || "SELL".equals(signal)) { signal = "NONE"; } return signal; } catch (Exception e) { log.warn("[Evaluator] 전략 판정 오류 key={} idx={}: {}", cacheKey, index, e.getMessage()); triggerRulesCache.remove(cacheKey); tradingRecordCache.remove(cacheKey); } return "NONE"; } private TriggerRules buildTriggerRules(String market, String candleType, long strategyId, String deviceId, Long userId) { Optional strategyOpt = strategyRepo.findById(strategyId); if (strategyOpt.isEmpty()) return null; GcStrategy strategy = strategyOpt.get(); if (!ta4jStorage.exists(market, candleType)) return null; BarSeries series = ta4jStorage.getOrCreate(market, candleType); Map> indicatorParams = indicatorSettingsService.getAll(userId, deviceId); int barCount = series.getBarCount(); if (barCount < 2) { log.warn("[Evaluator] 바 수 부족 strategyId={} market={} barCount={} (최소 2 이상 필요)", strategyId, market, barCount); return null; } try { 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] Trigger Rule 빌드 실패 strategyId={}: {}", strategyId, e.getMessage()); return null; } } }