185 lines
7.5 KiB
Java
185 lines
7.5 KiB
Java
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<OhlcvBar> 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<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);
|
|
}
|
|
}
|
|
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<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()) {
|
|
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<Long, List<Bar>> 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<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("[OhlcvBarSeries] aggregate fail tf={} bucket={}: {}", targetTf, entry.getKey(), e.getMessage());
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
}
|