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

This commit is contained in:
Macbook
2026-06-05 10:32:40 +09:00
parent 238dd0cebe
commit 5278177bbb
9 changed files with 484 additions and 116 deletions
@@ -85,9 +85,14 @@ public class BacktestingService {
int n = series.getBarCount();
if (n == 0) return emptyResponse("유효한 캔들 데이터가 없습니다.");
Rule entryRule = adapter.toRule(buyDsl, series, params);
// ── 멀티 타임프레임 지원: DSL에서 상위봉 타입 추출 후 집계 시리즈 빌드 ──────
String primaryTf = normalizeTf(req.getTimeframe());
Map<String, BarSeries> seriesOverrides = buildSeriesOverrides(
series, primaryTf, buyDsl, sellDsl);
Rule entryRule = adapter.toRule(buyDsl, series, params, seriesOverrides);
Rule baseExitRule = (sellDsl != null && !sellDsl.isNull())
? adapter.toRule(sellDsl, series, params)
? adapter.toRule(sellDsl, series, params, seriesOverrides)
: new BooleanRule(false);
Rule exitRule = buildExitRule(baseExitRule, series, cfg);
@@ -532,18 +537,139 @@ public class BacktestingService {
private static Duration timeframeToDuration(String tf) {
if (tf == null) return Duration.ofMinutes(1);
return switch (tf) {
case "1m" -> Duration.ofMinutes(1);
case "3m" -> Duration.ofMinutes(3);
case "5m" -> Duration.ofMinutes(5);
case "10m" -> Duration.ofMinutes(10);
case "15m" -> Duration.ofMinutes(15);
case "30m" -> Duration.ofMinutes(30);
case "1h" -> Duration.ofHours(1);
case "4h" -> Duration.ofHours(4);
case "1D" -> Duration.ofDays(1);
case "1W" -> Duration.ofDays(7);
case "1M" -> Duration.ofDays(30);
default -> Duration.ofMinutes(1);
case "1m" -> Duration.ofMinutes(1);
case "3m" -> Duration.ofMinutes(3);
case "5m" -> Duration.ofMinutes(5);
case "10m" -> Duration.ofMinutes(10);
case "15m" -> Duration.ofMinutes(15);
case "30m" -> Duration.ofMinutes(30);
case "1h" -> Duration.ofHours(1);
case "4h" -> Duration.ofHours(4);
case "1d", "1D" -> Duration.ofDays(1);
case "1w", "1W" -> Duration.ofDays(7);
case "1M" -> Duration.ofDays(30);
default -> Duration.ofMinutes(1);
};
}
// ── 멀티 타임프레임 지원 ─────────────────────────────────────────────────
/**
* 기본봉 시리즈 + DSL 에서 추출한 상위봉 집계 시리즈로 seriesOverrides 맵을 구성한다.
* 백테스트에서 라이브와 동일한 멀티 TF 시그널 평가를 보장한다.
*/
private Map<String, BarSeries> buildSeriesOverrides(BarSeries primarySeries, String primaryTf,
JsonNode buyDsl, JsonNode sellDsl) {
Set<String> requiredTfs = new LinkedHashSet<>();
collectTimeframesFromDsl(buyDsl, requiredTfs);
collectTimeframesFromDsl(sellDsl, requiredTfs);
requiredTfs.remove(primaryTf);
Map<String, BarSeries> overrides = new LinkedHashMap<>();
overrides.put(primaryTf, primarySeries);
for (String tf : requiredTfs) {
Duration targetDur = timeframeToDuration(tf);
Duration primaryDur = timeframeToDuration(primaryTf);
if (targetDur.compareTo(primaryDur) <= 0) continue; // 같거나 하위봉은 기본 시리즈 사용
BarSeries aggregated = aggregateSeries(primarySeries, primaryDur, targetDur, tf);
if (aggregated.getBarCount() > 0) {
overrides.put(tf, aggregated);
log.info("[Backtest] 멀티 TF 집계: {} bars → {} ({}봉)", primaryTf, tf, aggregated.getBarCount());
}
}
return overrides;
}
/**
* DSL 트리를 재귀 탐색하여 TIMEFRAME 노드의 candleType 값을 수집한다.
*/
private void collectTimeframesFromDsl(JsonNode node, Set<String> result) {
if (node == null || node.isNull()) return;
String type = node.path("type").asText("");
if ("TIMEFRAME".equals(type)) {
String ct = node.path("candleType").asText("");
if (!ct.isBlank()) result.add(normalizeTf(ct));
}
JsonNode children = node.path("children");
if (children.isArray()) {
for (JsonNode child : children) collectTimeframesFromDsl(child, result);
}
JsonNode child = node.path("child");
if (!child.isMissingNode() && !child.isNull()) collectTimeframesFromDsl(child, result);
JsonNode cond = node.path("condition");
if (!cond.isMissingNode() && !cond.isNull()) {
// 복합지표 leftCandleType / rightCandleType
String leftCt = cond.path("leftCandleType").asText("");
String rightCt = cond.path("rightCandleType").asText("");
if (!leftCt.isBlank()) result.add(normalizeTf(leftCt));
if (!rightCt.isBlank()) result.add(normalizeTf(rightCt));
}
}
/**
* primarySeries(하위봉) → targetDur(상위봉) 으로 OHLCV 집계.
* 각 타깃 봉 버킷에 속하는 하위봉들을 그룹핑하여 새 BarSeries 를 생성한다.
*/
private BarSeries aggregateSeries(BarSeries primary, Duration primaryDur,
Duration targetDur, String targetTf) {
long primarySecs = primaryDur.getSeconds();
long targetSecs = targetDur.getSeconds();
BarSeries result = new BaseBarSeriesBuilder()
.withNumFactory(org.ta4j.core.num.DoubleNumFactory.getInstance())
.build();
TimeBarBuilderFactory factory = new TimeBarBuilderFactory();
// 하위봉을 상위봉 버킷별로 그룹핑 (버킷 시작 epoch 초 → 봉 목록)
Map<Long, List<Bar>> grouped = new LinkedHashMap<>();
for (int i = primary.getBeginIndex(); i <= primary.getEndIndex(); i++) {
Bar bar = primary.getBar(i);
// bar.endTime = 봉 종료 시각 → 봉 시작 = endTime - primaryDur
long barStartSec = bar.getEndTime().getEpochSecond() - primarySecs;
long bucketStart = (barStartSec / targetSecs) * targetSecs;
grouped.computeIfAbsent(bucketStart, k -> new ArrayList<>()).add(bar);
}
for (Map.Entry<Long, List<Bar>> entry : grouped.entrySet()) {
List<Bar> bars = entry.getValue();
if (bars.isEmpty()) continue;
Instant bucketEnd = Instant.ofEpochSecond(entry.getKey()).plus(targetDur);
double open = bars.get(0).getOpenPrice().doubleValue();
double high = bars.stream().mapToDouble(b -> b.getHighPrice().doubleValue()).max().orElse(open);
double low = bars.stream().mapToDouble(b -> b.getLowPrice().doubleValue()).min().orElse(open);
double close = bars.get(bars.size() - 1).getClosePrice().doubleValue();
double volume = bars.stream().mapToDouble(b -> b.getVolume().doubleValue()).sum();
try {
factory.createBarBuilder(result)
.timePeriod(targetDur).endTime(bucketEnd)
.openPrice(open).highPrice(high).lowPrice(low)
.closePrice(close).volume(volume).add();
} catch (Exception e) {
log.debug("[Backtest] 상위봉 집계 실패 (tf={} bucket={}): {}", targetTf, entry.getKey(), e.getMessage());
}
}
return result;
}
/** timeframe 문자열 정규화 (1m, 3m, 5m ... 1h, 4h, 1d, 1w) */
private static String normalizeTf(String tf) {
if (tf == null) return "1m";
return switch (tf.toLowerCase()) {
case "1", "1m" -> "1m";
case "3", "3m" -> "3m";
case "5", "5m" -> "5m";
case "10", "10m" -> "10m";
case "15", "15m" -> "15m";
case "30", "30m" -> "30m";
case "60", "1h" -> "1h";
case "240", "4h" -> "4h";
case "1d", "d" -> "1d";
case "1w", "w" -> "1w";
default -> tf;
};
}