Files
goldenChart/backend/src/main/java/com/goldenchart/service/LiveStrategyEvaluator.java
T
2026-05-28 02:09:38 +09:00

347 lines
16 KiB
Java
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
/**
* 실시간 전략 체크 평가기.
*
* <p>두 가지 방식으로 전략 만족 여부를 판정한다:
* <ul>
* <li>방식 A (CANDLE_CLOSE): 봉 마감 직후 BarBuilder 에서 트리거</li>
* <li>방식 B (REALTIME_TICK): 3초 스케줄러에서 현재 진행 중인 캔들 인덱스로 판정</li>
* </ul>
*
* <p>포지션 종속성 모드:
* <ul>
* <li><b>LONG_ONLY</b>: TradingRecord 에 열린 포지션이 있어야 매도 시그널 발생.
* 시장 진입/청산을 {@code strategy.shouldEnter/shouldExit} 로 판정.</li>
* <li><b>SIGNAL_ONLY</b>: 포지션 관계없이 지표 규칙만 충족되면 시그널 확정.
* {@code rule.isSatisfied(index)} 를 직접 호출.</li>
* </ul>
*
* <p>동시성 안전:
* <ul>
* <li>ConcurrentHashMap 으로 market×candleType 키에 대한 Strategy 캐시 관리</li>
* <li>LONG_ONLY 모드용 TradingRecord 캐시도 ConcurrentHashMap 으로 관리</li>
* <li>Ta4jStorage.getOrCreate/updateLastBar 는 내부 synchronized 처리</li>
* </ul>
*/
@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<String, TriggerRules> triggerRulesCache = new ConcurrentHashMap<>();
private record TriggerRules(Rule entryRule, Rule exitRule) {}
/**
* LONG_ONLY 모드용 TradingRecord 캐시: "market:candleType:strategyId" → TradingRecord
* 시그널이 발생할 때마다 Record 를 갱신하여 포지션 상태를 유지한다.
*/
private final ConcurrentHashMap<String, BaseTradingRecord> tradingRecordCache = new ConcurrentHashMap<>();
/**
* LONG_ONLY 모드용 포지션 오픈 여부 추적: Ta4j API 버전 차이 대응.
*/
private final ConcurrentHashMap<String, Boolean> positionOpenCache = new ConcurrentHashMap<>();
/**
* REALTIME_TICK 중복 시그널 방지: "market:candleType" → 마지막으로 시그널을 낸 seriesIndex.
* 동일 provisional 봉에서 3초 스케줄러가 반복 실행되더라도 이미 시그널을 낸 인덱스면 무시.
* 새 provisional 봉(다음 분)이 시작되면 자동으로 다른 index 이므로 갱신된다.
*/
private final ConcurrentHashMap<String, Integer> 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<GcLiveStrategySettings> 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초 스케줄러에서 호출.
*
* <p>동일 provisional 봉(endIndex)에서 CROSS 시그널이 중복 발화되지 않도록
* 마지막 시그널 인덱스를 추적한다. 새 분이 시작되면 endIndex가 증가하므로
* 자동으로 다음 교차 이벤트를 감지할 수 있다.
*
* <p><b>캐시 무효화 정책 (Ta4j CachedIndicator memoization 문제 대응)</b><br>
* 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<GcLiveStrategySettings> 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 설정에 대해 확정봉 인덱스로 전략을 평가한다.
*
* <p>배경: CrossedDown/Up 교차 이벤트가 봉 마감 시점에 발생하면, 3초 스케줄러는
* 이미 다음 분의 provisional 봉(index N+1)을 평가하기 때문에 교차를 감지하지 못한다.
* (CrossedDown(N+1) → CCI(N) < threshold, CCI(N+1) < threshold → 교차 없음)
*
* <p>이 메서드를 {@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<GcLiveStrategySettings> 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<GcStrategy> 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<String, Map<String, Object>> 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;
}
}
}