package com.goldenchart.service; import com.fasterxml.jackson.databind.JsonNode; import com.goldenchart.dto.OhlcvBar; import lombok.extern.slf4j.Slf4j; import org.ta4j.core.Bar; import org.ta4j.core.BarSeries; import org.ta4j.core.BaseBarSeriesBuilder; import org.ta4j.core.bars.TimeBarBuilderFactory; import java.time.Duration; import java.time.Instant; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Set; /** * 차트·백테스트 공통 — OHLCV → Ta4j BarSeries 및 멀티 TF overrides. */ @Slf4j final class OhlcvBarSeriesSupport { private OhlcvBarSeriesSupport() {} static BarSeries buildSeries(List bars, String timeframe) { BarSeries series = new BaseBarSeriesBuilder() .withNumFactory(org.ta4j.core.num.DoubleNumFactory.getInstance()) .build(); TimeBarBuilderFactory factory = new TimeBarBuilderFactory(); Duration period = timeframeToDuration(timeframe); for (OhlcvBar b : bars) { Instant endInst = Instant.ofEpochSecond(b.getTime()).plus(period); factory.createBarBuilder(series) .timePeriod(period).endTime(endInst) .openPrice(b.getOpen()).highPrice(b.getHigh()) .lowPrice(b.getLow()).closePrice(b.getClose()) .volume(b.getVolume()).add(); } return series; } static 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); } } return overrides; } static int resolveBarIndex(BarSeries series, Long barTimeSec) { if (barTimeSec == null) return series.getEndIndex(); int bestIdx = series.getEndIndex(); long bestDist = Long.MAX_VALUE; for (int i = series.getBeginIndex(); i <= series.getEndIndex(); i++) { long openSec = barOpenEpochSec(series, i); long dist = Math.abs(openSec - barTimeSec); if (dist < bestDist) { bestDist = dist; bestIdx = i; } } return bestIdx; } static long barOpenEpochSec(BarSeries series, int index) { var bar = series.getBar(index); return bar.getEndTime().getEpochSecond() - bar.getTimePeriod().getSeconds(); } 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 static Duration timeframeToDuration(String tf) { if (tf == null) return Duration.ofMinutes(1); return switch (normalizeTf(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); default -> Duration.ofMinutes(1); }; } private static void collectTimeframesFromDsl(JsonNode node, Set result) { if (node == null || node.isNull()) return; String type = node.path("type").asText(""); if ("TIMEFRAME".equals(type)) { JsonNode types = node.path("candleTypes"); if (types.isArray()) { for (JsonNode t : types) { String ct = t.asText(""); if (!ct.isBlank()) result.add(normalizeTf(ct)); } } 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()) { 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)); } } private static 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(); Map> grouped = new LinkedHashMap<>(); for (int i = primary.getBeginIndex(); i <= primary.getEndIndex(); i++) { Bar bar = primary.getBar(i); 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("[OhlcvBarSeries] aggregate fail tf={} bucket={}: {}", targetTf, entry.getKey(), e.getMessage()); } } return result; } }