알림팝업 수정, 전략평가 로직 개선
This commit is contained in:
@@ -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());
|
||||
|
||||
Reference in New Issue
Block a user