알림팝업 수정, 전략평가 로직 개선
This commit is contained in:
@@ -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;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user