785 lines
36 KiB
Java
785 lines
36 KiB
Java
package com.goldenchart.service;
|
|
|
|
import com.fasterxml.jackson.databind.JsonNode;
|
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
|
import com.fasterxml.jackson.databind.node.ObjectNode;
|
|
import com.goldenchart.dto.BacktestResponse;
|
|
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;
|
|
import com.goldenchart.websocket.DynamicSubscriptionManager;
|
|
import lombok.RequiredArgsConstructor;
|
|
import lombok.extern.slf4j.Slf4j;
|
|
import org.springframework.stereotype.Service;
|
|
import org.ta4j.core.BarSeries;
|
|
import org.ta4j.core.Rule;
|
|
import org.springframework.transaction.annotation.Transactional;
|
|
|
|
import java.util.*;
|
|
|
|
/**
|
|
* 가상투자 — 종목×전략별 조건 충족 현황 (Ta4j Rule.isSatisfied).
|
|
*/
|
|
@Service
|
|
@RequiredArgsConstructor
|
|
@Slf4j
|
|
public class LiveConditionStatusService {
|
|
|
|
private static final Map<String, String> CONDITION_LABEL = Map.ofEntries(
|
|
Map.entry("GT", "초과(>)"),
|
|
Map.entry("LT", "미만(<)"),
|
|
Map.entry("GTE", "이상(≥)"),
|
|
Map.entry("LTE", "이하(≤)"),
|
|
Map.entry("EQ", "같음(=)"),
|
|
Map.entry("NEQ", "다름(≠)"),
|
|
Map.entry("CROSS_UP", "상향 돌파"),
|
|
Map.entry("CROSS_DOWN", "하향 돌파"),
|
|
Map.entry("SLOPE_UP", "상승 중"),
|
|
Map.entry("LOWEST_LTE", "N봉 최저 ≤"),
|
|
Map.entry("LOWEST_GTE", "N봉 최저 ≥"),
|
|
Map.entry("SLOPE_DOWN", "하락 중")
|
|
);
|
|
|
|
private record PendingCond(String id, JsonNode condition, String timeframe, String side) {}
|
|
|
|
private final GcStrategyRepository strategyRepo;
|
|
private final Ta4jStorage ta4jStorage;
|
|
private final StrategyDslToTa4jAdapter adapter;
|
|
private final IndicatorSettingsService indicatorSettingsService;
|
|
private final ObjectMapper objectMapper;
|
|
private final DynamicSubscriptionManager subscriptionManager;
|
|
private final StrategyDslTimeframeNormalizer timeframeNormalizer;
|
|
|
|
@Transactional(readOnly = true)
|
|
public LiveConditionStatusDto evaluate(Long userId, String deviceId,
|
|
String market, long strategyId) {
|
|
return evaluate(userId, deviceId, market, strategyId, null);
|
|
}
|
|
|
|
@Transactional(readOnly = true)
|
|
public LiveConditionStatusDto evaluate(Long userId, String deviceId,
|
|
String market, long strategyId,
|
|
Long barTimeSec) {
|
|
return evaluate(userId, deviceId, market, strategyId, barTimeSec, null);
|
|
}
|
|
|
|
@Transactional(readOnly = true)
|
|
public LiveConditionStatusDto evaluate(Long userId, String deviceId,
|
|
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);
|
|
}
|
|
GcStrategy strategy = opt.get();
|
|
|
|
Map<String, Map<String, Object>> params =
|
|
mergeIndicatorParams(
|
|
indicatorSettingsService.getAll(userId, deviceId),
|
|
indicatorParamsOverride);
|
|
Map<String, Map<String, Object>> visual =
|
|
indicatorSettingsService.getAllVisual(userId, deviceId);
|
|
|
|
List<PendingCond> pending = new ArrayList<>();
|
|
collectConditions(strategy.getBuyConditionJson(), "1m", "buy", pending);
|
|
collectConditions(strategy.getSellConditionJson(), "1m", "sell", pending);
|
|
|
|
if (pending.isEmpty()) {
|
|
return empty(market, strategyId);
|
|
}
|
|
|
|
if (chartBars != null && !chartBars.isEmpty() && chartTimeframe != null && !chartTimeframe.isBlank()) {
|
|
return evaluateOnChartBars(strategy, market, strategyId, chartBars, chartTimeframe,
|
|
barTimeSec, params, visual);
|
|
}
|
|
|
|
// 마켓 구독 + warm-up — 과거 봉 평가 시 더 많은 이력 확보
|
|
int warmupBars = barTimeSec != null ? 500 : 60;
|
|
Set<String> timeframes = new LinkedHashSet<>();
|
|
for (PendingCond p : pending) {
|
|
String tf = LiveStrategyTimeframeService.normalize(p.timeframe());
|
|
timeframes.add(tf);
|
|
subscriptionManager.ensureBarsReadySync(market, tf, warmupBars);
|
|
}
|
|
subscriptionManager.ensureBarsReadySync(market, "1m", warmupBars);
|
|
|
|
List<LiveConditionRowDto> rows = new ArrayList<>();
|
|
int met = 0;
|
|
int evaluable = 0;
|
|
Integer primaryEvalIndex = null;
|
|
|
|
for (PendingCond p : pending) {
|
|
String tf = LiveStrategyTimeframeService.normalize(p.timeframe());
|
|
if (!ta4jStorage.exists(market, tf)) {
|
|
rows.add(toRowUnevaluated(p, visual));
|
|
continue;
|
|
}
|
|
BarSeries series = ta4jStorage.getOrCreate(market, tf);
|
|
if (series.isEmpty()) {
|
|
rows.add(toRowUnevaluated(p, visual));
|
|
continue;
|
|
}
|
|
int index = resolveBarIndex(series, barTimeSec);
|
|
if (primaryEvalIndex == null) primaryEvalIndex = index;
|
|
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, java.util.Map.of());
|
|
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] eval fail {} {}: {}", market, p.id(), e.getMessage());
|
|
rows.add(toRowUnevaluated(p, visual));
|
|
}
|
|
}
|
|
|
|
int matchRate = evaluable > 0 ? Math.round(100f * met / evaluable) : 0;
|
|
String tfSummary = String.join(", ", timeframes);
|
|
|
|
// [항목6] 전체 논리 트리 평가 — matchRate 와 달리 AND/OR/NOT 게이트 완전 반영
|
|
Boolean overallEntryMet = evaluateOverallRule(
|
|
strategy.getBuyConditionJson(), market, params, visual, barTimeSec);
|
|
Boolean overallExitMet = evaluateOverallRule(
|
|
strategy.getSellConditionJson(), market, params, visual, barTimeSec);
|
|
|
|
return LiveConditionStatusDto.builder()
|
|
.market(market)
|
|
.strategyId(strategyId)
|
|
.timeframe(tfSummary)
|
|
.matchRate(matchRate)
|
|
.overallEntryMet(overallEntryMet)
|
|
.overallExitMet(overallExitMet)
|
|
.rows(rows)
|
|
.updatedAt(System.currentTimeMillis())
|
|
.barTimeSec(barTimeSec)
|
|
.evalBarIndex(primaryEvalIndex)
|
|
.build();
|
|
}
|
|
|
|
/**
|
|
* barTimeSec(초)에 가장 가까운 bar index — null 이면 최신 봉.
|
|
* 차트·업비트 API 와 동일하게 봉 시작(open) 시각 기준으로 매칭한다.
|
|
*/
|
|
private 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;
|
|
}
|
|
|
|
/** Ta4j Bar → 차트용 봉 시작 시각(초) — BacktestingService.barStartEpoch 와 동일 */
|
|
private static long barOpenEpochSec(BarSeries series, int index) {
|
|
var bar = series.getBar(index);
|
|
return bar.getEndTime().getEpochSecond() - bar.getTimePeriod().getSeconds();
|
|
}
|
|
|
|
/**
|
|
* 차트·백테스트와 동일 OHLCV 로 조건 평가 — Ta4jStorage 와의 불일치 방지.
|
|
*/
|
|
private LiveConditionStatusDto evaluateOnChartBars(
|
|
GcStrategy strategy,
|
|
String market,
|
|
long strategyId,
|
|
List<OhlcvBar> chartBars,
|
|
String chartTimeframe,
|
|
Long barTimeSec,
|
|
Map<String, Map<String, Object>> params,
|
|
Map<String, Map<String, Object>> visual) {
|
|
try {
|
|
JsonNode buyDslRaw = timeframeNormalizer.normalize(
|
|
objectMapper.readTree(
|
|
strategy.getBuyConditionJson() != null ? strategy.getBuyConditionJson() : "null"),
|
|
strategy.getName());
|
|
JsonNode sellDslRaw = timeframeNormalizer.normalize(
|
|
objectMapper.readTree(
|
|
strategy.getSellConditionJson() != null ? strategy.getSellConditionJson() : "null"),
|
|
strategy.getName());
|
|
|
|
String primaryTf = OhlcvBarSeriesSupport.normalizeTf(chartTimeframe);
|
|
JsonNode buyDsl = timeframeNormalizer.remapForChartTimeframe(buyDslRaw, primaryTf);
|
|
JsonNode sellDsl = timeframeNormalizer.remapForChartTimeframe(sellDslRaw, primaryTf);
|
|
|
|
List<PendingCond> pending = new ArrayList<>();
|
|
collectConditionsFromNode(buyDsl, "1m", "buy", pending);
|
|
collectConditionsFromNode(sellDsl, "1m", "sell", pending);
|
|
|
|
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, null, 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;
|
|
}
|
|
// 차트봉 평가 — overrides 가 있으면 storage 와 혼합하지 않음
|
|
if (!overrides.isEmpty()) {
|
|
return primarySeries;
|
|
}
|
|
if (ta4jStorage.exists(market, normalized)) {
|
|
return ta4jStorage.getOrCreate(market, normalized);
|
|
}
|
|
return primarySeries;
|
|
}
|
|
|
|
/** 백테스트·scan-signals 와 동일 — 마지막 evalCount 봉만 스캔 */
|
|
private static int resolveChartScanStartIndex(BarSeries series, int evalCount) {
|
|
if (series == null || series.isEmpty() || evalCount <= 0) {
|
|
return series != null ? series.getBeginIndex() : 0;
|
|
}
|
|
int total = series.getBarCount();
|
|
if (evalCount >= total) return series.getBeginIndex();
|
|
return series.getEndIndex() - evalCount + 1;
|
|
}
|
|
|
|
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 {
|
|
if (primarySeries == null || primarySeries.isEmpty()) return null;
|
|
|
|
StrategyDslToTa4jAdapter.RuleBuildContext ctx =
|
|
new StrategyDslToTa4jAdapter.RuleBuildContext(
|
|
primarySeries, params, visual, market, null, false, seriesOverrides);
|
|
Rule rule = adapter.toRule(dsl, ctx);
|
|
int index = OhlcvBarSeriesSupport.resolveBarIndex(primarySeries, barTimeSec);
|
|
return rule.isSatisfied(index, null);
|
|
} catch (Exception e) {
|
|
log.debug("[LiveCondition:chart] overall rule fail market={}: {}", market, e.getMessage());
|
|
return null;
|
|
}
|
|
}
|
|
|
|
private void collectConditionsFromNode(JsonNode node, String timeframe, String side,
|
|
List<PendingCond> out) {
|
|
if (node == null || node.isNull()) return;
|
|
walk(node, timeframe, side, out, new HashSet<>());
|
|
}
|
|
|
|
private LiveConditionStatusDto empty(String market, long strategyId) {
|
|
return LiveConditionStatusDto.builder()
|
|
.market(market)
|
|
.strategyId(strategyId)
|
|
.timeframe("")
|
|
.matchRate(0)
|
|
.rows(List.of())
|
|
.updatedAt(System.currentTimeMillis())
|
|
.build();
|
|
}
|
|
|
|
// ── [항목6] 전체 논리 트리 평가 ────────────────────────────────────────────
|
|
|
|
/**
|
|
* 전략 DSL 전체를 Ta4j Rule 로 변환하여 현재 시계열에 대해 평가한다.
|
|
*
|
|
* <p>기존 {@code matchRate} 는 단순 CONDITION 리프 충족 비율이지만,
|
|
* 이 메서드는 AND/OR/NOT 게이트까지 완전히 반영하여 실제 시그널과 동일한
|
|
* 논리 결과를 반환한다.
|
|
*
|
|
* @return {@code true} = 현재 조건 충족, {@code false} = 미충족,
|
|
* {@code null} = 데이터 부족·평가 불가
|
|
*/
|
|
private Boolean evaluateOverallRule(String conditionJson, String market,
|
|
Map<String, Map<String, Object>> params,
|
|
Map<String, Map<String, Object>> visual,
|
|
Long barTimeSec) {
|
|
if (conditionJson == null || conditionJson.isBlank()) return null;
|
|
try {
|
|
com.fasterxml.jackson.databind.JsonNode dsl =
|
|
objectMapper.readTree(conditionJson);
|
|
if (dsl == null || dsl.isNull()) return null;
|
|
|
|
// DSL 루트의 주 시간봉 추출 (TIMEFRAME 노드가 있으면 해당 봉 사용)
|
|
// 5m 전략에서 1m 시리즈로 평가하면 CROSS_UP 등이 부정확해지는 버그 수정
|
|
String primaryTf = extractPrimaryTimeframe(dsl);
|
|
if (!ta4jStorage.exists(market, primaryTf)) {
|
|
// 지정 봉이 없으면 1m으로 폴백
|
|
primaryTf = "1m";
|
|
if (!ta4jStorage.exists(market, "1m")) return null;
|
|
}
|
|
BarSeries series = ta4jStorage.getOrCreate(market, primaryTf);
|
|
if (series.isEmpty()) return null;
|
|
|
|
StrategyDslToTa4jAdapter.RuleBuildContext ctx =
|
|
new StrategyDslToTa4jAdapter.RuleBuildContext(
|
|
series, params, visual, market, ta4jStorage, false, java.util.Map.of());
|
|
org.ta4j.core.Rule rule = adapter.toRule(dsl, ctx);
|
|
return rule.isSatisfied(resolveBarIndex(series, barTimeSec), null);
|
|
} catch (Exception e) {
|
|
log.debug("[LiveCondition] overall rule eval fail market={}: {}", market, e.getMessage());
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* DSL 트리에서 주 시간봉을 추출한다.
|
|
* TIMEFRAME 노드가 있으면 그 candleType을 반환, 없으면 "1m".
|
|
* 복수의 TIMEFRAME이 있으면 가장 길게 등장하는 봉 우선 (다수결).
|
|
*/
|
|
private String extractPrimaryTimeframe(com.fasterxml.jackson.databind.JsonNode dsl) {
|
|
Map<String, Integer> tfCount = new java.util.LinkedHashMap<>();
|
|
collectTimeframes(dsl, tfCount);
|
|
if (tfCount.isEmpty()) return "1m";
|
|
return tfCount.entrySet().stream()
|
|
.max(java.util.Map.Entry.comparingByValue())
|
|
.map(java.util.Map.Entry::getKey)
|
|
.orElse("1m");
|
|
}
|
|
|
|
private void collectTimeframes(com.fasterxml.jackson.databind.JsonNode node,
|
|
Map<String, Integer> out) {
|
|
if (node == null || node.isNull()) return;
|
|
String type = node.path("type").asText("CONDITION");
|
|
if ("TIMEFRAME".equals(type)) {
|
|
String tf = LiveStrategyTimeframeService.normalize(
|
|
node.path("candleType").asText("1m"));
|
|
out.merge(tf, 1, Integer::sum);
|
|
}
|
|
com.fasterxml.jackson.databind.JsonNode children = node.path("children");
|
|
if (children.isArray()) {
|
|
for (com.fasterxml.jackson.databind.JsonNode c : children) collectTimeframes(c, out);
|
|
}
|
|
com.fasterxml.jackson.databind.JsonNode child = node.path("child");
|
|
if (!child.isMissingNode()) collectTimeframes(child, out);
|
|
}
|
|
|
|
private void collectConditions(String json, String timeframe, String side,
|
|
List<PendingCond> out) {
|
|
if (json == null || json.isBlank()) return;
|
|
try {
|
|
walk(objectMapper.readTree(json), timeframe, side, out, new HashSet<>());
|
|
} catch (Exception e) {
|
|
log.warn("[LiveCondition] JSON walk fail: {}", e.getMessage());
|
|
}
|
|
}
|
|
|
|
private void walk(JsonNode node, String timeframe, String side,
|
|
List<PendingCond> out, Set<String> seen) {
|
|
if (node == null || node.isNull()) return;
|
|
// StrategyDslToTa4jAdapter 와 동일하게 type 미설정 시 "CONDITION" 기본값 적용
|
|
String type = node.path("type").asText("CONDITION");
|
|
|
|
if ("TIMEFRAME".equals(type)) {
|
|
String tf = LiveStrategyTimeframeService.normalize(
|
|
node.path("candleType").asText(timeframe));
|
|
JsonNode children = node.path("children");
|
|
if (children.isArray()) {
|
|
for (JsonNode c : children) walk(c, tf, side, out, seen);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if ("AND".equals(type) || "OR".equals(type)) {
|
|
JsonNode children = node.path("children");
|
|
if (children.isArray()) {
|
|
for (JsonNode c : children) walk(c, timeframe, side, out, seen);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if ("NOT".equals(type)) {
|
|
walk(node.path("child"), timeframe, side, out, seen);
|
|
return;
|
|
}
|
|
|
|
if ("CONDITION".equals(type) && node.has("condition")) {
|
|
JsonNode cond = node.path("condition");
|
|
String nodeId = node.path("id").asText("");
|
|
String id = !nodeId.isBlank()
|
|
? nodeId + "-" + side
|
|
: side + ":" + timeframe + ":" + cond.hashCode();
|
|
String key = id + ":" + cond.toString();
|
|
if (seen.add(key)) {
|
|
out.add(new PendingCond(id, cond, timeframe, side));
|
|
}
|
|
return;
|
|
}
|
|
|
|
// START·구버전 DSL 등 — 자식 노드 재귀 (프론트 extractVirtualConditions 와 동일)
|
|
JsonNode children = node.path("children");
|
|
if (children.isArray()) {
|
|
for (JsonNode c : children) walk(c, timeframe, side, out, seen);
|
|
}
|
|
}
|
|
|
|
private LiveConditionRowDto toRowUnevaluated(PendingCond p,
|
|
Map<String, Map<String, Object>> visual) {
|
|
JsonNode c = p.condition();
|
|
String indType = c.path("indicatorType").asText("");
|
|
String plotKey = plotKeyFromCondition(c, indType);
|
|
String condType = c.path("conditionType").asText("GT");
|
|
String baseLabel = CONDITION_LABEL.getOrDefault(condType, condType);
|
|
String rangePrefix = StrategyDslToTa4jAdapter.candleRangeConditionPrefix(c);
|
|
String condLabel = rangePrefix.isBlank() ? baseLabel : rangePrefix + baseLabel;
|
|
Double target = readTargetNumeric(c, visual);
|
|
return LiveConditionRowDto.builder()
|
|
.id(p.id())
|
|
.indicatorType(indType)
|
|
.displayName(indType)
|
|
.conditionType(condType)
|
|
.conditionLabel(condLabel)
|
|
.thresholdLabel(formatThresholdStatic(c, target, visual))
|
|
.currentValue(null)
|
|
.targetValue(target)
|
|
.satisfied(null)
|
|
.timeframe(p.timeframe())
|
|
.side(p.side())
|
|
.plotKey(plotKey)
|
|
.build();
|
|
}
|
|
|
|
private String formatThresholdStatic(JsonNode cond, Double target,
|
|
Map<String, Map<String, Object>> params) {
|
|
if (target != null && !target.isNaN()) {
|
|
String ct = cond.path("conditionType").asText("GT");
|
|
String v = formatNum(target);
|
|
return switch (ct) {
|
|
case "LT", "CROSS_DOWN" -> "< " + v;
|
|
case "GT", "CROSS_UP" -> "> " + v;
|
|
case "GTE" -> "≥ " + v;
|
|
case "LTE" -> "≤ " + v;
|
|
case "EQ" -> "= " + v;
|
|
default -> cond.path("rightField").asText(v);
|
|
};
|
|
}
|
|
String rf = cond.path("rightField").asText("");
|
|
if (rf != null && !rf.isBlank() && !"NONE".equals(rf)) {
|
|
return adapter.formatMovingAverageFieldLabel(rf, params);
|
|
}
|
|
return "—";
|
|
}
|
|
|
|
private LiveConditionRowDto toRow(PendingCond p, Double current, Double target,
|
|
Boolean satisfied, BarSeries series,
|
|
Map<String, Map<String, Object>> params, int index) {
|
|
JsonNode c = p.condition();
|
|
String indType = c.path("indicatorType").asText("");
|
|
String plotKey = plotKeyFromCondition(c, indType);
|
|
String condType = c.path("conditionType").asText("GT");
|
|
String baseLabel = CONDITION_LABEL.getOrDefault(condType, condType);
|
|
String rangePrefix = StrategyDslToTa4jAdapter.candleRangeConditionPrefix(c);
|
|
String condLabel = rangePrefix.isBlank() ? baseLabel : rangePrefix + baseLabel;
|
|
|
|
return LiveConditionRowDto.builder()
|
|
.id(p.id())
|
|
.indicatorType(indType)
|
|
.displayName(indType)
|
|
.conditionType(condType)
|
|
.conditionLabel(condLabel)
|
|
.thresholdLabel(formatThreshold(c, target, series, params, index))
|
|
.currentValue(current)
|
|
.targetValue(target)
|
|
.satisfied(satisfied)
|
|
.timeframe(p.timeframe())
|
|
.side(p.side())
|
|
.plotKey(plotKey)
|
|
.build();
|
|
}
|
|
|
|
private String formatThreshold(JsonNode cond, Double target, BarSeries series,
|
|
Map<String, Map<String, Object>> params, int index) {
|
|
String ct = cond.path("conditionType").asText("GT");
|
|
if (target != null && !target.isNaN()) {
|
|
String v = formatNum(target);
|
|
return switch (ct) {
|
|
case "LT", "CROSS_DOWN" -> "< " + v;
|
|
case "GT", "CROSS_UP" -> "> " + v;
|
|
case "GTE" -> "≥ " + v;
|
|
case "LTE" -> "≤ " + v;
|
|
case "EQ" -> "= " + v;
|
|
default -> cond.path("rightField").asText(v);
|
|
};
|
|
}
|
|
String rf = cond.path("rightField").asText("");
|
|
if (rf != null && !rf.isBlank() && !"NONE".equals(rf)) {
|
|
Double rightLive = adapter.readConditionFieldValue(cond, series, params, index, false);
|
|
if (rightLive != null && !rightLive.isNaN()) {
|
|
String v = formatNum(rightLive);
|
|
String fieldLabel = adapter.formatMovingAverageFieldLabel(rf, params);
|
|
return switch (ct) {
|
|
case "LT", "CROSS_DOWN" -> "< " + v;
|
|
case "GT", "CROSS_UP" -> "> " + v;
|
|
case "GTE" -> "≥ " + v;
|
|
case "LTE" -> "≤ " + v;
|
|
case "EQ" -> "= " + v;
|
|
default -> fieldLabel + " (" + v + ")";
|
|
};
|
|
}
|
|
return adapter.formatMovingAverageFieldLabel(rf, params);
|
|
}
|
|
return "—";
|
|
}
|
|
|
|
private static String formatNum(double v) {
|
|
if (Math.abs(v) >= 1000) return String.format("%.0f", v);
|
|
return String.format("%.2f", v);
|
|
}
|
|
|
|
private Double readTargetNumeric(JsonNode cond, Map<String, Map<String, Object>> visual) {
|
|
String indType = cond.path("indicatorType").asText("");
|
|
String rf = cond.path("rightField").asText("");
|
|
Double resolved = IndicatorHlineResolver.resolveThresholdField(cond, rf, indType, visual);
|
|
if (resolved != null) return resolved;
|
|
if (cond.has("targetValue") && !cond.path("targetValue").isNull()) {
|
|
return cond.path("targetValue").asDouble();
|
|
}
|
|
if (cond.has("compareValue") && !cond.path("compareValue").isNull()) {
|
|
return cond.path("compareValue").asDouble();
|
|
}
|
|
if (rf.startsWith("K_")) {
|
|
try { return Double.parseDouble(rf.substring(2)); } catch (NumberFormatException ignored) {}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
private static String plotKeyFromCondition(JsonNode cond, String indicatorType) {
|
|
String left = cond.path("leftField").asText("");
|
|
if (left != null && !left.isBlank() && !"none".equalsIgnoreCase(left)) {
|
|
return left;
|
|
}
|
|
return indicatorType;
|
|
}
|
|
|
|
private static Map<String, Map<String, Object>> mergeIndicatorParams(
|
|
Map<String, Map<String, Object>> base,
|
|
Map<String, Map<String, Object>> override) {
|
|
if (override == null || override.isEmpty()) return base;
|
|
if (base == null || base.isEmpty()) return override;
|
|
|
|
Map<String, Map<String, Object>> merged = new LinkedHashMap<>(base);
|
|
override.forEach((type, overrideParams) -> {
|
|
Map<String, Object> baseParams = merged.get(type);
|
|
if (baseParams == null || baseParams.isEmpty()) {
|
|
merged.put(type, overrideParams);
|
|
} else {
|
|
Map<String, Object> inner = new LinkedHashMap<>(baseParams);
|
|
inner.putAll(overrideParams);
|
|
merged.put(type, inner);
|
|
}
|
|
});
|
|
return merged;
|
|
}
|
|
|
|
/**
|
|
* 차트 봉 구간을 스캔하여 매수/매도 시그널 목록을 반환한다.
|
|
* {@link #evaluateOnChartBars} 의 {@code overallEntryMet}/{@code overallExitMet} 와
|
|
* 동일 Rule 엔진을 사용하므로 조건 패널 "충족" 과 차트 마커가 일치한다.
|
|
*/
|
|
@Transactional(readOnly = true)
|
|
public List<BacktestResponse.Signal> scanSignalsOnChartBars(
|
|
long userId,
|
|
String deviceId,
|
|
String market,
|
|
long strategyId,
|
|
List<OhlcvBar> chartBars,
|
|
String chartTimeframe,
|
|
Map<String, Map<String, Object>> indicatorParamsOverride,
|
|
Integer evaluationBarCount) {
|
|
Optional<GcStrategy> opt = strategyRepo.findById(strategyId);
|
|
if (opt.isEmpty() || chartBars == null || chartBars.isEmpty()
|
|
|| chartTimeframe == null || chartTimeframe.isBlank()) {
|
|
return List.of();
|
|
}
|
|
GcStrategy strategy = opt.get();
|
|
|
|
Map<String, Map<String, Object>> params =
|
|
mergeIndicatorParams(
|
|
indicatorSettingsService.getAll(userId, deviceId),
|
|
indicatorParamsOverride);
|
|
Map<String, Map<String, Object>> visual =
|
|
indicatorSettingsService.getAllVisual(userId, deviceId);
|
|
|
|
try {
|
|
String strategyName = strategy.getName();
|
|
JsonNode buyDslRaw = timeframeNormalizer.normalize(
|
|
objectMapper.readTree(
|
|
strategy.getBuyConditionJson() != null ? strategy.getBuyConditionJson() : "null"),
|
|
strategyName);
|
|
JsonNode sellDslRaw = timeframeNormalizer.normalize(
|
|
objectMapper.readTree(
|
|
strategy.getSellConditionJson() != null ? strategy.getSellConditionJson() : "null"),
|
|
strategyName);
|
|
|
|
String primaryTf = OhlcvBarSeriesSupport.normalizeTf(chartTimeframe);
|
|
JsonNode buyDsl = timeframeNormalizer.remapForChartTimeframe(buyDslRaw, primaryTf);
|
|
JsonNode sellDsl = timeframeNormalizer.remapForChartTimeframe(sellDslRaw, primaryTf);
|
|
BarSeries primarySeries = OhlcvBarSeriesSupport.buildSeries(chartBars, primaryTf);
|
|
if (primarySeries.isEmpty()) {
|
|
return List.of();
|
|
}
|
|
|
|
Map<String, BarSeries> seriesOverrides = OhlcvBarSeriesSupport.buildSeriesOverrides(
|
|
primarySeries, primaryTf, buyDsl, sellDsl);
|
|
|
|
int evalCount = evaluationBarCount != null && evaluationBarCount > 0
|
|
? evaluationBarCount
|
|
: primarySeries.getBarCount();
|
|
int startIdx = resolveChartScanStartIndex(primarySeries, evalCount);
|
|
|
|
StrategyDslToTa4jAdapter.RuleBuildContext ruleCtx =
|
|
new StrategyDslToTa4jAdapter.RuleBuildContext(
|
|
primarySeries, params, visual, market, null, false, seriesOverrides);
|
|
Rule entryRule = (buyDsl != null && !buyDsl.isNull())
|
|
? adapter.toRule(buyDsl, ruleCtx)
|
|
: new org.ta4j.core.rules.BooleanRule(false);
|
|
Rule exitRule = (sellDsl != null && !sellDsl.isNull())
|
|
? adapter.toRule(sellDsl, ruleCtx)
|
|
: new org.ta4j.core.rules.BooleanRule(false);
|
|
|
|
List<BacktestResponse.Signal> signals = new ArrayList<>();
|
|
int buyHits = 0;
|
|
int sellHits = 0;
|
|
for (int i = startIdx; i <= primarySeries.getEndIndex(); i++) {
|
|
long barTimeSec = barOpenEpochSec(primarySeries, i);
|
|
double close = primarySeries.getBar(i).getClosePrice().doubleValue();
|
|
|
|
if (entryRule.isSatisfied(i, null)) {
|
|
buyHits++;
|
|
signals.add(BacktestResponse.Signal.builder()
|
|
.time(barTimeSec)
|
|
.type("BUY")
|
|
.price(close)
|
|
.barIndex(i)
|
|
.quantity(0)
|
|
.build());
|
|
}
|
|
if (exitRule.isSatisfied(i, null)) {
|
|
sellHits++;
|
|
signals.add(BacktestResponse.Signal.builder()
|
|
.time(barTimeSec)
|
|
.type("SELL")
|
|
.price(close)
|
|
.barIndex(i)
|
|
.quantity(0)
|
|
.build());
|
|
}
|
|
}
|
|
if (signals.isEmpty()) {
|
|
log.info("[LiveCondition:scan] 0 signals market={} strategy={} tf={} bars={} eval={}..{}",
|
|
market, strategyId, primaryTf, primarySeries.getBarCount(), startIdx,
|
|
primarySeries.getEndIndex());
|
|
} else {
|
|
log.debug("[LiveCondition:scan] market={} strategy={} tf={} buy={} sell={}",
|
|
market, strategyId, primaryTf, buyHits, sellHits);
|
|
}
|
|
return signals;
|
|
} catch (Exception e) {
|
|
log.warn("[LiveCondition:scan] fail market={} strategy={}: {}",
|
|
market, strategyId, e.getMessage(), e);
|
|
return List.of();
|
|
}
|
|
}
|
|
|
|
}
|