package com.goldenchart.service; import com.goldenchart.entity.GcLiveStrategySettings; import com.goldenchart.entity.GcStrategy; import com.goldenchart.entity.GcTradeSignal; import com.goldenchart.repository.GcLiveStrategySettingsRepository; import com.goldenchart.repository.GcPaperAccountRepository; import com.goldenchart.repository.GcPaperPositionRepository; import com.goldenchart.repository.GcStrategyRepository; import com.goldenchart.repository.GcTradeSignalRepository; 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.math.BigDecimal; import java.util.List; import java.util.Map; import java.util.Optional; import java.util.concurrent.ConcurrentHashMap; /** * 실시간 전략 체크 평가기. * *

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

* *

REALTIME_TICK 캐시 우회 이유 (동시성 보장)

*

Ta4j {@code CachedIndicator} 는 {@code getValue(index)} 결과를 내부 리스트에 영구 저장한다. * {@code Ta4jStorage.updateLastBar} 가 provisional 봉의 close price 를 갱신해도 * 이미 캐시된 값은 변하지 않으므로 Rule 이 구 가격으로 판정(Ghost Signal)한다. * REALTIME_TICK 에서는 Rule 자체를 매번 재생성함으로써 캐시 오염을 원천 차단한다. * 이 방식은 기존 "triggerRulesCache.remove + evaluate" 복합 연산의 스레드 안전성 문제도 해소한다. * *

포지션 종속성 모드: *

* *

동시성 안전: *

*/ @Service @RequiredArgsConstructor @Slf4j public class LiveStrategyEvaluator { private final GcLiveStrategySettingsRepository settingsRepo; private final GcStrategyRepository strategyRepo; private final GcTradeSignalRepository tradeSignalRepo; private final GcPaperAccountRepository paperAccountRepo; private final GcPaperPositionRepository paperPositionRepo; 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 버전 차이 대응. * 서버 재시작 시 gc_trade_signal 최신 레코드로 복원된다 (resolvePositionOpen 참조). */ private final ConcurrentHashMap positionOpenCache = new ConcurrentHashMap<>(); /** * REALTIME_TICK 중복 시그널 방지: "market:candleType" → 마지막으로 시그널을 낸 seriesIndex. * 동일 provisional 봉에서 3초 스케줄러가 반복 실행되더라도 이미 시그널을 낸 인덱스면 무시. * 새 provisional 봉(다음 분)이 시작되면 자동으로 다른 index 이므로 갱신된다. */ private final ConcurrentHashMap realtimeSignaledIdx = new ConcurrentHashMap<>(); /** * [항목1] REALTIME_TICK buildTriggerRules 오버헤드 경감용 앱-레벨 캐시. * *

전략 DSL(GcStrategy)과 지표 파라미터(IndicatorSettings)는 사용자가 편집하지 않는 한 * 변경되지 않으므로 30초 TTL로 캐시하여 3초 주기 재조회 오버헤드를 줄인다. */ private final ConcurrentHashMap strategyCache = new ConcurrentHashMap<>(); private final ConcurrentHashMap indicatorCache = new ConcurrentHashMap<>(); private record StrategyCacheEntry(GcStrategy strategy, long cachedAt) {} private record IndicatorParamsCacheEntry( Map> params, Map> visual, long cachedAt) {} private static final long STRATEGY_CACHE_TTL_MS = 30_000L; private static final long INDICATOR_CACHE_TTL_MS = 30_000L; // ── 공개 API ───────────────────────────────────────────────────────────── /** * 방식 A — 봉 마감 직후 호출. * * @param market 마켓 코드 * @param candleType 캔들 타입 * @param maturedIndex 확정된 봉의 BarSeries 인덱스 * @return "BUY" | "SELL" | "NONE" * @deprecated 마켓 단위 API — 멀티 전략 환경에서 첫 번째 non-NONE 만 반환하므로 * 시그널 발행 경로에서 사용 금지. * 대신 {@link BarCloseStrategyEvaluationService#onMaturedBarClose} 를 사용할 것. */ @Deprecated 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) 평가. * useConfirmedOnly=true: CrossSeriesRule 이 상위봉의 확정봉(endIndex-1)을 사용. */ 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(), true /* useConfirmedOnly */); if (!"NONE".equals(result)) { log.info("[Evaluator] CANDLE_CLOSE strategyId={} {} {} idx={} mode={} → {}", s.getStrategyId(), market, candleType, maturedIndex, s.getPositionMode(), result); } return result; } /** * 방식 B — 3초 스케줄러에서 호출. * * @param market 마켓 코드 * @param candleType 캔들 타입 * @return "BUY" | "SELL" | "NONE" * @deprecated 마켓 단위 API — 멀티 전략 환경에서 첫 번째 non-NONE 만 반환. * 시그널 발행 경로에서 사용 금지. * 대신 {@link #evaluateSettingRealtimeTick} 를 설정별로 직접 호출할 것. */ @Deprecated 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) 평가. * *

캐시 완전 우회: REALTIME_TICK 은 provisional 봉의 close price 가 틱마다 바뀌므로 * Rule 을 매번 신규 빌드(useConfirmedOnly=false)하여 Ta4j CachedIndicator 캐시 오염을 * 원천 차단한다. 동시에 {@code triggerRulesCache} 복합 연산의 스레드 안전성 문제도 제거한다. */ 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"; // 캐시 완전 우회: 매번 신규 Rule 빌드 (CachedIndicator stale 방지 + 동시성 보장) String result = evaluateWithFreshRules(market, candleType, s.getStrategyId(), s.getPositionMode(), currentIndex, s.getDeviceId(), s.getUserId(), false /* useConfirmedOnly */); 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)을 평가하기 때문에 교차를 감지하지 못한다. * 이 메서드를 {@code BarBuilder.commitBar} 에서 호출하면 CANDLE_CLOSE 와 동일한 * 타이밍에 확정봉 인덱스(N)로 평가하여 누락을 방지한다. * useConfirmedOnly=true 로 상위봉도 확정봉 기준 평가. */ 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 평가(교차 누락 방지). * 캐시 우회 + useConfirmedOnly=true. */ 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"; // 확정봉 기준 평가: 캐시 우회 + useConfirmedOnly=true String result = evaluateWithFreshRules(market, candleType, s.getStrategyId(), s.getPositionMode(), maturedIndex, s.getDeviceId(), s.getUserId(), true /* useConfirmedOnly */); 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); // 앱 레벨 전략 DSL 캐시 무효화 strategyCache.remove(strategyId); } /** 지표 설정 변경 시 지표 파라미터 캐시 무효화 */ public void invalidateIndicatorParams(Long userId, String deviceId) { String key = (userId != null ? userId : "") + ":" + (deviceId != null ? deviceId : ""); indicatorCache.remove(key); } // ── Private ─────────────────────────────────────────────────────────────── /** * CANDLE_CLOSE 전용 캐시 사용 경로. * Rule 을 캐시하여 재사용하며, useConfirmedOnly=true 로 빌드한 Rule 을 저장한다. */ private String evaluate(String market, String candleType, long strategyId, String positionMode, int index, String deviceId, Long userId, boolean useConfirmedOnly) { String cacheKey = market + ":" + candleType + ":" + strategyId; TriggerRules rules = triggerRulesCache.get(cacheKey); if (rules == null) { rules = buildTriggerRules(market, candleType, strategyId, deviceId, userId, useConfirmedOnly); if (rules == null) return "NONE"; triggerRulesCache.put(cacheKey, rules); } return applySignalLogic(rules, market, candleType, strategyId, positionMode, index, deviceId, userId); } /** * REALTIME_TICK 전용 캐시 우회 경로. * 매번 신규 Rule 인스턴스를 빌드하여 CachedIndicator stale 캐시를 완전히 차단한다. * 빌드한 Rule 은 캐시에 저장하지 않는다. */ private String evaluateWithFreshRules(String market, String candleType, long strategyId, String positionMode, int index, String deviceId, Long userId, boolean useConfirmedOnly) { TriggerRules rules = buildTriggerRules(market, candleType, strategyId, deviceId, userId, useConfirmedOnly); if (rules == null) return "NONE"; return applySignalLogic(rules, market, candleType, strategyId, positionMode, index, deviceId, userId); } /** Rule 판정 + TradingRecord 갱신 공통 로직 */ private String applySignalLogic(TriggerRules rules, String market, String candleType, long strategyId, String positionMode, int index, String deviceId, Long userId) { String cacheKey = market + ":" + candleType + ":" + strategyId; 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 = resolvePositionOpen(cacheKey, market, strategyId, deviceId, userId); 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, boolean useConfirmedOnly) { GcStrategy strategy = resolveStrategy(strategyId); if (strategy == null) return null; if (!ta4jStorage.exists(market, candleType)) return null; BarSeries series = ta4jStorage.getOrCreate(market, candleType); IndicatorParamsCacheEntry indicatorEntry = resolveIndicatorParams(userId, deviceId); Map> indicatorParams = indicatorEntry.params(); Map> indicatorVisual = indicatorEntry.visual(); 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, indicatorVisual, ta4jStorage, useConfirmedOnly); Rule exitRule = triggerBranchEvaluator.buildTriggerRule( strategy.getSellConditionJson(), market, strategyId, "sell", candleType, indicatorParams, indicatorVisual, ta4jStorage, useConfirmedOnly); return new TriggerRules(entryRule, exitRule); } catch (Exception e) { log.error("[Evaluator] Trigger Rule 빌드 실패 strategyId={}: {}", strategyId, e.getMessage()); return null; } } // ── 항목1: 전략 DSL / 지표 파라미터 앱-레벨 캐시 헬퍼 ───────────────────── /** * strategyId 로 GcStrategy 를 조회하되 TTL 내에는 캐시를 재사용한다. * 사용자가 전략을 수정하면 invalidateStrategy 로 캐시를 무효화한다. */ private GcStrategy resolveStrategy(long strategyId) { StrategyCacheEntry cached = strategyCache.get(strategyId); if (cached != null && (System.currentTimeMillis() - cached.cachedAt()) < STRATEGY_CACHE_TTL_MS) { return cached.strategy(); } Optional opt = strategyRepo.findById(strategyId); if (opt.isEmpty()) { strategyCache.remove(strategyId); return null; } strategyCache.put(strategyId, new StrategyCacheEntry(opt.get(), System.currentTimeMillis())); return opt.get(); } /** * (userId, deviceId) 키로 지표 파라미터 / visual 을 조회하되 TTL 내에는 캐시를 재사용한다. */ private IndicatorParamsCacheEntry resolveIndicatorParams(Long userId, String deviceId) { String key = (userId != null ? userId : "") + ":" + (deviceId != null ? deviceId : ""); IndicatorParamsCacheEntry cached = indicatorCache.get(key); if (cached != null && (System.currentTimeMillis() - cached.cachedAt()) < INDICATOR_CACHE_TTL_MS) { return cached; } Map> params = indicatorSettingsService.getAll(userId, deviceId); Map> visual = indicatorSettingsService.getAllVisual(userId, deviceId); IndicatorParamsCacheEntry entry = new IndicatorParamsCacheEntry(params, visual, System.currentTimeMillis()); indicatorCache.put(key, entry); return entry; } // ── 항목3: LONG_ONLY 포지션 상태 복원 (서버 재시작 중복 BUY 방지) ────────── /** * 포지션 오픈 여부를 반환한다. * *

in-memory 캐시가 비어 있으면(서버 재시작 등) gc_trade_signal 테이블에서 * 해당 전략×마켓의 가장 최근 시그널을 조회하여 포지션 상태를 복원한다. * *

    *
  • 최신 시그널 = BUY → 포지션 오픈 (중복 BUY 차단)
  • *
  • 최신 시그널 = SELL 또는 없음 → 포지션 없음
  • *
*/ /** * LONG_ONLY 포지션 오픈 여부 복원 — gc_trade_signal + gc_paper_position 교차 검증. * *

[항목3 보완] 서버 재시작 시 gc_trade_signal DB 의 최신 레코드만 보면 "마지막 신호=BUY"라서 * 포지션이 열려 있다고 판단할 수 있지만, 서버 다운 중 사용자가 수동으로 물량을 전량 매도했다면 * 실제 가상 잔고(gc_paper_position.quantity)는 0 이다. 이 경우 gc_trade_signal 에 SELL 기록이 * 없어도 포지션이 없다고 바르게 판정해야 한다. * *

로직 순서: *

    *
  1. 캐시 히트 → 즉시 반환
  2. *
  3. gc_trade_signal 최신 레코드 조회 → 마지막 신호가 BUY 이면 "열림" 후보
  4. *
  5. "열림" 후보인 경우에만 gc_paper_position.quantity 교차 검증: * quantity == 0 → 수동 청산된 것으로 판단, isOpen = false 로 보정
  6. *
*/ private boolean resolvePositionOpen(String cacheKey, String market, long strategyId, String deviceId, Long userId) { Boolean cached = positionOpenCache.get(cacheKey); if (cached != null) return cached; // Step 1: gc_trade_signal 최신 레코드로 1차 판정 Optional latest = tradeSignalRepo.findFirstByMarketAndStrategyIdOrderByCreatedAtDesc(market, strategyId); boolean isOpen = latest.map(s -> "BUY".equals(s.getSignalType())).orElse(false); // Step 2: "열림" 후보인 경우만 gc_paper_position 교차 검증 if (isOpen) { try { Long accountId = resolveAccountId(userId, deviceId); if (accountId != null) { // 심볼 형식: gc_paper_position 은 market 값 그대로 저장 (e.g. "KRW-BTC") boolean hasPosition = paperPositionRepo .findByAccountIdAndSymbol(accountId, market) .map(p -> p.getQuantity() != null && p.getQuantity().compareTo(BigDecimal.ZERO) > 0) .orElse(false); if (!hasPosition) { isOpen = false; log.info("[Evaluator] 포지션 교차검증: gc_trade_signal=BUY 지만 " + "gc_paper_position.quantity=0 → 수동청산 감지, key={} → CLOSED", cacheKey); } } } catch (Exception e) { log.warn("[Evaluator] 포지션 교차검증 실패 key={}: {} — signal-only 판정 유지", cacheKey, e.getMessage()); } } positionOpenCache.put(cacheKey, isOpen); if (isOpen) { log.info("[Evaluator] 포지션 상태 복원 (DB+position 교차검증) key={} → OPEN", cacheKey); } return isOpen; } /** userId 또는 deviceId 로 가상계좌 ID 조회 */ private Long resolveAccountId(Long userId, String deviceId) { if (userId != null) { return paperAccountRepo.findByUserId(userId) .map(a -> a.getId()) .orElse(null); } if (deviceId != null && !deviceId.isBlank()) { return paperAccountRepo.findByDeviceId(deviceId) .map(a -> a.getId()) .orElse(null); } return null; } }