From 5278177bbb392d5e689118eff19e420d031d0c76 Mon Sep 17 00:00:00 2001 From: Macbook Date: Fri, 5 Jun 2026 10:32:40 +0900 Subject: [PATCH] =?UTF-8?q?=EC=95=8C=EB=A6=BC=ED=8C=9D=EC=97=85=20?= =?UTF-8?q?=EC=88=98=EC=A0=95,=20=EC=A0=84=EB=9E=B5=ED=8F=89=EA=B0=80=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/BacktestingService.java | 154 +++++++++++++++-- .../service/LiveConditionStatusService.java | 2 +- .../service/LiveStrategyEvaluator.java | 110 +++++++----- .../service/StrategyDslToTa4jAdapter.java | 162 ++++++++++++++---- .../StrategyTriggerBranchEvaluator.java | 39 ++++- .../backtest/BacktestAnalysisChart.tsx | 17 +- .../src/contexts/TradeNotificationContext.tsx | 76 +++++++- frontend/src/types/uiPreferences.ts | 6 + 전략편집기 로직 및 동작흐름.md | 34 ++-- 9 files changed, 484 insertions(+), 116 deletions(-) diff --git a/backend/src/main/java/com/goldenchart/service/BacktestingService.java b/backend/src/main/java/com/goldenchart/service/BacktestingService.java index 8282de3..47dab30 100644 --- a/backend/src/main/java/com/goldenchart/service/BacktestingService.java +++ b/backend/src/main/java/com/goldenchart/service/BacktestingService.java @@ -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 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 buildSeriesOverrides(BarSeries primarySeries, String primaryTf, + JsonNode buyDsl, JsonNode sellDsl) { + Set requiredTfs = new LinkedHashSet<>(); + collectTimeframesFromDsl(buyDsl, requiredTfs); + collectTimeframesFromDsl(sellDsl, requiredTfs); + requiredTfs.remove(primaryTf); + + Map 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 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> 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> entry : grouped.entrySet()) { + List 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; }; } diff --git a/backend/src/main/java/com/goldenchart/service/LiveConditionStatusService.java b/backend/src/main/java/com/goldenchart/service/LiveConditionStatusService.java index 905b479..f4ad9db 100644 --- a/backend/src/main/java/com/goldenchart/service/LiveConditionStatusService.java +++ b/backend/src/main/java/com/goldenchart/service/LiveConditionStatusService.java @@ -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( diff --git a/backend/src/main/java/com/goldenchart/service/LiveStrategyEvaluator.java b/backend/src/main/java/com/goldenchart/service/LiveStrategyEvaluator.java index 0a688a0..5f9d101 100644 --- a/backend/src/main/java/com/goldenchart/service/LiveStrategyEvaluator.java +++ b/backend/src/main/java/com/goldenchart/service/LiveStrategyEvaluator.java @@ -23,16 +23,24 @@ import java.util.concurrent.ConcurrentHashMap; * *

두 가지 방식으로 전략 만족 여부를 판정한다: *

    - *
  • 방식 A (CANDLE_CLOSE): 봉 마감 직후 BarBuilder 에서 트리거
  • - *
  • 방식 B (REALTIME_TICK): 3초 스케줄러에서 현재 진행 중인 캔들 인덱스로 판정
  • + *
  • 방식 A (CANDLE_CLOSE): 봉 마감 직후 BarBuilder 에서 트리거 — Rule 캐시 사용, + * {@code useConfirmedOnly=true} 로 상위봉 확정봉 인덱스 사용.
  • + *
  • 방식 B (REALTIME_TICK): 3초 스케줄러에서 현재 진행 중인 캔들 인덱스로 판정 — + * Rule 캐시 우회, 매 평가마다 신규 Rule 인스턴스 빌드. + * {@code useConfirmedOnly=false} 로 현재 봉 포함 최신 인덱스 사용.
  • *
* + *

REALTIME_TICK 캐시 우회 이유 (동시성 보장)

+ *

Ta4j {@code CachedIndicator} 는 {@code getValue(index)} 결과를 내부 리스트에 영구 저장한다. + * {@code Ta4jStorage.updateLastBar} 가 provisional 봉의 close price 를 갱신해도 + * 이미 캐시된 값은 변하지 않으므로 Rule 이 구 가격으로 판정(Ghost Signal)한다. + * REALTIME_TICK 에서는 Rule 자체를 매번 재생성함으로써 캐시 오염을 원천 차단한다. + * 이 방식은 기존 "triggerRulesCache.remove + evaluate" 복합 연산의 스레드 안전성 문제도 해소한다. + * *

포지션 종속성 모드: *

    - *
  • LONG_ONLY: TradingRecord 에 열린 포지션이 있어야 매도 시그널 발생. - * 시장 진입/청산을 {@code strategy.shouldEnter/shouldExit} 로 판정.
  • - *
  • SIGNAL_ONLY: 포지션 관계없이 지표 규칙만 충족되면 시그널 확정. - * {@code rule.isSatisfied(index)} 를 직접 호출.
  • + *
  • LONG_ONLY: TradingRecord 에 열린 포지션이 있어야 매도 시그널 발생.
  • + *
  • SIGNAL_ONLY: 포지션 관계없이 지표 규칙만 충족되면 시그널 확정.
  • *
* *

동시성 안전: @@ -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 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초 스케줄러에서 호출. * - *

동일 provisional 봉(endIndex)에서 CROSS 시그널이 중복 발화되지 않도록 - * 마지막 시그널 인덱스를 추적한다. 새 분이 시작되면 endIndex가 증가하므로 - * 자동으로 다음 교차 이벤트를 감지할 수 있다. - * - *

캐시 무효화 정책 (Ta4j CachedIndicator memoization 문제 대응)
- * 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 settings = settingsRepo.findActiveByMarket(market); if (settings.isEmpty()) return "NONE"; @@ -160,6 +165,10 @@ public class LiveStrategyEvaluator { /** * 단일 live 설정 — 3초 스케줄러(REALTIME_TICK) 평가. + * + *

캐시 완전 우회: 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 { * *

배경: CrossedDown/Up 교차 이벤트가 봉 마감 시점에 발생하면, 3초 스케줄러는 * 이미 다음 분의 provisional 봉(index N+1)을 평가하기 때문에 교차를 감지하지 못한다. - * (CrossedDown(N+1) → CCI(N) < threshold, CCI(N+1) < threshold → 교차 없음) - * - *

이 메서드를 {@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 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 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()); diff --git a/backend/src/main/java/com/goldenchart/service/StrategyDslToTa4jAdapter.java b/backend/src/main/java/com/goldenchart/service/StrategyDslToTa4jAdapter.java index 7d3b5ea..11ba8bf 100644 --- a/backend/src/main/java/com/goldenchart/service/StrategyDslToTa4jAdapter.java +++ b/backend/src/main/java/com/goldenchart/service/StrategyDslToTa4jAdapter.java @@ -111,18 +111,44 @@ public class StrategyDslToTa4jAdapter { Map> indicatorParams, Map> indicatorVisual, String market, - Ta4jStorage storage + Ta4jStorage storage, + /** + * CANDLE_CLOSE 평가 시 true — CrossSeriesRule 이 상위봉의 마지막 확정봉 + * (endIndex - 1)을 사용하도록 강제한다. provisional 봉 오염을 방지. + */ + boolean useConfirmedOnly, + /** + * 백테스트용 사전 집계 시리즈 맵 candleType→BarSeries. + * null 이 아니고 비어 있지 않으면 백테스트 모드로 동작하며 + * CrossSeriesRule 에서 타임스탬프 기반 룩업을 수행한다. + */ + Map seriesOverrides ) { - /** visual 미전달 호환 */ + /** visual 미전달 호환 (레거시) */ public RuleBuildContext(BarSeries primarySeries, Map> 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> 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> 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> indicatorParams, Map> 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> indicatorParams, + Map 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> indicatorParams, + Map> 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 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. + * + *

    + *
  • 백테스트 모드({@code isBacktest=true}): 기본봉 endTime ≤ 기준으로 상위봉 인덱스를 + * 이진탐색하여 정확히 대응하는 확정봉을 사용한다. 라이브와 동일한 멀티 TF 시그널 보장.
  • + *
  • CANDLE_CLOSE 모드({@code useConfirmedOnly=true}): 상위봉의 마지막 확정봉 + * (endIndex - 1)을 사용한다. 진행 중인 provisional 봉을 참조하지 않음.
  • + *
  • REALTIME_TICK 모드(둘 다 false): 상위봉의 {@code getEndIndex()}(현재 봉 포함) + * 를 사용한다. 실시간 지표값 반영.
  • + *
+ */ 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> 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> 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> 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 childRules(JsonNode node, BarSeries series, Map> 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 left = resolveField(leftField, indType, indParams, leftSeries, condPeriod, leftPeriod, cond, ctx); Indicator 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 left, Indicator 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 resolveField(String field, String indType, Map 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) { diff --git a/backend/src/main/java/com/goldenchart/service/StrategyTriggerBranchEvaluator.java b/backend/src/main/java/com/goldenchart/service/StrategyTriggerBranchEvaluator.java index 163514c..6583f5e 100644 --- a/backend/src/main/java/com/goldenchart/service/StrategyTriggerBranchEvaluator.java +++ b/backend/src/main/java/com/goldenchart/service/StrategyTriggerBranchEvaluator.java @@ -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> indicatorParams, Map> 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> indicatorParams, + Map> 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); diff --git a/frontend/src/components/backtest/BacktestAnalysisChart.tsx b/frontend/src/components/backtest/BacktestAnalysisChart.tsx index f8e1dfa..8163257 100644 --- a/frontend/src/components/backtest/BacktestAnalysisChart.tsx +++ b/frontend/src/components/backtest/BacktestAnalysisChart.tsx @@ -35,6 +35,7 @@ import { resolveStrategyPrimaryTimeframe, } from '../../utils/strategyToChartIndicators'; import type { ChartOverlayToggleKey } from '../../utils/chartOverlayVisibility'; +import StrategyOscillatorPanes from './StrategyOscillatorPanes'; interface Props { symbol: string; @@ -154,10 +155,21 @@ const BacktestAnalysisChart: React.FC = ({ return () => { cancelled = true; }; }, [strategyId]); + /** + * overlayVisibility 가 없는 경우(BacktestHistoryPage 컨텍스트)에는 + * 보조지표(오실레이터)를 TradingChart sub-pane 이 아닌 아래쪽 + * StrategyOscillatorPanes 섹션에서 표시한다. + */ + const showOscillatorPanel = !overlayVisibility; + const baseIndicators = useMemo(() => { let inds: IndicatorConfig[]; if (strategy) { inds = buildVirtualTradingChartIndicators(strategy, getParams, getVisualConfig); + // 오실레이터 패널을 별도 섹션으로 표시할 때는 TradingChart 에서 제거 + if (showOscillatorPanel) { + inds = inds.filter(i => isOverlayType(i.type)); + } inds = inds.filter(i => i.timeframeVisibility?.[chartTimeframe] !== false); } else { inds = defaultOverlayIndicators(getParams); @@ -166,7 +178,7 @@ const BacktestAnalysisChart: React.FC = ({ inds = ensurePaperChartOverlays(inds, getParams, getVisualConfig); } return inds; - }, [strategy, getParams, getVisualConfig, chartTimeframe, overlayVisibility]); + }, [strategy, getParams, getVisualConfig, chartTimeframe, overlayVisibility, showOscillatorPanel]); const indicators = useMemo(() => { let inds = overlayVisibility @@ -455,6 +467,9 @@ const BacktestAnalysisChart: React.FC = ({
표시할 캔들 데이터가 없습니다.
)} + {showOscillatorPanel && !loading && bars.length > 0 && ( + + )} ); diff --git a/frontend/src/contexts/TradeNotificationContext.tsx b/frontend/src/contexts/TradeNotificationContext.tsx index 8a82a67..331d08a 100644 --- a/frontend/src/contexts/TradeNotificationContext.tsx +++ b/frontend/src/contexts/TradeNotificationContext.tsx @@ -113,6 +113,16 @@ function saveHiddenIds(ids: Set) { 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 { const ctx = useContext(TradeNotificationContext); if (!ctx) { @@ -157,6 +167,12 @@ export const TradeNotificationProvider: React.FC = ({ * 같은 틱에 수신된 신호 ID 까지 readIds 에 포함시킬 수 있다. */ const allSeenIdsRef = useRef>(new Set()); + /** + * 전체닫기·삭제 실행 시각 (epoch 초). + * candleTime < suppressBefore 인 오래된 신호는 팝업을 띄우지 않는다. + * 백엔드 backfill 이 STOMP 로 수십 일치 과거 신호를 재전송해도 무시한다. + */ + const suppressBeforeRef = useRef(loadSuppressBefore()); const [detailSignal, setDetailSignal] = useState(null); const [detailCentered, setDetailCentered] = useState(false); @@ -176,7 +192,29 @@ export const TradeNotificationProvider: React.FC = ({ */ const cacheRead = loadReadIds(); 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() : loadHiddenIds(); const items = dtos .map(d => dtoToItem(d, read)) @@ -252,6 +290,8 @@ export const TradeNotificationProvider: React.FC = ({ readIdsRef.current = merged; setReadIds(merged); } + // 전체닫기·삭제 시각 복원 (재접속 후에도 오래된 신호 팝업 방지) + suppressBeforeRef.current = loadSuppressBefore(); setToastNotifications([]); void refreshHistory({ serverOnly: true, rawServerList: true }); }, [refreshHistory, settingsLoaded, settingsSessionKey]); @@ -276,19 +316,29 @@ export const TradeNotificationProvider: React.FC = ({ // 수신한 모든 ID 를 동기적으로 누적 (dismissAllToasts 가 참조) allSeenIdsRef.current.add(id); + /** + * ── 오래된 신호 억제 (핵심 필터) ─────────────────────────────────────── + * 전체닫기·삭제 시각(suppressBefore) 이전에 시작된 캔들의 신호는 + * 백엔드 backfill 이 STOMP 로 재전송해도 팝업을 띄우지 않는다. + * candleTime 은 epoch 초 단위이고 suppressBefore 도 epoch 초이다. + */ + const isSuppressed = + suppressBeforeRef.current > 0 && + signal.candleTime < suppressBeforeRef.current; + /** * 읽음 여부 3중 확인: * 1. readIdsRef.current — dismissAllToasts 가 즉시 업데이트하는 인메모리 ref * 2. loadReadIds() — DB 캐시 기반 값 - * 3. allNotificationsRef — dismissAllToasts 의 functional updater 가 - * isRead:true 를 예약했지만 readIdsRef 에 아직 반영되지 않은 경우 커버 + * 3. allNotificationsRef — functional updater 가 isRead:true 예약 후 ref 미반영 커버 */ const alreadyRead = + isSuppressed || readIdsRef.current.has(id) || loadReadIds().has(id) || (allNotificationsRef.current.find(n => n.id === id)?.isRead === true); - // readIds 에 누락된 읽음 항목 보정 (다음 STOMP/폴링에서 즉시 필터링) + // readIds 에 누락된 읽음 항목 보정 if (alreadyRead && !readIdsRef.current.has(id)) { const next = new Set(readIdsRef.current); next.add(id); @@ -389,6 +439,11 @@ export const TradeNotificationProvider: React.FC = ({ ...allSeenIdsRef.current, // 동일 틱에 수신됐지만 ref 미반영 ID ]); + // 전체닫기 시각 기록 — 이 시각 이전 candleTime 신호는 이후 팝업 금지 + const nowSec = Math.floor(Date.now() / 1000); + suppressBeforeRef.current = nowSec; + saveSuppressBefore(nowSec, true); + // 1) 인메모리 ref 즉시 갱신 (다음 addNotification 호출에서 즉시 참조) readIdsRef.current = nextRead; @@ -490,18 +545,21 @@ export const TradeNotificationProvider: React.FC = ({ ]), ]; + // 전체 삭제 시각 기록 — 이 시각 이전 candleTime 신호는 이후 팝업 금지 + const nowSec = Math.floor(Date.now() / 1000); + suppressBeforeRef.current = nowSec; + saveSuppressBefore(nowSec, true); + /** - * readIds 를 new Set() 으로 초기화하지 않는다. - * 초기화하면 백엔드가 삭제된 신호를 STOMP 로 재전송할 때 - * readIds 에 없어서 팝업이 다시 뜨는 버그가 발생한다. - * 대신 삭제된 ID 를 readIds 에 추가하여 재전송 신호를 필터링한다. + * readIds 에 삭제된 ID 를 추가 (초기화 X). + * 백엔드가 동일 신호를 STOMP 로 재전송해도 suppressBefore + readIds 이중 필터. */ const nextRead = new Set([...readIdsRef.current, ...ids]); readIdsRef.current = nextRead; setReadIds(nextRead); saveReadIds(nextRead, true); - // hiddenIds 는 초기화 (전체 삭제 후 목록 완전히 비움) + // hiddenIds 초기화 (전체 삭제 후 목록 완전히 비움) saveHiddenIds(new Set()); setToastNotifications([]); diff --git a/frontend/src/types/uiPreferences.ts b/frontend/src/types/uiPreferences.ts index dc5269e..a46de0f 100644 --- a/frontend/src/types/uiPreferences.ts +++ b/frontend/src/types/uiPreferences.ts @@ -33,6 +33,12 @@ export interface UiPreferences { tradeNotifications?: { readIds?: string[]; hiddenIds?: string[]; + /** + * 전체닫기·전체삭제 실행 시각 (epoch 초). + * candleTime < suppressBefore 인 신호는 팝업 표시하지 않는다. + * 백엔드 backfill 로 오래된 신호가 STOMP 재전송되어도 팝업이 뜨지 않게 한다. + */ + suppressBefore?: number; /** 매매 시그널 알림 목록 화면 — list | grid */ listLayout?: 'list' | 'grid'; /** 그리드형 열 배치 프리셋 id (tradeNotificationGridPresets) */ diff --git a/전략편집기 로직 및 동작흐름.md b/전략편집기 로직 및 동작흐름.md index 1f10b53..a56ffcb 100644 --- a/전략편집기 로직 및 동작흐름.md +++ b/전략편집기 로직 및 동작흐름.md @@ -438,17 +438,22 @@ flowchart LR **조건:** `executionType == REALTIME_TICK` 등 동일 -**평가:** `evaluateRealtimeTick(market, candleType)` +**평가:** `evaluateSettingRealtimeTick(setting, market, candleType)` - `currentIndex = series.getEndIndex()` — **진행 중(provisional) 봉** -- **중복 방지:** 동일 `(market, candleType)`에서 같은 index에 이미 시그널 냈으면 skip -- 평가 전 **strategy 캐시 제거** — `updateLastBar` 후 지표 캐시 stale 방지 +- **중복 방지:** 동일 `(market, candleType:strategyId)`에서 같은 index에 이미 시그널 냈으면 skip +- **Rule 캐시 완전 우회:** 매 평가마다 신규 Rule 인스턴스 빌드 (`evaluateWithFreshRules`) + - Ta4j `CachedIndicator` stale 캐시 원천 차단 + - `triggerRulesCache` 복합 연산(remove→put) 스레드 안전성 문제 제거 +- **멀티 TF:** `useConfirmedOnly=false` — 비트리거 상위봉 현재 진행 중인 봉 기준 평가 ### 9.5 보완 — 마감 직후 REALTIME 재평가 -`BarBuilder`: `CANDLE_CLOSE`가 `NONE`이면 → `evaluateRealtimeAtClose(maturedIndex)` +봉 마감 시 → `evaluateSettingRealtimeAtClose(setting, market, candleType, maturedIndex)` - 봉 마감 시점의 **교차(CROSS)** 를 3초 스케줄러가 놓치는 경우 대비 +- `useConfirmedOnly=true` — 확정봉 인덱스 기준 + 상위봉도 확정봉 사용 +- Rule 캐시 우회 ### 9.6 평가 흐름 (단일 종목·단일 시점) @@ -457,24 +462,29 @@ flowchart TD A[트리거: 봉 마감 또는 3초 틱] B{isLiveCheck?} C{usesTimeframe?} - D[LiveStrategyEvaluator.evaluate] - E[buildStrategy - DSL to Ta4j] + D1[CANDLE_CLOSE: evaluate - 캐시 사용 - useConfirmedOnly=true] + D2[REALTIME_TICK: evaluateWithFreshRules - 캐시 우회 - useConfirmedOnly=false] + E[buildTriggerRules - DSL to Ta4j with CrossSeriesRule] F[StrategySignalDeterminer.determineSignal] G{결과} - H[publishStrategySignal] + H[설정별 독립 publishStrategySignal] I[NONE - 종료] A --> B B -->|false| I B -->|true| C C -->|false| I - C -->|true| D - D --> E --> F --> G + C -->|CANDLE_CLOSE| D1 + C -->|REALTIME_TICK| D2 + D1 --> E --> F --> G + D2 --> E G -->|BUY/SELL| H G -->|NONE| I ``` -**주의:** 동일 마켓에 활성 설정이 여러 개면 evaluator는 **첫 non-NONE 시그널만** 반환할 수 있음. +**주의:** `BarCloseStrategyEvaluationService` / `LiveStrategyScheduler` 는 **설정별 독립 루프**로 +멀티 전략 시그널을 각각 발행한다. `evaluateCandleClose(market)`, `evaluateRealtimeTick(market)` 마켓 단위 API 는 +첫 non-NONE 만 반환하므로 실시간 시그널 발행 경로에서 사용 금지 (`@Deprecated`). --- @@ -533,7 +543,9 @@ flowchart TD | 항목 | 백테스트 (`BacktestingService`) | 실시간 (`LiveStrategyEvaluator`) | |------|--------------------------------|----------------------------------| | 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) | | 청산 | DSL sell + **손절/익절/트레일링** OR 합성 | DSL sell (+ 리스크 모니터 별도) | | 출력 | `BacktestResponse.signals`, `gc_backtest_result` | STOMP + `gc_trade_signal` |