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

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(); int n = series.getBarCount();
if (n == 0) return emptyResponse("유효한 캔들 데이터가 없습니다."); 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()) Rule baseExitRule = (sellDsl != null && !sellDsl.isNull())
? adapter.toRule(sellDsl, series, params) ? adapter.toRule(sellDsl, series, params, seriesOverrides)
: new BooleanRule(false); : new BooleanRule(false);
Rule exitRule = buildExitRule(baseExitRule, series, cfg); Rule exitRule = buildExitRule(baseExitRule, series, cfg);
@@ -532,18 +537,139 @@ public class BacktestingService {
private static Duration timeframeToDuration(String tf) { private static Duration timeframeToDuration(String tf) {
if (tf == null) return Duration.ofMinutes(1); if (tf == null) return Duration.ofMinutes(1);
return switch (tf) { return switch (tf) {
case "1m" -> Duration.ofMinutes(1); case "1m" -> Duration.ofMinutes(1);
case "3m" -> Duration.ofMinutes(3); case "3m" -> Duration.ofMinutes(3);
case "5m" -> Duration.ofMinutes(5); case "5m" -> Duration.ofMinutes(5);
case "10m" -> Duration.ofMinutes(10); case "10m" -> Duration.ofMinutes(10);
case "15m" -> Duration.ofMinutes(15); case "15m" -> Duration.ofMinutes(15);
case "30m" -> Duration.ofMinutes(30); case "30m" -> Duration.ofMinutes(30);
case "1h" -> Duration.ofHours(1); case "1h" -> Duration.ofHours(1);
case "4h" -> Duration.ofHours(4); case "4h" -> Duration.ofHours(4);
case "1D" -> Duration.ofDays(1); case "1d", "1D" -> Duration.ofDays(1);
case "1W" -> Duration.ofDays(7); case "1w", "1W" -> Duration.ofDays(7);
case "1M" -> Duration.ofDays(30); case "1M" -> Duration.ofDays(30);
default -> Duration.ofMinutes(1); 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 = StrategyDslToTa4jAdapter.RuleBuildContext ctx =
new StrategyDslToTa4jAdapter.RuleBuildContext( new StrategyDslToTa4jAdapter.RuleBuildContext(
series, params, visual, market, ta4jStorage); series, params, visual, market, ta4jStorage, false, java.util.Map.of());
Rule rule = adapter.toRule(wrapper, ctx); Rule rule = adapter.toRule(wrapper, ctx);
boolean satisfied = rule.isSatisfied(index, null); boolean satisfied = rule.isSatisfied(index, null);
Double current = adapter.readConditionFieldValue( Double current = adapter.readConditionFieldValue(
@@ -23,16 +23,24 @@ import java.util.concurrent.ConcurrentHashMap;
* *
* <p>두 가지 방식으로 전략 만족 여부를 판정한다: * <p>두 가지 방식으로 전략 만족 여부를 판정한다:
* <ul> * <ul>
* <li>방식 A (CANDLE_CLOSE): 봉 마감 직후 BarBuilder 에서 트리거</li> * <li>방식 A (CANDLE_CLOSE): 봉 마감 직후 BarBuilder 에서 트리거 — Rule 캐시 사용,
* <li>방식 B (REALTIME_TICK): 3초 스케줄러에서 현재 진행 중인 캔들 인덱스로 판정</li> * {@code useConfirmedOnly=true} 로 상위봉 확정봉 인덱스 사용.</li>
* <li>방식 B (REALTIME_TICK): 3초 스케줄러에서 현재 진행 중인 캔들 인덱스로 판정 —
* Rule 캐시 <b>우회</b>, 매 평가마다 신규 Rule 인스턴스 빌드.
* {@code useConfirmedOnly=false} 로 현재 봉 포함 최신 인덱스 사용.</li>
* </ul> * </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>포지션 종속성 모드: * <p>포지션 종속성 모드:
* <ul> * <ul>
* <li><b>LONG_ONLY</b>: TradingRecord 에 열린 포지션이 있어야 매도 시그널 발생. * <li><b>LONG_ONLY</b>: TradingRecord 에 열린 포지션이 있어야 매도 시그널 발생.</li>
* 시장 진입/청산을 {@code strategy.shouldEnter/shouldExit} 로 판정.</li> * <li><b>SIGNAL_ONLY</b>: 포지션 관계없이 지표 규칙만 충족되면 시그널 확정.</li>
* <li><b>SIGNAL_ONLY</b>: 포지션 관계없이 지표 규칙만 충족되면 시그널 확정.
* {@code rule.isSatisfied(index)} 를 직접 호출.</li>
* </ul> * </ul>
* *
* <p>동시성 안전: * <p>동시성 안전:
@@ -90,7 +98,11 @@ public class LiveStrategyEvaluator {
* @param candleType 캔들 타입 * @param candleType 캔들 타입
* @param maturedIndex 확정된 봉의 BarSeries 인덱스 * @param maturedIndex 확정된 봉의 BarSeries 인덱스
* @return "BUY" | "SELL" | "NONE" * @return "BUY" | "SELL" | "NONE"
* @deprecated 마켓 단위 API — 멀티 전략 환경에서 첫 번째 non-NONE 만 반환하므로
* 시그널 발행 경로에서 사용 금지.
* 대신 {@link BarCloseStrategyEvaluationService#onMaturedBarClose} 를 사용할 것.
*/ */
@Deprecated
public String evaluateCandleClose(String market, String candleType, int maturedIndex) { public String evaluateCandleClose(String market, String candleType, int maturedIndex) {
List<GcLiveStrategySettings> settings = settingsRepo.findActiveByMarket(market); List<GcLiveStrategySettings> settings = settingsRepo.findActiveByMarket(market);
if (settings.isEmpty()) return "NONE"; if (settings.isEmpty()) return "NONE";
@@ -109,6 +121,7 @@ public class LiveStrategyEvaluator {
/** /**
* 단일 live 설정 — 봉 마감(CANDLE_CLOSE) 평가. * 단일 live 설정 — 봉 마감(CANDLE_CLOSE) 평가.
* useConfirmedOnly=true: CrossSeriesRule 이 상위봉의 확정봉(endIndex-1)을 사용.
*/ */
public String evaluateSettingOnCandleClose(GcLiveStrategySettings s, String market, public String evaluateSettingOnCandleClose(GcLiveStrategySettings s, String market,
String candleType, int maturedIndex) { String candleType, int maturedIndex) {
@@ -120,7 +133,7 @@ public class LiveStrategyEvaluator {
String result = evaluate(market, candleType, s.getStrategyId(), String result = evaluate(market, candleType, s.getStrategyId(),
s.getPositionMode(), maturedIndex, s.getPositionMode(), maturedIndex,
s.getDeviceId(), s.getUserId()); s.getDeviceId(), s.getUserId(), true /* useConfirmedOnly */);
if (!"NONE".equals(result)) { if (!"NONE".equals(result)) {
log.info("[Evaluator] CANDLE_CLOSE strategyId={} {} {} idx={} mode={} → {}", log.info("[Evaluator] CANDLE_CLOSE strategyId={} {} {} idx={} mode={} → {}",
s.getStrategyId(), market, candleType, maturedIndex, s.getPositionMode(), result); s.getStrategyId(), market, candleType, maturedIndex, s.getPositionMode(), result);
@@ -131,22 +144,14 @@ public class LiveStrategyEvaluator {
/** /**
* 방식 B — 3초 스케줄러에서 호출. * 방식 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 market 마켓 코드
* @param candleType 캔들 타입 * @param candleType 캔들 타입
* @return "BUY" | "SELL" | "NONE" * @return "BUY" | "SELL" | "NONE"
* @deprecated 마켓 단위 API — 멀티 전략 환경에서 첫 번째 non-NONE 만 반환.
* 시그널 발행 경로에서 사용 금지.
* 대신 {@link #evaluateSettingRealtimeTick} 를 설정별로 직접 호출할 것.
*/ */
@Deprecated
public String evaluateRealtimeTick(String market, String candleType) { public String evaluateRealtimeTick(String market, String candleType) {
List<GcLiveStrategySettings> settings = settingsRepo.findActiveByMarket(market); List<GcLiveStrategySettings> settings = settingsRepo.findActiveByMarket(market);
if (settings.isEmpty()) return "NONE"; if (settings.isEmpty()) return "NONE";
@@ -160,6 +165,10 @@ public class LiveStrategyEvaluator {
/** /**
* 단일 live 설정 — 3초 스케줄러(REALTIME_TICK) 평가. * 단일 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, public String evaluateSettingRealtimeTick(GcLiveStrategySettings s, String market,
String candleType) { String candleType) {
@@ -177,12 +186,10 @@ public class LiveStrategyEvaluator {
Integer lastSignaledIdx = realtimeSignaledIdx.get(dedupeKey); Integer lastSignaledIdx = realtimeSignaledIdx.get(dedupeKey);
if (lastSignaledIdx != null && lastSignaledIdx == currentIndex) return "NONE"; if (lastSignaledIdx != null && lastSignaledIdx == currentIndex) return "NONE";
String cacheKey = market + ":" + candleType + ":" + s.getStrategyId(); // 캐시 완전 우회: 매번 신규 Rule 빌드 (CachedIndicator stale 방지 + 동시성 보장)
triggerRulesCache.remove(cacheKey); String result = evaluateWithFreshRules(market, candleType, s.getStrategyId(),
String result = evaluate(market, candleType, s.getStrategyId(),
s.getPositionMode(), currentIndex, s.getPositionMode(), currentIndex,
s.getDeviceId(), s.getUserId()); s.getDeviceId(), s.getUserId(), false /* useConfirmedOnly */);
if (!"NONE".equals(result)) { if (!"NONE".equals(result)) {
log.info("[Evaluator] REALTIME_TICK strategyId={} {} {} idx={} mode={} → {}", log.info("[Evaluator] REALTIME_TICK strategyId={} {} {} idx={} mode={} → {}",
s.getStrategyId(), market, candleType, currentIndex, s.getPositionMode(), result); s.getStrategyId(), market, candleType, currentIndex, s.getPositionMode(), result);
@@ -196,15 +203,9 @@ public class LiveStrategyEvaluator {
* *
* <p>배경: CrossedDown/Up 교차 이벤트가 봉 마감 시점에 발생하면, 3초 스케줄러는 * <p>배경: CrossedDown/Up 교차 이벤트가 봉 마감 시점에 발생하면, 3초 스케줄러는
* 이미 다음 분의 provisional 봉(index N+1)을 평가하기 때문에 교차를 감지하지 못한다. * 이미 다음 분의 provisional 봉(index N+1)을 평가하기 때문에 교차를 감지하지 못한다.
* (CrossedDown(N+1) → CCI(N) < threshold, CCI(N+1) < threshold → 교차 없음) * 이 메서드를 {@code BarBuilder.commitBar} 에서 호출하면 CANDLE_CLOSE 와 동일한
* * 타이밍에 확정봉 인덱스(N)로 평가하여 누락을 방지한다.
* <p>이 메서드를 {@code BarBuilder.commitBar} 에서 호출하면 CANDLE_CLOSE 와 동일한 * useConfirmedOnly=true 로 상위봉도 확정봉 기준 평가.
* 타이밍에 REALTIME_TICK 전략도 확정봉 인덱스(N)로 평가하여 누락을 방지한다.
*
* @param market 마켓 코드
* @param candleType 캔들 타입
* @param maturedIndex 확정된 봉의 BarSeries 인덱스
* @return "BUY" | "SELL" | "NONE"
*/ */
public String evaluateRealtimeAtClose(String market, String candleType, int maturedIndex) { public String evaluateRealtimeAtClose(String market, String candleType, int maturedIndex) {
List<GcLiveStrategySettings> settings = settingsRepo.findActiveByMarket(market); List<GcLiveStrategySettings> settings = settingsRepo.findActiveByMarket(market);
@@ -219,6 +220,7 @@ public class LiveStrategyEvaluator {
/** /**
* 단일 live 설정 — 봉 마감 시점 REALTIME_TICK 평가(교차 누락 방지). * 단일 live 설정 — 봉 마감 시점 REALTIME_TICK 평가(교차 누락 방지).
* 캐시 우회 + useConfirmedOnly=true.
*/ */
public String evaluateSettingRealtimeAtClose(GcLiveStrategySettings s, String market, public String evaluateSettingRealtimeAtClose(GcLiveStrategySettings s, String market,
String candleType, int maturedIndex) { String candleType, int maturedIndex) {
@@ -228,12 +230,10 @@ public class LiveStrategyEvaluator {
if (!ta4jStorage.exists(market, candleType)) return "NONE"; if (!ta4jStorage.exists(market, candleType)) return "NONE";
if (!conditionTimeframes.usesTimeframe(s.getStrategyId(), candleType)) return "NONE"; if (!conditionTimeframes.usesTimeframe(s.getStrategyId(), candleType)) return "NONE";
String cacheKey = market + ":" + candleType + ":" + s.getStrategyId(); // 확정봉 기준 평가: 캐시 우회 + useConfirmedOnly=true
triggerRulesCache.remove(cacheKey); String result = evaluateWithFreshRules(market, candleType, s.getStrategyId(),
String result = evaluate(market, candleType, s.getStrategyId(),
s.getPositionMode(), maturedIndex, s.getPositionMode(), maturedIndex,
s.getDeviceId(), s.getUserId()); s.getDeviceId(), s.getUserId(), true /* useConfirmedOnly */);
if (!"NONE".equals(result)) { if (!"NONE".equals(result)) {
log.info("[Evaluator] REALTIME_TICK @candle_close strategyId={} {} {} idx={} mode={} → {}", log.info("[Evaluator] REALTIME_TICK @candle_close strategyId={} {} {} idx={} mode={} → {}",
s.getStrategyId(), market, candleType, maturedIndex, s.getPositionMode(), result); s.getStrategyId(), market, candleType, maturedIndex, s.getPositionMode(), result);
@@ -262,18 +262,42 @@ public class LiveStrategyEvaluator {
// ── Private ─────────────────────────────────────────────────────────────── // ── Private ───────────────────────────────────────────────────────────────
/**
* CANDLE_CLOSE 전용 캐시 사용 경로.
* Rule 을 캐시하여 재사용하며, useConfirmedOnly=true 로 빌드한 Rule 을 저장한다.
*/
private String evaluate(String market, String candleType, long strategyId, private String evaluate(String market, String candleType, long strategyId,
String positionMode, int index, String positionMode, int index,
String deviceId, Long userId) { String deviceId, Long userId, boolean useConfirmedOnly) {
String cacheKey = market + ":" + candleType + ":" + strategyId; String cacheKey = market + ":" + candleType + ":" + strategyId;
TriggerRules rules = triggerRulesCache.get(cacheKey); TriggerRules rules = triggerRulesCache.get(cacheKey);
if (rules == null) { if (rules == null) {
rules = buildTriggerRules(market, candleType, strategyId, deviceId, userId); rules = buildTriggerRules(market, candleType, strategyId, deviceId, userId, useConfirmedOnly);
if (rules == null) return "NONE"; if (rules == null) return "NONE";
triggerRulesCache.put(cacheKey, rules); 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 { try {
String mode = positionMode != null ? positionMode : "LONG_ONLY"; String mode = positionMode != null ? positionMode : "LONG_ONLY";
@@ -312,7 +336,7 @@ public class LiveStrategyEvaluator {
} }
private TriggerRules buildTriggerRules(String market, String candleType, long strategyId, 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); Optional<GcStrategy> strategyOpt = strategyRepo.findById(strategyId);
if (strategyOpt.isEmpty()) return null; if (strategyOpt.isEmpty()) return null;
@@ -335,10 +359,10 @@ public class LiveStrategyEvaluator {
try { try {
Rule entryRule = triggerBranchEvaluator.buildTriggerRule( Rule entryRule = triggerBranchEvaluator.buildTriggerRule(
strategy.getBuyConditionJson(), market, strategyId, "buy", strategy.getBuyConditionJson(), market, strategyId, "buy",
candleType, indicatorParams, indicatorVisual, ta4jStorage); candleType, indicatorParams, indicatorVisual, ta4jStorage, useConfirmedOnly);
Rule exitRule = triggerBranchEvaluator.buildTriggerRule( Rule exitRule = triggerBranchEvaluator.buildTriggerRule(
strategy.getSellConditionJson(), market, strategyId, "sell", strategy.getSellConditionJson(), market, strategyId, "sell",
candleType, indicatorParams, indicatorVisual, ta4jStorage); candleType, indicatorParams, indicatorVisual, ta4jStorage, useConfirmedOnly);
return new TriggerRules(entryRule, exitRule); return new TriggerRules(entryRule, exitRule);
} catch (Exception e) { } catch (Exception e) {
log.error("[Evaluator] Trigger Rule 빌드 실패 strategyId={}: {}", strategyId, e.getMessage()); 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>> indicatorParams,
Map<String, Map<String, Object>> indicatorVisual, Map<String, Map<String, Object>> indicatorVisual,
String market, 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, public RuleBuildContext(BarSeries primarySeries,
Map<String, Map<String, Object>> indicatorParams, Map<String, Map<String, Object>> indicatorParams,
String market, String market,
Ta4jStorage storage) { 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) { BarSeries resolveSeries(String candleType) {
String ct = LiveStrategyTimeframeService.normalize(candleType); String ct = LiveStrategyTimeframeService.normalize(candleType);
// 백테스트용 사전 집계 시리즈 우선 검색
if (seriesOverrides != null && seriesOverrides.containsKey(ct)) {
return seriesOverrides.get(ct);
}
if (storage != null && market != null && !market.isBlank() if (storage != null && market != null && !market.isBlank()
&& storage.exists(market, ct)) { && storage.exists(market, ct)) {
return storage.getOrCreate(market, ct); return storage.getOrCreate(market, ct);
@@ -133,20 +159,41 @@ public class StrategyDslToTa4jAdapter {
public Rule toRule(JsonNode node, BarSeries series, public Rule toRule(JsonNode node, BarSeries series,
Map<String, Map<String, Object>> indicatorParams) { 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, public Rule toRule(JsonNode node, BarSeries series,
Map<String, Map<String, Object>> indicatorParams, Map<String, Map<String, Object>> indicatorParams,
String market, Ta4jStorage storage) { 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, public Rule toRule(JsonNode node, BarSeries series,
Map<String, Map<String, Object>> indicatorParams, Map<String, Map<String, Object>> indicatorParams,
Map<String, Map<String, Object>> indicatorVisual, Map<String, Map<String, Object>> indicatorVisual,
String market, Ta4jStorage storage) { 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) { public Rule toRule(JsonNode node, RuleBuildContext ctx) {
@@ -198,35 +245,82 @@ public class StrategyDslToTa4jAdapter {
private Rule buildTimeframeRule(JsonNode node, RuleBuildContext ctx) { private Rule buildTimeframeRule(JsonNode node, RuleBuildContext ctx) {
String ct = node.path("candleType").asText("1m"); String ct = node.path("candleType").asText("1m");
BarSeries branchSeries = ctx.resolveSeries(ct); BarSeries branchSeries = ctx.resolveSeries(ct);
RuleBuildContext branchCtx = new RuleBuildContext( RuleBuildContext branchCtx = ctx.withPrimary(branchSeries);
branchSeries, ctx.indicatorParams(), ctx.indicatorVisual(), ctx.market(), ctx.storage());
List<Rule> rules = childRules(node, branchCtx); List<Rule> rules = childRules(node, branchCtx);
if (rules.isEmpty()) return new BooleanRule(false); if (rules.isEmpty()) return new BooleanRule(false);
Rule inner = rules.get(0); Rule inner = rules.get(0);
if (branchSeries == ctx.primarySeries()) return inner; 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 static final class CrossSeriesRule implements Rule {
private final Rule inner; private final Rule inner;
private final BarSeries branchSeries; 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.inner = inner;
this.branchSeries = branchSeries; this.branchSeries = branchSeries;
this.triggerSeries = triggerSeries; this.primarySeries = primarySeries;
this.useConfirmedOnly = useConfirmedOnly;
this.isBacktest = isBacktest;
} }
@Override @Override
public boolean isSatisfied(int index, TradingRecord tradingRecord) { public boolean isSatisfied(int index, TradingRecord tradingRecord) {
int evalIndex = branchSeries == triggerSeries if (branchSeries.getBarCount() == 0) return false;
? index
: branchSeries.getEndIndex(); int evalIndex;
if (evalIndex < 0 || branchSeries.getBarCount() == 0) return false; 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); 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 ──────────────────────────────────────────────────────── // ── AND / OR / NOT ────────────────────────────────────────────────────────
@@ -264,22 +358,22 @@ public class StrategyDslToTa4jAdapter {
private Rule buildAndRule(JsonNode node, BarSeries series, private Rule buildAndRule(JsonNode node, BarSeries series,
Map<String, Map<String, Object>> p) { 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, private Rule buildOrRule(JsonNode node, BarSeries series,
Map<String, Map<String, Object>> p) { 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, private Rule buildNotRule(JsonNode node, BarSeries series,
Map<String, Map<String, Object>> p) { 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, private List<Rule> childRules(JsonNode node, BarSeries series,
Map<String, Map<String, Object>> p) { 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 ───────────────────────────────────────────────────────────── // ── CONDITION ─────────────────────────────────────────────────────────────
@@ -437,7 +531,10 @@ public class StrategyDslToTa4jAdapter {
/** 복합지표 — leftCandleType / rightCandleType 이 서로 다를 때 교차 시간봉 평가 */ /** 복합지표 — leftCandleType / rightCandleType 이 서로 다를 때 교차 시간봉 평가 */
private boolean needsCrossTimeframeComposite(JsonNode cond, RuleBuildContext ctx) { private boolean needsCrossTimeframeComposite(JsonNode cond, RuleBuildContext ctx) {
if (!cond.path("composite").asBoolean(false)) return false; 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( String leftCt = LiveStrategyTimeframeService.normalize(
cond.path("leftCandleType").asText("1m")); cond.path("leftCandleType").asText("1m"));
String rightCt = LiveStrategyTimeframeService.normalize( String rightCt = LiveStrategyTimeframeService.normalize(
@@ -470,7 +567,7 @@ public class StrategyDslToTa4jAdapter {
try { try {
Indicator<Num> left = resolveField(leftField, indType, indParams, leftSeries, condPeriod, leftPeriod, cond, ctx); Indicator<Num> left = resolveField(leftField, indType, indParams, leftSeries, condPeriod, leftPeriod, cond, ctx);
Indicator<Num> right = resolveField(rightField, indType, indParams, rightSeries, condPeriod, rightPeriod, 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) { } catch (Exception e) {
log.warn("[Adapter] 복합 교차시간봉 빌드 실패 ind={} cond={}: {}", log.warn("[Adapter] 복합 교차시간봉 빌드 실패 ind={} cond={}: {}",
indType, condType, e.getMessage()); 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 (trigger == branch) return triggerIndex;
if (isBacktest) {
java.time.Instant t = trigger.getBar(triggerIndex).getEndTime();
return CrossSeriesRule.findLastIndexAtOrBefore(branch, t);
}
int end = branch.getEndIndex(); int end = branch.getEndIndex();
return end >= 0 ? end : 0; return end >= 0 ? end : 0;
} }
@@ -491,25 +592,28 @@ public class StrategyDslToTa4jAdapter {
private final BarSeries triggerSeries; private final BarSeries triggerSeries;
private final BarSeries leftSeries; private final BarSeries leftSeries;
private final BarSeries rightSeries; private final BarSeries rightSeries;
private final boolean isBacktest;
CrossTimeframeCompositeRule(String condType, CrossTimeframeCompositeRule(String condType,
Indicator<Num> left, Indicator<Num> left,
Indicator<Num> right, Indicator<Num> right,
BarSeries triggerSeries, BarSeries triggerSeries,
BarSeries leftSeries, BarSeries leftSeries,
BarSeries rightSeries) { BarSeries rightSeries,
boolean isBacktest) {
this.condType = condType; this.condType = condType;
this.left = left; this.left = left;
this.right = right; this.right = right;
this.triggerSeries = triggerSeries; this.triggerSeries = triggerSeries;
this.leftSeries = leftSeries; this.leftSeries = leftSeries;
this.rightSeries = rightSeries; this.rightSeries = rightSeries;
this.isBacktest = isBacktest;
} }
@Override @Override
public boolean isSatisfied(int index, TradingRecord tradingRecord) { public boolean isSatisfied(int index, TradingRecord tradingRecord) {
int li = evalIndex(triggerSeries, leftSeries, index); int li = evalIndex(triggerSeries, leftSeries, index, isBacktest);
int ri = evalIndex(triggerSeries, rightSeries, index); int ri = evalIndex(triggerSeries, rightSeries, index, isBacktest);
if (li < 1 || ri < 1) return false; if (li < 1 || ri < 1) return false;
Num l0 = left.getValue(li - 1); Num l0 = left.getValue(li - 1);
@@ -567,7 +671,7 @@ public class StrategyDslToTa4jAdapter {
Map<String, Object> p, BarSeries s, Map<String, Object> p, BarSeries s,
int condPeriod, int sidePeriod) { int condPeriod, int sidePeriod) {
return resolveField(field, indType, p, s, condPeriod, sidePeriod, null, return resolveField(field, indType, p, s, condPeriod, sidePeriod, null,
new RuleBuildContext(s, Map.of(), Map.of(), null, null)); new RuleBuildContext(s, Map.of(), Map.of(), null, null, false, Map.of()));
} }
private double resolveConstantField(String field) { private double resolveConstantField(String field) {
@@ -40,6 +40,8 @@ public class StrategyTriggerBranchEvaluator {
/** /**
* 트리거 분봉({@code triggerCandleType}) 마감 시 호출되는 Rule. * 트리거 분봉({@code triggerCandleType}) 마감 시 호출되는 Rule.
* {@code index}는 트리거 분봉 BarSeries의 확정봉 인덱스. * {@code index}는 트리거 분봉 BarSeries의 확정봉 인덱스.
*
* @param useConfirmedOnly true → CANDLE_CLOSE: 비트리거 상위봉의 확정봉(endIndex-1) 사용
*/ */
public Rule buildTriggerRule(String conditionJson, public Rule buildTriggerRule(String conditionJson,
String market, String market,
@@ -48,7 +50,8 @@ public class StrategyTriggerBranchEvaluator {
String triggerCandleType, String triggerCandleType,
Map<String, Map<String, Object>> indicatorParams, Map<String, Map<String, Object>> indicatorParams,
Map<String, Map<String, Object>> indicatorVisual, Map<String, Map<String, Object>> indicatorVisual,
Ta4jStorage storage) { Ta4jStorage storage,
boolean useConfirmedOnly) {
BranchScope scope = parseScope(conditionJson); BranchScope scope = parseScope(conditionJson);
if (scope.branches().isEmpty()) return new BooleanRule(false); if (scope.branches().isEmpty()) return new BooleanRule(false);
@@ -59,11 +62,26 @@ public class StrategyTriggerBranchEvaluator {
indicatorParams, indicatorParams,
indicatorVisual != null ? indicatorVisual : Map.of(), indicatorVisual != null ? indicatorVisual : Map.of(),
market, market,
storage); storage,
useConfirmedOnly,
Map.of());
return new TriggerBranchRule(scope, market, strategyId, side, trigger, ctx); 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) { BranchScope parseScope(String conditionJson) {
if (conditionJson == null || conditionJson.isBlank()) { if (conditionJson == null || conditionJson.isBlank()) {
return new BranchScope("SINGLE", List.of()); return new BranchScope("SINGLE", List.of());
@@ -225,14 +243,19 @@ public class StrategyTriggerBranchEvaluator {
BarSeries branchSeries = ctx.resolveSeries(branch.candleType()); BarSeries branchSeries = ctx.resolveSeries(branch.candleType());
if (branchSeries.isEmpty()) return false; if (branchSeries.isEmpty()) return false;
int branchIndex = branch.candleType().equals(triggerCandleType) int branchIndex;
? index if (branch.candleType().equals(triggerCandleType)) {
: branchSeries.getEndIndex(); 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; if (branchIndex < 0 || branchIndex >= branchSeries.getBarCount()) return false;
StrategyDslToTa4jAdapter.RuleBuildContext branchCtx = StrategyDslToTa4jAdapter.RuleBuildContext branchCtx = ctx.withPrimary(branchSeries);
new StrategyDslToTa4jAdapter.RuleBuildContext(
branchSeries, ctx.indicatorParams(), ctx.indicatorVisual(), ctx.market(), ctx.storage());
try { try {
Rule rule = adapter.toRule(subtree, branchCtx); Rule rule = adapter.toRule(subtree, branchCtx);
return rule.isSatisfied(branchIndex, null); return rule.isSatisfied(branchIndex, null);
@@ -35,6 +35,7 @@ import {
resolveStrategyPrimaryTimeframe, resolveStrategyPrimaryTimeframe,
} from '../../utils/strategyToChartIndicators'; } from '../../utils/strategyToChartIndicators';
import type { ChartOverlayToggleKey } from '../../utils/chartOverlayVisibility'; import type { ChartOverlayToggleKey } from '../../utils/chartOverlayVisibility';
import StrategyOscillatorPanes from './StrategyOscillatorPanes';
interface Props { interface Props {
symbol: string; symbol: string;
@@ -154,10 +155,21 @@ const BacktestAnalysisChart: React.FC<Props> = ({
return () => { cancelled = true; }; return () => { cancelled = true; };
}, [strategyId]); }, [strategyId]);
/**
* overlayVisibility 가 없는 경우(BacktestHistoryPage 컨텍스트)에는
* 보조지표(오실레이터)를 TradingChart sub-pane 이 아닌 아래쪽
* StrategyOscillatorPanes 섹션에서 표시한다.
*/
const showOscillatorPanel = !overlayVisibility;
const baseIndicators = useMemo(() => { const baseIndicators = useMemo(() => {
let inds: IndicatorConfig[]; let inds: IndicatorConfig[];
if (strategy) { if (strategy) {
inds = buildVirtualTradingChartIndicators(strategy, getParams, getVisualConfig); inds = buildVirtualTradingChartIndicators(strategy, getParams, getVisualConfig);
// 오실레이터 패널을 별도 섹션으로 표시할 때는 TradingChart 에서 제거
if (showOscillatorPanel) {
inds = inds.filter(i => isOverlayType(i.type));
}
inds = inds.filter(i => i.timeframeVisibility?.[chartTimeframe] !== false); inds = inds.filter(i => i.timeframeVisibility?.[chartTimeframe] !== false);
} else { } else {
inds = defaultOverlayIndicators(getParams); inds = defaultOverlayIndicators(getParams);
@@ -166,7 +178,7 @@ const BacktestAnalysisChart: React.FC<Props> = ({
inds = ensurePaperChartOverlays(inds, getParams, getVisualConfig); inds = ensurePaperChartOverlays(inds, getParams, getVisualConfig);
} }
return inds; return inds;
}, [strategy, getParams, getVisualConfig, chartTimeframe, overlayVisibility]); }, [strategy, getParams, getVisualConfig, chartTimeframe, overlayVisibility, showOscillatorPanel]);
const indicators = useMemo(() => { const indicators = useMemo(() => {
let inds = overlayVisibility let inds = overlayVisibility
@@ -455,6 +467,9 @@ const BacktestAnalysisChart: React.FC<Props> = ({
<div className="btd-analysis-error"> .</div> <div className="btd-analysis-error"> .</div>
)} )}
</div> </div>
{showOscillatorPanel && !loading && bars.length > 0 && (
<StrategyOscillatorPanes bars={bars} strategy={strategy} />
)}
</div> </div>
</div> </div>
); );
@@ -113,6 +113,16 @@ function saveHiddenIds(ids: Set<string>) {
patchUiPreferences({ tradeNotifications: { hiddenIds: [...ids] } }); patchUiPreferences({ tradeNotifications: { hiddenIds: [...ids] } });
} }
/** 전체닫기·삭제 시각 (epoch 초) 로드 */
function loadSuppressBefore(): number {
return getUiPreferences().tradeNotifications?.suppressBefore ?? 0;
}
/** 전체닫기·삭제 시각 저장 */
function saveSuppressBefore(ts: number, immediate = false) {
patchUiPreferences({ tradeNotifications: { suppressBefore: ts } }, immediate);
}
export function useTradeNotification(): TradeNotificationContextValue { export function useTradeNotification(): TradeNotificationContextValue {
const ctx = useContext(TradeNotificationContext); const ctx = useContext(TradeNotificationContext);
if (!ctx) { if (!ctx) {
@@ -157,6 +167,12 @@ export const TradeNotificationProvider: React.FC<ProviderProps> = ({
* 같은 틱에 수신된 신호 ID 까지 readIds 에 포함시킬 수 있다. * 같은 틱에 수신된 신호 ID 까지 readIds 에 포함시킬 수 있다.
*/ */
const allSeenIdsRef = useRef<Set<string>>(new Set()); const allSeenIdsRef = useRef<Set<string>>(new Set());
/**
* 전체닫기·삭제 실행 시각 (epoch 초).
* candleTime < suppressBefore 인 오래된 신호는 팝업을 띄우지 않는다.
* 백엔드 backfill 이 STOMP 로 수십 일치 과거 신호를 재전송해도 무시한다.
*/
const suppressBeforeRef = useRef<number>(loadSuppressBefore());
const [detailSignal, setDetailSignal] = useState<TradeSignalInfo | null>(null); const [detailSignal, setDetailSignal] = useState<TradeSignalInfo | null>(null);
const [detailCentered, setDetailCentered] = useState(false); const [detailCentered, setDetailCentered] = useState(false);
@@ -176,7 +192,29 @@ export const TradeNotificationProvider: React.FC<ProviderProps> = ({
*/ */
const cacheRead = loadReadIds(); const cacheRead = loadReadIds();
const memRead = readIdsRef.current; const memRead = readIdsRef.current;
const read = new Set([...cacheRead, ...memRead]); const suppressBefore = suppressBeforeRef.current;
/**
* suppressBefore 이전 캔들 신호: readIds 에 추가하고 isRead=true 처리.
* DB 에 남아있는 오래된 신호가 폴링에서 미읽음으로 잡히는 것을 방지한다.
*/
const suppressed = dtos.filter(
d => suppressBefore > 0 && (d.candleTime ?? 0) < suppressBefore,
);
let effectiveRead = new Set([...cacheRead, ...memRead]);
if (suppressed.length > 0) {
const suppressedIds = suppressed.map(
d => `${d.market}:${d.candleTime}:${d.signalType}`,
);
const nextRead = new Set([...effectiveRead, ...suppressedIds]);
if (nextRead.size !== effectiveRead.size) {
readIdsRef.current = nextRead;
effectiveRead = nextRead;
saveReadIds(nextRead, true);
}
}
const read = effectiveRead;
const hidden = opts?.rawServerList ? new Set<string>() : loadHiddenIds(); const hidden = opts?.rawServerList ? new Set<string>() : loadHiddenIds();
const items = dtos const items = dtos
.map(d => dtoToItem(d, read)) .map(d => dtoToItem(d, read))
@@ -252,6 +290,8 @@ export const TradeNotificationProvider: React.FC<ProviderProps> = ({
readIdsRef.current = merged; readIdsRef.current = merged;
setReadIds(merged); setReadIds(merged);
} }
// 전체닫기·삭제 시각 복원 (재접속 후에도 오래된 신호 팝업 방지)
suppressBeforeRef.current = loadSuppressBefore();
setToastNotifications([]); setToastNotifications([]);
void refreshHistory({ serverOnly: true, rawServerList: true }); void refreshHistory({ serverOnly: true, rawServerList: true });
}, [refreshHistory, settingsLoaded, settingsSessionKey]); }, [refreshHistory, settingsLoaded, settingsSessionKey]);
@@ -276,19 +316,29 @@ export const TradeNotificationProvider: React.FC<ProviderProps> = ({
// 수신한 모든 ID 를 동기적으로 누적 (dismissAllToasts 가 참조) // 수신한 모든 ID 를 동기적으로 누적 (dismissAllToasts 가 참조)
allSeenIdsRef.current.add(id); allSeenIdsRef.current.add(id);
/**
* ── 오래된 신호 억제 (핵심 필터) ───────────────────────────────────────
* 전체닫기·삭제 시각(suppressBefore) 이전에 시작된 캔들의 신호는
* 백엔드 backfill 이 STOMP 로 재전송해도 팝업을 띄우지 않는다.
* candleTime 은 epoch 초 단위이고 suppressBefore 도 epoch 초이다.
*/
const isSuppressed =
suppressBeforeRef.current > 0 &&
signal.candleTime < suppressBeforeRef.current;
/** /**
* 읽음 여부 3중 확인: * 읽음 여부 3중 확인:
* 1. readIdsRef.current — dismissAllToasts 가 즉시 업데이트하는 인메모리 ref * 1. readIdsRef.current — dismissAllToasts 가 즉시 업데이트하는 인메모리 ref
* 2. loadReadIds() — DB 캐시 기반 값 * 2. loadReadIds() — DB 캐시 기반 값
* 3. allNotificationsRef — dismissAllToasts 의 functional updater 가 * 3. allNotificationsRef — functional updater 가 isRead:true 예약 후 ref 미반영 커버
* isRead:true 를 예약했지만 readIdsRef 에 아직 반영되지 않은 경우 커버
*/ */
const alreadyRead = const alreadyRead =
isSuppressed ||
readIdsRef.current.has(id) || readIdsRef.current.has(id) ||
loadReadIds().has(id) || loadReadIds().has(id) ||
(allNotificationsRef.current.find(n => n.id === id)?.isRead === true); (allNotificationsRef.current.find(n => n.id === id)?.isRead === true);
// readIds 에 누락된 읽음 항목 보정 (다음 STOMP/폴링에서 즉시 필터링) // readIds 에 누락된 읽음 항목 보정
if (alreadyRead && !readIdsRef.current.has(id)) { if (alreadyRead && !readIdsRef.current.has(id)) {
const next = new Set(readIdsRef.current); const next = new Set(readIdsRef.current);
next.add(id); next.add(id);
@@ -389,6 +439,11 @@ export const TradeNotificationProvider: React.FC<ProviderProps> = ({
...allSeenIdsRef.current, // 동일 틱에 수신됐지만 ref 미반영 ID ...allSeenIdsRef.current, // 동일 틱에 수신됐지만 ref 미반영 ID
]); ]);
// 전체닫기 시각 기록 — 이 시각 이전 candleTime 신호는 이후 팝업 금지
const nowSec = Math.floor(Date.now() / 1000);
suppressBeforeRef.current = nowSec;
saveSuppressBefore(nowSec, true);
// 1) 인메모리 ref 즉시 갱신 (다음 addNotification 호출에서 즉시 참조) // 1) 인메모리 ref 즉시 갱신 (다음 addNotification 호출에서 즉시 참조)
readIdsRef.current = nextRead; readIdsRef.current = nextRead;
@@ -490,18 +545,21 @@ export const TradeNotificationProvider: React.FC<ProviderProps> = ({
]), ]),
]; ];
// 전체 삭제 시각 기록 — 이 시각 이전 candleTime 신호는 이후 팝업 금지
const nowSec = Math.floor(Date.now() / 1000);
suppressBeforeRef.current = nowSec;
saveSuppressBefore(nowSec, true);
/** /**
* readIds 를 new Set() 으로 초기화하지 않는다. * readIds 에 삭제된 ID 를 추가 (초기화 X).
* 초기화하면 백엔드가 삭제된 신호를 STOMP 로 재전송할 때 * 백엔드가 동일 신호를 STOMP 로 재전송해도 suppressBefore + readIds 이중 필터.
* readIds 에 없어서 팝업이 다시 뜨는 버그가 발생한다.
* 대신 삭제된 ID 를 readIds 에 추가하여 재전송 신호를 필터링한다.
*/ */
const nextRead = new Set([...readIdsRef.current, ...ids]); const nextRead = new Set([...readIdsRef.current, ...ids]);
readIdsRef.current = nextRead; readIdsRef.current = nextRead;
setReadIds(nextRead); setReadIds(nextRead);
saveReadIds(nextRead, true); saveReadIds(nextRead, true);
// hiddenIds 초기화 (전체 삭제 후 목록 완전히 비움) // hiddenIds 초기화 (전체 삭제 후 목록 완전히 비움)
saveHiddenIds(new Set()); saveHiddenIds(new Set());
setToastNotifications([]); setToastNotifications([]);
+6
View File
@@ -33,6 +33,12 @@ export interface UiPreferences {
tradeNotifications?: { tradeNotifications?: {
readIds?: string[]; readIds?: string[];
hiddenIds?: string[]; hiddenIds?: string[];
/**
* 전체닫기·전체삭제 실행 시각 (epoch 초).
* candleTime < suppressBefore 인 신호는 팝업 표시하지 않는다.
* 백엔드 backfill 로 오래된 신호가 STOMP 재전송되어도 팝업이 뜨지 않게 한다.
*/
suppressBefore?: number;
/** 매매 시그널 알림 목록 화면 — list | grid */ /** 매매 시그널 알림 목록 화면 — list | grid */
listLayout?: 'list' | 'grid'; listLayout?: 'list' | 'grid';
/** 그리드형 열 배치 프리셋 id (tradeNotificationGridPresets) */ /** 그리드형 열 배치 프리셋 id (tradeNotificationGridPresets) */
+23 -11
View File
@@ -438,17 +438,22 @@ flowchart LR
**조건:** `executionType == REALTIME_TICK` 등 동일 **조건:** `executionType == REALTIME_TICK` 등 동일
**평가:** `evaluateRealtimeTick(market, candleType)` **평가:** `evaluateSettingRealtimeTick(setting, market, candleType)`
- `currentIndex = series.getEndIndex()`**진행 중(provisional) 봉** - `currentIndex = series.getEndIndex()`**진행 중(provisional) 봉**
- **중복 방지:** 동일 `(market, candleType)`에서 같은 index에 이미 시그널 냈으면 skip - **중복 방지:** 동일 `(market, candleType:strategyId)`에서 같은 index에 이미 시그널 냈으면 skip
- 평가 전 **strategy 캐시 제거**`updateLastBar` 후 지표 캐시 stale 방지 - **Rule 캐시 완전 우회:** 매 평가마다 신규 Rule 인스턴스 빌드 (`evaluateWithFreshRules`)
- Ta4j `CachedIndicator` stale 캐시 원천 차단
- `triggerRulesCache` 복합 연산(remove→put) 스레드 안전성 문제 제거
- **멀티 TF:** `useConfirmedOnly=false` — 비트리거 상위봉 현재 진행 중인 봉 기준 평가
### 9.5 보완 — 마감 직후 REALTIME 재평가 ### 9.5 보완 — 마감 직후 REALTIME 재평가
`BarBuilder`: `CANDLE_CLOSE``NONE`이면`evaluateRealtimeAtClose(maturedIndex)` 봉 마감 시`evaluateSettingRealtimeAtClose(setting, market, candleType, maturedIndex)`
- 봉 마감 시점의 **교차(CROSS)** 를 3초 스케줄러가 놓치는 경우 대비 - 봉 마감 시점의 **교차(CROSS)** 를 3초 스케줄러가 놓치는 경우 대비
- `useConfirmedOnly=true` — 확정봉 인덱스 기준 + 상위봉도 확정봉 사용
- Rule 캐시 우회
### 9.6 평가 흐름 (단일 종목·단일 시점) ### 9.6 평가 흐름 (단일 종목·단일 시점)
@@ -457,24 +462,29 @@ flowchart TD
A[트리거: 봉 마감 또는 3초 틱] A[트리거: 봉 마감 또는 3초 틱]
B{isLiveCheck?} B{isLiveCheck?}
C{usesTimeframe?} C{usesTimeframe?}
D[LiveStrategyEvaluator.evaluate] D1[CANDLE_CLOSE: evaluate - 캐시 사용 - useConfirmedOnly=true]
E[buildStrategy - DSL to Ta4j] D2[REALTIME_TICK: evaluateWithFreshRules - 캐시 우회 - useConfirmedOnly=false]
E[buildTriggerRules - DSL to Ta4j with CrossSeriesRule]
F[StrategySignalDeterminer.determineSignal] F[StrategySignalDeterminer.determineSignal]
G{결과} G{결과}
H[publishStrategySignal] H[설정별 독립 publishStrategySignal]
I[NONE - 종료] I[NONE - 종료]
A --> B A --> B
B -->|false| I B -->|false| I
B -->|true| C B -->|true| C
C -->|false| I C -->|false| I
C -->|true| D C -->|CANDLE_CLOSE| D1
D --> E --> F --> G C -->|REALTIME_TICK| D2
D1 --> E --> F --> G
D2 --> E
G -->|BUY/SELL| H G -->|BUY/SELL| H
G -->|NONE| I G -->|NONE| I
``` ```
**주의:** 동일 마켓에 활성 설정이 여러 개면 evaluator**첫 non-NONE 시그널만** 반환할 수 있음. **주의:** `BarCloseStrategyEvaluationService` / `LiveStrategyScheduler` 는 **설정별 독립 루프**로
멀티 전략 시그널을 각각 발행한다. `evaluateCandleClose(market)`, `evaluateRealtimeTick(market)` 마켓 단위 API 는
첫 non-NONE 만 반환하므로 실시간 시그널 발행 경로에서 사용 금지 (`@Deprecated`).
--- ---
@@ -533,7 +543,9 @@ flowchart TD
| 항목 | 백테스트 (`BacktestingService`) | 실시간 (`LiveStrategyEvaluator`) | | 항목 | 백테스트 (`BacktestingService`) | 실시간 (`LiveStrategyEvaluator`) |
|------|--------------------------------|----------------------------------| |------|--------------------------------|----------------------------------|
| BarSeries | 요청 `bars`로 일회성 생성 | `Ta4jStorage` 실시간 누적 | | BarSeries | 요청 `bars`로 일회성 생성 | `Ta4jStorage` 실시간 누적 |
| adapter | `toRule(dsl, series, params)` | `toRule(..., market, storage)` | | 멀티 TF | **DSL에서 상위봉 타입 추출 → 집계 시리즈 생성** (→ seriesOverrides) | `Ta4jStorage` 다중 시리즈 |
| adapter | `toRule(dsl, series, params, seriesOverrides)` (백테스트 멀티 TF) | `toRule(..., market, storage)` |
| CrossSeriesRule | **타임스탬프 기반 룩업** (isBacktest=true) | getEndIndex() 또는 endIndex-1 |
| 스캔 | **전 구간** 0..barCount-1 | **단일 index** (마감 or provisional) | | 스캔 | **전 구간** 0..barCount-1 | **단일 index** (마감 or provisional) |
| 청산 | DSL sell + **손절/익절/트레일링** OR 합성 | DSL sell (+ 리스크 모니터 별도) | | 청산 | DSL sell + **손절/익절/트레일링** OR 합성 | DSL sell (+ 리스크 모니터 별도) |
| 출력 | `BacktestResponse.signals`, `gc_backtest_result` | STOMP + `gc_trade_signal` | | 출력 | `BacktestResponse.signals`, `gc_backtest_result` | STOMP + `gc_trade_signal` |