전략평가 화면 수정

This commit is contained in:
Macbook
2026-06-12 15:32:00 +09:00
parent 8aae8489ae
commit b560b9de5c
10 changed files with 413 additions and 13 deletions
@@ -5,6 +5,7 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.goldenchart.dto.LiveConditionRowDto;
import com.goldenchart.dto.LiveConditionStatusDto;
import com.goldenchart.dto.OhlcvBar;
import com.goldenchart.entity.GcStrategy;
import com.goldenchart.repository.GcStrategyRepository;
import com.goldenchart.storage.Ta4jStorage;
@@ -68,6 +69,16 @@ public class LiveConditionStatusService {
String market, long strategyId,
Long barTimeSec,
Map<String, Map<String, Object>> indicatorParamsOverride) {
return evaluate(userId, deviceId, market, strategyId, barTimeSec, indicatorParamsOverride, null, null);
}
@Transactional(readOnly = true)
public LiveConditionStatusDto evaluate(Long userId, String deviceId,
String market, long strategyId,
Long barTimeSec,
Map<String, Map<String, Object>> indicatorParamsOverride,
List<OhlcvBar> chartBars,
String chartTimeframe) {
Optional<GcStrategy> opt = strategyRepo.findById(strategyId);
if (opt.isEmpty()) {
return empty(market, strategyId);
@@ -89,6 +100,11 @@ public class LiveConditionStatusService {
return empty(market, strategyId);
}
if (chartBars != null && !chartBars.isEmpty() && chartTimeframe != null && !chartTimeframe.isBlank()) {
return evaluateOnChartBars(strategy, market, strategyId, pending, chartBars, chartTimeframe,
barTimeSec, params, visual);
}
// 마켓 구독 + warm-up — 과거 봉 평가 시 더 많은 이력 확보
int warmupBars = barTimeSec != null ? 500 : 60;
Set<String> timeframes = new LinkedHashSet<>();
@@ -191,6 +207,137 @@ public class LiveConditionStatusService {
return bar.getEndTime().getEpochSecond() - bar.getTimePeriod().getSeconds();
}
/**
* 차트·백테스트와 동일 OHLCV 로 조건 평가 — Ta4jStorage 와의 불일치 방지.
*/
private LiveConditionStatusDto evaluateOnChartBars(
GcStrategy strategy,
String market,
long strategyId,
List<PendingCond> pending,
List<OhlcvBar> chartBars,
String chartTimeframe,
Long barTimeSec,
Map<String, Map<String, Object>> params,
Map<String, Map<String, Object>> visual) {
try {
JsonNode buyDsl = objectMapper.readTree(
strategy.getBuyConditionJson() != null ? strategy.getBuyConditionJson() : "null");
JsonNode sellDsl = objectMapper.readTree(
strategy.getSellConditionJson() != null ? strategy.getSellConditionJson() : "null");
String primaryTf = OhlcvBarSeriesSupport.normalizeTf(chartTimeframe);
BarSeries primarySeries = OhlcvBarSeriesSupport.buildSeries(chartBars, primaryTf);
if (primarySeries.isEmpty()) {
return empty(market, strategyId);
}
Map<String, BarSeries> seriesOverrides = OhlcvBarSeriesSupport.buildSeriesOverrides(
primarySeries, primaryTf, buyDsl, sellDsl);
List<LiveConditionRowDto> rows = new ArrayList<>();
int met = 0;
int evaluable = 0;
Integer primaryEvalIndex = OhlcvBarSeriesSupport.resolveBarIndex(primarySeries, barTimeSec);
for (PendingCond p : pending) {
String tf = LiveStrategyTimeframeService.normalize(p.timeframe());
BarSeries series = resolveChartSeries(tf, primaryTf, primarySeries, seriesOverrides, market);
if (series == null || series.isEmpty()) {
rows.add(toRowUnevaluated(p, visual));
continue;
}
int index = OhlcvBarSeriesSupport.resolveBarIndex(series, barTimeSec);
try {
ObjectNode wrapper = objectMapper.createObjectNode();
wrapper.put("type", "CONDITION");
wrapper.set("condition", p.condition());
StrategyDslToTa4jAdapter.RuleBuildContext ctx =
new StrategyDslToTa4jAdapter.RuleBuildContext(
series, params, visual, market, ta4jStorage, false, seriesOverrides);
Rule rule = adapter.toRule(wrapper, ctx);
boolean satisfied = rule.isSatisfied(index, null);
Double current = adapter.readConditionFieldValue(
p.condition(), series, params, index, true);
Double target = readTargetNumeric(p.condition(), visual);
if (target == null) {
target = adapter.readConditionFieldValue(
p.condition(), series, params, index, false);
}
rows.add(toRow(p, current, target, satisfied, series, params, index));
evaluable++;
if (satisfied) met++;
} catch (Exception e) {
log.debug("[LiveCondition:chart] eval fail {} {}: {}", market, p.id(), e.getMessage());
rows.add(toRowUnevaluated(p, visual));
}
}
int matchRate = evaluable > 0 ? Math.round(100f * met / evaluable) : 0;
Boolean overallEntryMet = evaluateOverallRuleOnChart(
buyDsl, primarySeries, seriesOverrides, market, params, visual, barTimeSec);
Boolean overallExitMet = evaluateOverallRuleOnChart(
sellDsl, primarySeries, seriesOverrides, market, params, visual, barTimeSec);
return LiveConditionStatusDto.builder()
.market(market)
.strategyId(strategyId)
.timeframe(primaryTf)
.matchRate(matchRate)
.overallEntryMet(overallEntryMet)
.overallExitMet(overallExitMet)
.rows(rows)
.updatedAt(System.currentTimeMillis())
.barTimeSec(barTimeSec)
.evalBarIndex(primaryEvalIndex)
.build();
} catch (Exception e) {
log.warn("[LiveCondition:chart] evaluate fail market={}: {}", market, e.getMessage());
return empty(market, strategyId);
}
}
private BarSeries resolveChartSeries(String tf, String primaryTf, BarSeries primarySeries,
Map<String, BarSeries> overrides, String market) {
String normalized = OhlcvBarSeriesSupport.normalizeTf(tf);
if (overrides.containsKey(normalized)) {
return overrides.get(normalized);
}
if (normalized.equals(primaryTf)) {
return primarySeries;
}
if (ta4jStorage.exists(market, normalized)) {
return ta4jStorage.getOrCreate(market, normalized);
}
return primarySeries;
}
private Boolean evaluateOverallRuleOnChart(JsonNode dsl, BarSeries primarySeries,
Map<String, BarSeries> seriesOverrides,
String market,
Map<String, Map<String, Object>> params,
Map<String, Map<String, Object>> visual,
Long barTimeSec) {
if (dsl == null || dsl.isNull()) return null;
try {
String primaryTf = extractPrimaryTimeframe(dsl);
BarSeries series = resolveChartSeries(primaryTf, OhlcvBarSeriesSupport.normalizeTf(primaryTf),
primarySeries, seriesOverrides, market);
if (series == null || series.isEmpty()) return null;
StrategyDslToTa4jAdapter.RuleBuildContext ctx =
new StrategyDslToTa4jAdapter.RuleBuildContext(
series, params, visual, market, ta4jStorage, false, seriesOverrides);
Rule rule = adapter.toRule(dsl, ctx);
int index = OhlcvBarSeriesSupport.resolveBarIndex(series, barTimeSec);
return rule.isSatisfied(index, null);
} catch (Exception e) {
log.debug("[LiveCondition:chart] overall rule fail market={}: {}", market, e.getMessage());
return null;
}
}
private LiveConditionStatusDto empty(String market, long strategyId) {
return LiveConditionStatusDto.builder()
.market(market)
@@ -0,0 +1,184 @@
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;
}
}