알림팝업 수정, 전략평가 로직 개선

This commit is contained in:
Macbook
2026-06-05 10:32:40 +09:00
parent 238dd0cebe
commit 5278177bbb
9 changed files with 484 additions and 116 deletions
@@ -23,16 +23,24 @@ import java.util.concurrent.ConcurrentHashMap;
*
* <p>두 가지 방식으로 전략 만족 여부를 판정한다:
* <ul>
* <li>방식 A (CANDLE_CLOSE): 봉 마감 직후 BarBuilder 에서 트리거</li>
* <li>방식 B (REALTIME_TICK): 3초 스케줄러에서 현재 진행 중인 캔들 인덱스로 판정</li>
* <li>방식 A (CANDLE_CLOSE): 봉 마감 직후 BarBuilder 에서 트리거 — Rule 캐시 사용,
* {@code useConfirmedOnly=true} 로 상위봉 확정봉 인덱스 사용.</li>
* <li>방식 B (REALTIME_TICK): 3초 스케줄러에서 현재 진행 중인 캔들 인덱스로 판정 —
* Rule 캐시 <b>우회</b>, 매 평가마다 신규 Rule 인스턴스 빌드.
* {@code useConfirmedOnly=false} 로 현재 봉 포함 최신 인덱스 사용.</li>
* </ul>
*
* <h3>REALTIME_TICK 캐시 우회 이유 (동시성 보장)</h3>
* <p>Ta4j {@code CachedIndicator} 는 {@code getValue(index)} 결과를 내부 리스트에 영구 저장한다.
* {@code Ta4jStorage.updateLastBar} 가 provisional 봉의 close price 를 갱신해도
* 이미 캐시된 값은 변하지 않으므로 Rule 이 구 가격으로 판정(Ghost Signal)한다.
* REALTIME_TICK 에서는 Rule 자체를 매번 재생성함으로써 캐시 오염을 원천 차단한다.
* 이 방식은 기존 "triggerRulesCache.remove + evaluate" 복합 연산의 스레드 안전성 문제도 해소한다.
*
* <p>포지션 종속성 모드:
* <ul>
* <li><b>LONG_ONLY</b>: TradingRecord 에 열린 포지션이 있어야 매도 시그널 발생.
* 시장 진입/청산을 {@code strategy.shouldEnter/shouldExit} 로 판정.</li>
* <li><b>SIGNAL_ONLY</b>: 포지션 관계없이 지표 규칙만 충족되면 시그널 확정.
* {@code rule.isSatisfied(index)} 를 직접 호출.</li>
* <li><b>LONG_ONLY</b>: TradingRecord 에 열린 포지션이 있어야 매도 시그널 발생.</li>
* <li><b>SIGNAL_ONLY</b>: 포지션 관계없이 지표 규칙만 충족되면 시그널 확정.</li>
* </ul>
*
* <p>동시성 안전:
@@ -90,7 +98,11 @@ public class LiveStrategyEvaluator {
* @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<GcLiveStrategySettings> settings = settingsRepo.findActiveByMarket(market);
if (settings.isEmpty()) return "NONE";
@@ -109,6 +121,7 @@ public class LiveStrategyEvaluator {
/**
* 단일 live 설정 — 봉 마감(CANDLE_CLOSE) 평가.
* useConfirmedOnly=true: CrossSeriesRule 이 상위봉의 확정봉(endIndex-1)을 사용.
*/
public String evaluateSettingOnCandleClose(GcLiveStrategySettings s, String market,
String candleType, int maturedIndex) {
@@ -120,7 +133,7 @@ public class LiveStrategyEvaluator {
String result = evaluate(market, candleType, s.getStrategyId(),
s.getPositionMode(), maturedIndex,
s.getDeviceId(), s.getUserId());
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);
@@ -131,22 +144,14 @@ public class LiveStrategyEvaluator {
/**
* 방식 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"
* @deprecated 마켓 단위 API — 멀티 전략 환경에서 첫 번째 non-NONE 만 반환.
* 시그널 발행 경로에서 사용 금지.
* 대신 {@link #evaluateSettingRealtimeTick} 를 설정별로 직접 호출할 것.
*/
@Deprecated
public String evaluateRealtimeTick(String market, String candleType) {
List<GcLiveStrategySettings> settings = settingsRepo.findActiveByMarket(market);
if (settings.isEmpty()) return "NONE";
@@ -160,6 +165,10 @@ public class LiveStrategyEvaluator {
/**
* 단일 live 설정 — 3초 스케줄러(REALTIME_TICK) 평가.
*
* <p><b>캐시 완전 우회</b>: REALTIME_TICK 은 provisional 봉의 close price 가 틱마다 바뀌므로
* Rule 을 매번 신규 빌드(useConfirmedOnly=false)하여 Ta4j CachedIndicator 캐시 오염을
* 원천 차단한다. 동시에 {@code triggerRulesCache} 복합 연산의 스레드 안전성 문제도 제거한다.
*/
public String evaluateSettingRealtimeTick(GcLiveStrategySettings s, String market,
String candleType) {
@@ -177,12 +186,10 @@ public class LiveStrategyEvaluator {
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(),
// 캐시 완전 우회: 매번 신규 Rule 빌드 (CachedIndicator stale 방지 + 동시성 보장)
String result = evaluateWithFreshRules(market, candleType, s.getStrategyId(),
s.getPositionMode(), currentIndex,
s.getDeviceId(), s.getUserId());
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);
@@ -196,15 +203,9 @@ public class LiveStrategyEvaluator {
*
* <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"
* 이 메서드를 {@code BarBuilder.commitBar} 에서 호출하면 CANDLE_CLOSE 와 동일한
* 타이밍에 확정봉 인덱스(N)로 평가하여 누락을 방지한다.
* useConfirmedOnly=true 로 상위봉도 확정봉 기준 평가.
*/
public String evaluateRealtimeAtClose(String market, String candleType, int maturedIndex) {
List<GcLiveStrategySettings> settings = settingsRepo.findActiveByMarket(market);
@@ -219,6 +220,7 @@ public class LiveStrategyEvaluator {
/**
* 단일 live 설정 — 봉 마감 시점 REALTIME_TICK 평가(교차 누락 방지).
* 캐시 우회 + useConfirmedOnly=true.
*/
public String evaluateSettingRealtimeAtClose(GcLiveStrategySettings s, String market,
String candleType, int maturedIndex) {
@@ -228,12 +230,10 @@ public class LiveStrategyEvaluator {
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(),
// 확정봉 기준 평가: 캐시 우회 + useConfirmedOnly=true
String result = evaluateWithFreshRules(market, candleType, s.getStrategyId(),
s.getPositionMode(), maturedIndex,
s.getDeviceId(), s.getUserId());
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);
@@ -262,18 +262,42 @@ public class LiveStrategyEvaluator {
// ── Private ───────────────────────────────────────────────────────────────
/**
* CANDLE_CLOSE 전용 캐시 사용 경로.
* Rule 을 캐시하여 재사용하며, useConfirmedOnly=true 로 빌드한 Rule 을 저장한다.
*/
private String evaluate(String market, String candleType, long strategyId,
String positionMode, int index,
String deviceId, Long userId) {
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);
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);
}
/**
* 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);
}
/** Rule 판정 + TradingRecord 갱신 공통 로직 */
private String applySignalLogic(TriggerRules rules, String market, String candleType,
long strategyId, String positionMode, int index) {
String cacheKey = market + ":" + candleType + ":" + strategyId;
try {
String mode = positionMode != null ? positionMode : "LONG_ONLY";
@@ -312,7 +336,7 @@ public class LiveStrategyEvaluator {
}
private TriggerRules buildTriggerRules(String market, String candleType, long strategyId,
String deviceId, Long userId) {
String deviceId, Long userId, boolean useConfirmedOnly) {
Optional<GcStrategy> strategyOpt = strategyRepo.findById(strategyId);
if (strategyOpt.isEmpty()) return null;
@@ -335,10 +359,10 @@ public class LiveStrategyEvaluator {
try {
Rule entryRule = triggerBranchEvaluator.buildTriggerRule(
strategy.getBuyConditionJson(), market, strategyId, "buy",
candleType, indicatorParams, indicatorVisual, ta4jStorage);
candleType, indicatorParams, indicatorVisual, ta4jStorage, useConfirmedOnly);
Rule exitRule = triggerBranchEvaluator.buildTriggerRule(
strategy.getSellConditionJson(), market, strategyId, "sell",
candleType, indicatorParams, indicatorVisual, ta4jStorage);
candleType, indicatorParams, indicatorVisual, ta4jStorage, useConfirmedOnly);
return new TriggerRules(entryRule, exitRule);
} catch (Exception e) {
log.error("[Evaluator] Trigger Rule 빌드 실패 strategyId={}: {}", strategyId, e.getMessage());