알림팝업 수정, 전략평가 로직 개선
This commit is contained in:
@@ -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);
|
||||||
|
|
||||||
@@ -540,13 +545,134 @@ public class BacktestingService {
|
|||||||
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;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private BacktestResponse emptyResponse(String reason) {
|
private BacktestResponse emptyResponse(String reason) {
|
||||||
log.warn("[BacktestingService] 빈 결과: {}", reason);
|
log.warn("[BacktestingService] 빈 결과: {}", reason);
|
||||||
BacktestAnalysisDto emptyAnalysis = BacktestAnalysisDto.builder().build();
|
BacktestAnalysisDto emptyAnalysis = BacktestAnalysisDto.builder().build();
|
||||||
|
|||||||
@@ -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([]);
|
||||||
|
|||||||
@@ -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
@@ -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` |
|
||||||
|
|||||||
Reference in New Issue
Block a user