package com.goldenchart.service; import com.goldenchart.dto.CandleBarDto; import com.goldenchart.entity.GcLiveStrategySettings; import com.goldenchart.repository.GcLiveStrategySettingsRepository; import com.goldenchart.trading.pipeline.OrderExecutionQueue; import com.goldenchart.websocket.TradingWebSocketBroker; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.ta4j.core.Bar; import org.ta4j.core.BarSeries; import com.goldenchart.storage.Ta4jStorage; import java.util.List; import java.util.concurrent.ConcurrentHashMap; /** * 봉 마감 시점 전략 평가 — BarBuilder·갭 백필 등 단일 진입점. * *

활성 live 설정 중 전략 DSL에 포함된 분봉({@link StrategyConditionTimeframeService}) * 에서만 평가하며, 동일 봉 마감(endTime)에 대해 중복 평가하지 않는다. */ @Service @RequiredArgsConstructor @Slf4j public class BarCloseStrategyEvaluationService { private final LiveStrategyEvaluator liveStrategyEvaluator; private final TradeSignalService tradeSignalService; private final OrderExecutionQueue orderExecutionQueue; private final GcLiveStrategySettingsRepository liveSettingsRepo; private final StrategyConditionTimeframeService conditionTimeframes; private final TradingWebSocketBroker broker; private final Ta4jStorage ta4jStorage; /** market:candleType:barEndEpoch — 동일 마감봉 중복 평가 방지 */ private final ConcurrentHashMap evaluatedBarCloseKeys = new ConcurrentHashMap<>(); /** * 확정된 봉(마감)에 대해 전략 평가 + BUY/SELL 시 STOMP·DB·주문 큐. */ public void onMaturedBarClose(String market, String candleType, int maturedIndex, Bar signalBar) { String ct = LiveStrategyTimeframeService.normalize(candleType); long barEndEpoch = signalBar.getEndTime().getEpochSecond(); String dedupKey = market + ":" + ct + ":" + barEndEpoch; if (evaluatedBarCloseKeys.putIfAbsent(dedupKey, barEndEpoch) != null) { return; } trimEvaluatedKeys(); List activeSettings = liveSettingsRepo.findActiveByMarket(market).stream() .filter(s -> Boolean.TRUE.equals(s.getIsLiveCheck()) && s.getStrategyId() != null) .toList(); if (activeSettings.isEmpty()) return; long candleTimeEpoch = barEndEpoch - signalBar.getTimePeriod().getSeconds(); for (GcLiveStrategySettings s : activeSettings) { if (s.getUserId() == null) continue; if (!conditionTimeframes.usesTimeframe(s.getStrategyId(), ct)) continue; String closeSignal = "NONE"; String signalExecType = null; if ("CANDLE_CLOSE".equals(s.getExecutionType())) { closeSignal = liveStrategyEvaluator.evaluateSettingOnCandleClose( s, market, ct, maturedIndex); signalExecType = "CANDLE_CLOSE"; } else if ("REALTIME_TICK".equals(s.getExecutionType())) { closeSignal = liveStrategyEvaluator.evaluateSettingRealtimeAtClose( s, market, ct, maturedIndex); signalExecType = "REALTIME_TICK"; } if (!"BUY".equals(closeSignal) && !"SELL".equals(closeSignal)) continue; publishStrategySignal(market, ct, signalBar, closeSignal, s, signalExecType); try { tradeSignalService.save( s.getDeviceId(), s.getUserId(), market, s.getStrategyId(), null, closeSignal, signalBar.getClosePrice().doubleValue(), candleTimeEpoch, ct, signalExecType); if (s.getUserId() != null) { orderExecutionQueue.submitSignal( s.getUserId(), market, s.getStrategyId(), closeSignal, signalBar.getClosePrice().doubleValue()); } } catch (Exception e) { log.warn("[BarCloseEval] 시그널 처리 실패 ({}/{} strategyId={}): {}", market, ct, s.getStrategyId(), e.getMessage()); } } } /** * REST 갭 백필 등으로 시리즈가 갱신된 뒤, 병합된 확정봉 구간을 순회하며 평가한다. * * @param maxMaturedBars 병합된 봉 수(상한) — 중간 마감봉 누락 방지 */ public void evaluateRecentMaturedBarsAfterBackfill(String market, String candleType, int maxMaturedBars) { if (maxMaturedBars <= 0 || !ta4jStorage.exists(market, candleType)) return; String ct = LiveStrategyTimeframeService.normalize(candleType); if (!shouldEvaluateOnClose(market, ct)) return; BarSeries series = ta4jStorage.getOrCreate(market, candleType); if (series.isEmpty()) return; int end = series.getEndIndex(); int begin = series.getBeginIndex(); int maturedEnd = Math.max(begin, end - 1); int maturedStart = Math.max(begin, maturedEnd - maxMaturedBars + 1); for (int i = maturedStart; i <= maturedEnd; i++) { onMaturedBarClose(market, ct, i, series.getBar(i)); } } public void invalidateMarket(String market) { evaluatedBarCloseKeys.keySet().removeIf(k -> k.startsWith(market + ":")); } /** 활성 실시간 전략 중 DSL에 해당 분봉이 포함된 항목이 있는지 */ public boolean shouldEvaluateOnClose(String market, String candleType) { String ct = LiveStrategyTimeframeService.normalize(candleType); return liveSettingsRepo.findActiveByMarket(market).stream() .filter(s -> Boolean.TRUE.equals(s.getIsLiveCheck()) && s.getStrategyId() != null) .anyMatch(s -> conditionTimeframes.usesTimeframe(s.getStrategyId(), ct)); } private void publishStrategySignal(String market, String candleType, Bar bar, String signal, GcLiveStrategySettings setting, String executionType) { long barStartEpoch = bar.getEndTime().getEpochSecond() - bar.getTimePeriod().getSeconds(); CandleBarDto dto = CandleBarDto.builder() .time(barStartEpoch) .open(bar.getOpenPrice().doubleValue()) .high(bar.getHighPrice().doubleValue()) .low(bar.getLowPrice().doubleValue()) .close(bar.getClosePrice().doubleValue()) .volume(bar.getVolume().doubleValue()) .signal(signal) .candleType(candleType) .strategyId(setting.getStrategyId()) .userId(setting.getUserId()) .deviceId(setting.getDeviceId()) .executionType(executionType) .build(); broker.publish(market, candleType, dto); log.info("[BarCloseEval] bar-close signal={} market={} candleType={} strategyId={} userId={}", signal, market, candleType, setting.getStrategyId(), setting.getUserId()); } private void trimEvaluatedKeys() { if (evaluatedBarCloseKeys.size() > 50_000) { evaluatedBarCloseKeys.clear(); log.info("[BarCloseEval] evaluatedBarCloseKeys cleared (size cap)"); } } }