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

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
@@ -85,9 +85,14 @@ public class BacktestingService {
int n = series.getBarCount();
if (n == 0) return emptyResponse("유효한 캔들 데이터가 없습니다.");
Rule entryRule = adapter.toRule(buyDsl, series, params);
// ── 멀티 타임프레임 지원: DSL에서 상위봉 타입 추출 후 집계 시리즈 빌드 ──────
String primaryTf = normalizeTf(req.getTimeframe());
Map<String, BarSeries> seriesOverrides = buildSeriesOverrides(
series, primaryTf, buyDsl, sellDsl);
Rule entryRule = adapter.toRule(buyDsl, series, params, seriesOverrides);
Rule baseExitRule = (sellDsl != null && !sellDsl.isNull())
? adapter.toRule(sellDsl, series, params)
? adapter.toRule(sellDsl, series, params, seriesOverrides)
: new BooleanRule(false);
Rule exitRule = buildExitRule(baseExitRule, series, cfg);
@@ -532,18 +537,139 @@ public class BacktestingService {
private static Duration timeframeToDuration(String tf) {
if (tf == null) return Duration.ofMinutes(1);
return switch (tf) {
case "1m" -> Duration.ofMinutes(1);
case "3m" -> Duration.ofMinutes(3);
case "5m" -> Duration.ofMinutes(5);
case "10m" -> Duration.ofMinutes(10);
case "15m" -> Duration.ofMinutes(15);
case "30m" -> Duration.ofMinutes(30);
case "1h" -> Duration.ofHours(1);
case "4h" -> Duration.ofHours(4);
case "1D" -> Duration.ofDays(1);
case "1W" -> Duration.ofDays(7);
case "1M" -> Duration.ofDays(30);
default -> Duration.ofMinutes(1);
case "1m" -> Duration.ofMinutes(1);
case "3m" -> Duration.ofMinutes(3);
case "5m" -> Duration.ofMinutes(5);
case "10m" -> Duration.ofMinutes(10);
case "15m" -> Duration.ofMinutes(15);
case "30m" -> Duration.ofMinutes(30);
case "1h" -> Duration.ofHours(1);
case "4h" -> Duration.ofHours(4);
case "1d", "1D" -> Duration.ofDays(1);
case "1w", "1W" -> Duration.ofDays(7);
case "1M" -> Duration.ofDays(30);
default -> Duration.ofMinutes(1);
};
}
// ── 멀티 타임프레임 지원 ─────────────────────────────────────────────────
/**
* 기본봉 시리즈 + DSL 에서 추출한 상위봉 집계 시리즈로 seriesOverrides 맵을 구성한다.
* 백테스트에서 라이브와 동일한 멀티 TF 시그널 평가를 보장한다.
*/
private Map<String, BarSeries> buildSeriesOverrides(BarSeries primarySeries, String primaryTf,
JsonNode buyDsl, JsonNode sellDsl) {
Set<String> requiredTfs = new LinkedHashSet<>();
collectTimeframesFromDsl(buyDsl, requiredTfs);
collectTimeframesFromDsl(sellDsl, requiredTfs);
requiredTfs.remove(primaryTf);
Map<String, BarSeries> overrides = new LinkedHashMap<>();
overrides.put(primaryTf, primarySeries);
for (String tf : requiredTfs) {
Duration targetDur = timeframeToDuration(tf);
Duration primaryDur = timeframeToDuration(primaryTf);
if (targetDur.compareTo(primaryDur) <= 0) continue; // 같거나 하위봉은 기본 시리즈 사용
BarSeries aggregated = aggregateSeries(primarySeries, primaryDur, targetDur, tf);
if (aggregated.getBarCount() > 0) {
overrides.put(tf, aggregated);
log.info("[Backtest] 멀티 TF 집계: {} bars → {} ({}봉)", primaryTf, tf, aggregated.getBarCount());
}
}
return overrides;
}
/**
* DSL 트리를 재귀 탐색하여 TIMEFRAME 노드의 candleType 값을 수집한다.
*/
private void collectTimeframesFromDsl(JsonNode node, Set<String> result) {
if (node == null || node.isNull()) return;
String type = node.path("type").asText("");
if ("TIMEFRAME".equals(type)) {
String ct = node.path("candleType").asText("");
if (!ct.isBlank()) result.add(normalizeTf(ct));
}
JsonNode children = node.path("children");
if (children.isArray()) {
for (JsonNode child : children) collectTimeframesFromDsl(child, result);
}
JsonNode child = node.path("child");
if (!child.isMissingNode() && !child.isNull()) collectTimeframesFromDsl(child, result);
JsonNode cond = node.path("condition");
if (!cond.isMissingNode() && !cond.isNull()) {
// 복합지표 leftCandleType / rightCandleType
String leftCt = cond.path("leftCandleType").asText("");
String rightCt = cond.path("rightCandleType").asText("");
if (!leftCt.isBlank()) result.add(normalizeTf(leftCt));
if (!rightCt.isBlank()) result.add(normalizeTf(rightCt));
}
}
/**
* primarySeries(하위봉) → targetDur(상위봉) 으로 OHLCV 집계.
* 각 타깃 봉 버킷에 속하는 하위봉들을 그룹핑하여 새 BarSeries 를 생성한다.
*/
private BarSeries aggregateSeries(BarSeries primary, Duration primaryDur,
Duration targetDur, String targetTf) {
long primarySecs = primaryDur.getSeconds();
long targetSecs = targetDur.getSeconds();
BarSeries result = new BaseBarSeriesBuilder()
.withNumFactory(org.ta4j.core.num.DoubleNumFactory.getInstance())
.build();
TimeBarBuilderFactory factory = new TimeBarBuilderFactory();
// 하위봉을 상위봉 버킷별로 그룹핑 (버킷 시작 epoch 초 → 봉 목록)
Map<Long, List<Bar>> grouped = new LinkedHashMap<>();
for (int i = primary.getBeginIndex(); i <= primary.getEndIndex(); i++) {
Bar bar = primary.getBar(i);
// bar.endTime = 봉 종료 시각 → 봉 시작 = endTime - primaryDur
long barStartSec = bar.getEndTime().getEpochSecond() - primarySecs;
long bucketStart = (barStartSec / targetSecs) * targetSecs;
grouped.computeIfAbsent(bucketStart, k -> new ArrayList<>()).add(bar);
}
for (Map.Entry<Long, List<Bar>> entry : grouped.entrySet()) {
List<Bar> bars = entry.getValue();
if (bars.isEmpty()) continue;
Instant bucketEnd = Instant.ofEpochSecond(entry.getKey()).plus(targetDur);
double open = bars.get(0).getOpenPrice().doubleValue();
double high = bars.stream().mapToDouble(b -> b.getHighPrice().doubleValue()).max().orElse(open);
double low = bars.stream().mapToDouble(b -> b.getLowPrice().doubleValue()).min().orElse(open);
double close = bars.get(bars.size() - 1).getClosePrice().doubleValue();
double volume = bars.stream().mapToDouble(b -> b.getVolume().doubleValue()).sum();
try {
factory.createBarBuilder(result)
.timePeriod(targetDur).endTime(bucketEnd)
.openPrice(open).highPrice(high).lowPrice(low)
.closePrice(close).volume(volume).add();
} catch (Exception e) {
log.debug("[Backtest] 상위봉 집계 실패 (tf={} bucket={}): {}", targetTf, entry.getKey(), e.getMessage());
}
}
return result;
}
/** timeframe 문자열 정규화 (1m, 3m, 5m ... 1h, 4h, 1d, 1w) */
private static String normalizeTf(String tf) {
if (tf == null) return "1m";
return switch (tf.toLowerCase()) {
case "1", "1m" -> "1m";
case "3", "3m" -> "3m";
case "5", "5m" -> "5m";
case "10", "10m" -> "10m";
case "15", "15m" -> "15m";
case "30", "30m" -> "30m";
case "60", "1h" -> "1h";
case "240", "4h" -> "4h";
case "1d", "d" -> "1d";
case "1w", "w" -> "1w";
default -> tf;
};
}
@@ -102,7 +102,7 @@ public class LiveConditionStatusService {
StrategyDslToTa4jAdapter.RuleBuildContext ctx =
new StrategyDslToTa4jAdapter.RuleBuildContext(
series, params, visual, market, ta4jStorage);
series, params, visual, market, ta4jStorage, false, java.util.Map.of());
Rule rule = adapter.toRule(wrapper, ctx);
boolean satisfied = rule.isSatisfied(index, null);
Double current = adapter.readConditionFieldValue(
@@ -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());
@@ -111,18 +111,44 @@ public class StrategyDslToTa4jAdapter {
Map<String, Map<String, Object>> indicatorParams,
Map<String, Map<String, Object>> indicatorVisual,
String market,
Ta4jStorage storage
Ta4jStorage storage,
/**
* CANDLE_CLOSE 평가 시 true — CrossSeriesRule 이 상위봉의 마지막 확정봉
* (endIndex - 1)을 사용하도록 강제한다. provisional 봉 오염을 방지.
*/
boolean useConfirmedOnly,
/**
* 백테스트용 사전 집계 시리즈 맵 candleType→BarSeries.
* null 이 아니고 비어 있지 않으면 백테스트 모드로 동작하며
* CrossSeriesRule 에서 타임스탬프 기반 룩업을 수행한다.
*/
Map<String, BarSeries> seriesOverrides
) {
/** visual 미전달 호환 */
/** visual 미전달 호환 (레거시) */
public RuleBuildContext(BarSeries primarySeries,
Map<String, Map<String, Object>> indicatorParams,
String market,
Ta4jStorage storage) {
this(primarySeries, indicatorParams, Map.of(), market, storage);
this(primarySeries, indicatorParams, Map.of(), market, storage, false, Map.of());
}
/** primarySeries 만 교체하고 나머지 필드 복사 (하위 컨텍스트 생성용) */
public RuleBuildContext withPrimary(BarSeries newPrimary) {
return new RuleBuildContext(newPrimary, indicatorParams, indicatorVisual,
market, storage, useConfirmedOnly, seriesOverrides);
}
/** 사용 중인 시리즈 오버라이드가 있으면 백테스트 모드 */
public boolean isBacktest() {
return seriesOverrides != null && !seriesOverrides.isEmpty();
}
BarSeries resolveSeries(String candleType) {
String ct = LiveStrategyTimeframeService.normalize(candleType);
// 백테스트용 사전 집계 시리즈 우선 검색
if (seriesOverrides != null && seriesOverrides.containsKey(ct)) {
return seriesOverrides.get(ct);
}
if (storage != null && market != null && !market.isBlank()
&& storage.exists(market, ct)) {
return storage.getOrCreate(market, ct);
@@ -133,20 +159,41 @@ public class StrategyDslToTa4jAdapter {
public Rule toRule(JsonNode node, BarSeries series,
Map<String, Map<String, Object>> indicatorParams) {
return toRule(node, new RuleBuildContext(series, indicatorParams, Map.of(), null, null));
return toRule(node, new RuleBuildContext(series, indicatorParams, Map.of(), null, null, false, Map.of()));
}
public Rule toRule(JsonNode node, BarSeries series,
Map<String, Map<String, Object>> indicatorParams,
String market, Ta4jStorage storage) {
return toRule(node, new RuleBuildContext(series, indicatorParams, Map.of(), market, storage));
return toRule(node, new RuleBuildContext(series, indicatorParams, Map.of(), market, storage, false, Map.of()));
}
public Rule toRule(JsonNode node, BarSeries series,
Map<String, Map<String, Object>> indicatorParams,
Map<String, Map<String, Object>> indicatorVisual,
String market, Ta4jStorage storage) {
return toRule(node, new RuleBuildContext(series, indicatorParams, indicatorVisual, market, storage));
return toRule(node, new RuleBuildContext(series, indicatorParams, indicatorVisual, market, storage, false, Map.of()));
}
/**
* 백테스트 전용: 사전 집계된 상위봉 시리즈 맵을 함께 전달.
* CrossSeriesRule 이 타임스탬프 기반 룩업을 수행하여 라이브와 동일한 멀티 TF 평가를 보장한다.
*/
public Rule toRule(JsonNode node, BarSeries series,
Map<String, Map<String, Object>> indicatorParams,
Map<String, BarSeries> seriesOverrides) {
return toRule(node, new RuleBuildContext(series, indicatorParams, Map.of(), null, null, false,
seriesOverrides != null ? seriesOverrides : Map.of()));
}
/**
* CANDLE_CLOSE 전용: useConfirmedOnly=true 로 상위봉 확정봉 인덱스를 사용한다.
*/
public Rule toRuleOnCandleClose(JsonNode node, BarSeries series,
Map<String, Map<String, Object>> indicatorParams,
Map<String, Map<String, Object>> indicatorVisual,
String market, Ta4jStorage storage) {
return toRule(node, new RuleBuildContext(series, indicatorParams, indicatorVisual, market, storage, true, Map.of()));
}
public Rule toRule(JsonNode node, RuleBuildContext ctx) {
@@ -198,35 +245,82 @@ public class StrategyDslToTa4jAdapter {
private Rule buildTimeframeRule(JsonNode node, RuleBuildContext ctx) {
String ct = node.path("candleType").asText("1m");
BarSeries branchSeries = ctx.resolveSeries(ct);
RuleBuildContext branchCtx = new RuleBuildContext(
branchSeries, ctx.indicatorParams(), ctx.indicatorVisual(), ctx.market(), ctx.storage());
RuleBuildContext branchCtx = ctx.withPrimary(branchSeries);
List<Rule> rules = childRules(node, branchCtx);
if (rules.isEmpty()) return new BooleanRule(false);
Rule inner = rules.get(0);
if (branchSeries == ctx.primarySeries()) return inner;
return new CrossSeriesRule(inner, branchSeries, ctx.primarySeries());
return new CrossSeriesRule(inner, branchSeries, ctx.primarySeries(),
ctx.useConfirmedOnly(), ctx.isBacktest());
}
/** 다른 시간봉 시리즈로 빌드된 Rule — 트리거 시리즈 인덱스에서 해당 분봉 최신봉으로 평가 */
/**
* 다른 시간봉 시리즈로 빌드된 Rule.
*
* <ul>
* <li><b>백테스트 모드</b>({@code isBacktest=true}): 기본봉 endTime ≤ 기준으로 상위봉 인덱스를
* 이진탐색하여 정확히 대응하는 확정봉을 사용한다. 라이브와 동일한 멀티 TF 시그널 보장.</li>
* <li><b>CANDLE_CLOSE 모드</b>({@code useConfirmedOnly=true}): 상위봉의 마지막 확정봉
* (endIndex - 1)을 사용한다. 진행 중인 provisional 봉을 참조하지 않음.</li>
* <li><b>REALTIME_TICK 모드</b>(둘 다 false): 상위봉의 {@code getEndIndex()}(현재 봉 포함)
* 를 사용한다. 실시간 지표값 반영.</li>
* </ul>
*/
private static final class CrossSeriesRule implements Rule {
private final Rule inner;
private final BarSeries branchSeries;
private final BarSeries triggerSeries;
private final BarSeries primarySeries;
private final boolean useConfirmedOnly;
private final boolean isBacktest;
CrossSeriesRule(Rule inner, BarSeries branchSeries, BarSeries triggerSeries) {
CrossSeriesRule(Rule inner, BarSeries branchSeries, BarSeries primarySeries,
boolean useConfirmedOnly, boolean isBacktest) {
this.inner = inner;
this.branchSeries = branchSeries;
this.triggerSeries = triggerSeries;
this.primarySeries = primarySeries;
this.useConfirmedOnly = useConfirmedOnly;
this.isBacktest = isBacktest;
}
@Override
public boolean isSatisfied(int index, TradingRecord tradingRecord) {
int evalIndex = branchSeries == triggerSeries
? index
: branchSeries.getEndIndex();
if (evalIndex < 0 || branchSeries.getBarCount() == 0) return false;
if (branchSeries.getBarCount() == 0) return false;
int evalIndex;
if (branchSeries == primarySeries) {
evalIndex = index;
} else if (isBacktest) {
// 백테스트: 기본봉 endTime 이하의 마지막 상위봉 인덱스 (타임스탬프 매핑)
java.time.Instant primaryEndTime = primarySeries.getBar(index).getEndTime();
evalIndex = findLastIndexAtOrBefore(branchSeries, primaryEndTime);
} else if (useConfirmedOnly) {
// CANDLE_CLOSE: 마지막 확정봉(provisional 제외)
evalIndex = Math.max(branchSeries.getBeginIndex(), branchSeries.getEndIndex() - 1);
} else {
// REALTIME_TICK: 현재 진행 중인 봉 포함
evalIndex = branchSeries.getEndIndex();
}
if (evalIndex < 0) return false;
return inner.isSatisfied(evalIndex, tradingRecord);
}
/** 이진탐색: endTime <= targetTime 인 마지막 봉 인덱스 반환 (-1 if none) */
static int findLastIndexAtOrBefore(BarSeries series, java.time.Instant targetTime) {
int lo = series.getBeginIndex();
int hi = series.getEndIndex();
int result = -1;
while (lo <= hi) {
int mid = (lo + hi) / 2;
if (!series.getBar(mid).getEndTime().isAfter(targetTime)) {
result = mid;
lo = mid + 1;
} else {
hi = mid - 1;
}
}
return result;
}
}
// ── AND / OR / NOT ────────────────────────────────────────────────────────
@@ -264,22 +358,22 @@ public class StrategyDslToTa4jAdapter {
private Rule buildAndRule(JsonNode node, BarSeries series,
Map<String, Map<String, Object>> p) {
return buildAndRule(node, new RuleBuildContext(series, p, Map.of(), null, null));
return buildAndRule(node, new RuleBuildContext(series, p, Map.of(), null, null, false, Map.of()));
}
private Rule buildOrRule(JsonNode node, BarSeries series,
Map<String, Map<String, Object>> p) {
return buildOrRule(node, new RuleBuildContext(series, p, Map.of(), null, null));
return buildOrRule(node, new RuleBuildContext(series, p, Map.of(), null, null, false, Map.of()));
}
private Rule buildNotRule(JsonNode node, BarSeries series,
Map<String, Map<String, Object>> p) {
return buildNotRule(node, new RuleBuildContext(series, p, Map.of(), null, null));
return buildNotRule(node, new RuleBuildContext(series, p, Map.of(), null, null, false, Map.of()));
}
private List<Rule> childRules(JsonNode node, BarSeries series,
Map<String, Map<String, Object>> p) {
return childRules(node, new RuleBuildContext(series, p, Map.of(), null, null));
return childRules(node, new RuleBuildContext(series, p, Map.of(), null, null, false, Map.of()));
}
// ── CONDITION ─────────────────────────────────────────────────────────────
@@ -437,7 +531,10 @@ public class StrategyDslToTa4jAdapter {
/** 복합지표 — leftCandleType / rightCandleType 이 서로 다를 때 교차 시간봉 평가 */
private boolean needsCrossTimeframeComposite(JsonNode cond, RuleBuildContext ctx) {
if (!cond.path("composite").asBoolean(false)) return false;
if (ctx.storage() == null || ctx.market() == null || ctx.market().isBlank()) return false;
// 라이브: storage + market 필요 / 백테스트: seriesOverrides 맵으로 대체
boolean hasSource = (ctx.storage() != null && ctx.market() != null && !ctx.market().isBlank())
|| ctx.isBacktest();
if (!hasSource) return false;
String leftCt = LiveStrategyTimeframeService.normalize(
cond.path("leftCandleType").asText("1m"));
String rightCt = LiveStrategyTimeframeService.normalize(
@@ -470,7 +567,7 @@ public class StrategyDslToTa4jAdapter {
try {
Indicator<Num> left = resolveField(leftField, indType, indParams, leftSeries, condPeriod, leftPeriod, cond, ctx);
Indicator<Num> right = resolveField(rightField, indType, indParams, rightSeries, condPeriod, rightPeriod, cond, ctx);
return new CrossTimeframeCompositeRule(condType, left, right, trigger, leftSeries, rightSeries);
return new CrossTimeframeCompositeRule(condType, left, right, trigger, leftSeries, rightSeries, ctx.isBacktest());
} catch (Exception e) {
log.warn("[Adapter] 복합 교차시간봉 빌드 실패 ind={} cond={}: {}",
indType, condType, e.getMessage());
@@ -478,8 +575,12 @@ public class StrategyDslToTa4jAdapter {
}
}
private static int evalIndex(BarSeries trigger, BarSeries branch, int triggerIndex) {
private static int evalIndex(BarSeries trigger, BarSeries branch, int triggerIndex, boolean isBacktest) {
if (trigger == branch) return triggerIndex;
if (isBacktest) {
java.time.Instant t = trigger.getBar(triggerIndex).getEndTime();
return CrossSeriesRule.findLastIndexAtOrBefore(branch, t);
}
int end = branch.getEndIndex();
return end >= 0 ? end : 0;
}
@@ -491,25 +592,28 @@ public class StrategyDslToTa4jAdapter {
private final BarSeries triggerSeries;
private final BarSeries leftSeries;
private final BarSeries rightSeries;
private final boolean isBacktest;
CrossTimeframeCompositeRule(String condType,
Indicator<Num> left,
Indicator<Num> right,
BarSeries triggerSeries,
BarSeries leftSeries,
BarSeries rightSeries) {
BarSeries rightSeries,
boolean isBacktest) {
this.condType = condType;
this.left = left;
this.right = right;
this.triggerSeries = triggerSeries;
this.leftSeries = leftSeries;
this.rightSeries = rightSeries;
this.isBacktest = isBacktest;
}
@Override
public boolean isSatisfied(int index, TradingRecord tradingRecord) {
int li = evalIndex(triggerSeries, leftSeries, index);
int ri = evalIndex(triggerSeries, rightSeries, index);
int li = evalIndex(triggerSeries, leftSeries, index, isBacktest);
int ri = evalIndex(triggerSeries, rightSeries, index, isBacktest);
if (li < 1 || ri < 1) return false;
Num l0 = left.getValue(li - 1);
@@ -566,8 +670,8 @@ public class StrategyDslToTa4jAdapter {
private Indicator<Num> resolveField(String field, String indType,
Map<String, Object> p, BarSeries s,
int condPeriod, int sidePeriod) {
return resolveField(field, indType, p, s, condPeriod, sidePeriod, null,
new RuleBuildContext(s, Map.of(), Map.of(), null, null));
return resolveField(field, indType, p, s, condPeriod, sidePeriod, null,
new RuleBuildContext(s, Map.of(), Map.of(), null, null, false, Map.of()));
}
private double resolveConstantField(String field) {
@@ -40,6 +40,8 @@ public class StrategyTriggerBranchEvaluator {
/**
* 트리거 분봉({@code triggerCandleType}) 마감 시 호출되는 Rule.
* {@code index}는 트리거 분봉 BarSeries의 확정봉 인덱스.
*
* @param useConfirmedOnly true → CANDLE_CLOSE: 비트리거 상위봉의 확정봉(endIndex-1) 사용
*/
public Rule buildTriggerRule(String conditionJson,
String market,
@@ -48,7 +50,8 @@ public class StrategyTriggerBranchEvaluator {
String triggerCandleType,
Map<String, Map<String, Object>> indicatorParams,
Map<String, Map<String, Object>> indicatorVisual,
Ta4jStorage storage) {
Ta4jStorage storage,
boolean useConfirmedOnly) {
BranchScope scope = parseScope(conditionJson);
if (scope.branches().isEmpty()) return new BooleanRule(false);
@@ -59,11 +62,26 @@ public class StrategyTriggerBranchEvaluator {
indicatorParams,
indicatorVisual != null ? indicatorVisual : Map.of(),
market,
storage);
storage,
useConfirmedOnly,
Map.of());
return new TriggerBranchRule(scope, market, strategyId, side, trigger, ctx);
}
/** 하위 호환 오버로드 (useConfirmedOnly=false) */
public Rule buildTriggerRule(String conditionJson,
String market,
long strategyId,
String side,
String triggerCandleType,
Map<String, Map<String, Object>> indicatorParams,
Map<String, Map<String, Object>> indicatorVisual,
Ta4jStorage storage) {
return buildTriggerRule(conditionJson, market, strategyId, side, triggerCandleType,
indicatorParams, indicatorVisual, storage, false);
}
BranchScope parseScope(String conditionJson) {
if (conditionJson == null || conditionJson.isBlank()) {
return new BranchScope("SINGLE", List.of());
@@ -225,14 +243,19 @@ public class StrategyTriggerBranchEvaluator {
BarSeries branchSeries = ctx.resolveSeries(branch.candleType());
if (branchSeries.isEmpty()) return false;
int branchIndex = branch.candleType().equals(triggerCandleType)
? index
: branchSeries.getEndIndex();
int branchIndex;
if (branch.candleType().equals(triggerCandleType)) {
branchIndex = index;
} else if (ctx.useConfirmedOnly()) {
// CANDLE_CLOSE: 마지막 확정봉 사용 (provisional 봉 오염 방지)
branchIndex = Math.max(branchSeries.getBeginIndex(), branchSeries.getEndIndex() - 1);
} else {
// REALTIME_TICK: 현재 봉(provisional 포함) 사용
branchIndex = branchSeries.getEndIndex();
}
if (branchIndex < 0 || branchIndex >= branchSeries.getBarCount()) return false;
StrategyDslToTa4jAdapter.RuleBuildContext branchCtx =
new StrategyDslToTa4jAdapter.RuleBuildContext(
branchSeries, ctx.indicatorParams(), ctx.indicatorVisual(), ctx.market(), ctx.storage());
StrategyDslToTa4jAdapter.RuleBuildContext branchCtx = ctx.withPrimary(branchSeries);
try {
Rule rule = adapter.toRule(subtree, branchCtx);
return rule.isSatisfied(branchIndex, null);