270 lines
11 KiB
Java
270 lines
11 KiB
Java
package com.goldenchart.service;
|
|
|
|
import com.fasterxml.jackson.databind.JsonNode;
|
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
|
import com.goldenchart.storage.Ta4jStorage;
|
|
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.ta4j.core.TradingRecord;
|
|
import org.ta4j.core.rules.BooleanRule;
|
|
|
|
import java.util.ArrayList;
|
|
import java.util.List;
|
|
import java.util.Map;
|
|
import java.util.Optional;
|
|
|
|
/**
|
|
* START/TIMEFRAME 분기별 트리거 분봉 마감 평가.
|
|
*
|
|
* <ul>
|
|
* <li>OR / 단일 START: 트리거 분봉 마감 시 해당 분기만 평가</li>
|
|
* <li>AND + 다중 START: 트리거 분기만 갱신, 나머지는 각자 마감 시 캐시된 상태로 결합</li>
|
|
* </ul>
|
|
*/
|
|
@Service
|
|
@RequiredArgsConstructor
|
|
@Slf4j
|
|
public class StrategyTriggerBranchEvaluator {
|
|
|
|
private final StrategyDslToTa4jAdapter adapter;
|
|
private final ObjectMapper objectMapper;
|
|
private final StrategyBranchStateCache branchCache;
|
|
|
|
record BranchScope(String combineOp, List<BranchDef> branches) {}
|
|
|
|
record BranchDef(String candleType, JsonNode subtree) {}
|
|
|
|
/**
|
|
* 트리거 분봉({@code triggerCandleType}) 마감 시 호출되는 Rule.
|
|
* {@code index}는 트리거 분봉 BarSeries의 확정봉 인덱스.
|
|
*
|
|
* @param useConfirmedOnly true → CANDLE_CLOSE: 비트리거 상위봉의 확정봉(endIndex-1) 사용
|
|
*/
|
|
public Rule buildTriggerRule(String conditionJson,
|
|
String market,
|
|
long strategyId,
|
|
String side,
|
|
String triggerCandleType,
|
|
Map<String, Map<String, Object>> indicatorParams,
|
|
Map<String, Map<String, Object>> indicatorVisual,
|
|
Ta4jStorage storage,
|
|
boolean useConfirmedOnly) {
|
|
BranchScope scope = parseScope(conditionJson);
|
|
if (scope.branches().isEmpty()) return new BooleanRule(false);
|
|
|
|
String trigger = LiveStrategyTimeframeService.normalize(triggerCandleType);
|
|
StrategyDslToTa4jAdapter.RuleBuildContext ctx =
|
|
new StrategyDslToTa4jAdapter.RuleBuildContext(
|
|
storage.getOrCreate(market, trigger),
|
|
indicatorParams,
|
|
indicatorVisual != null ? indicatorVisual : Map.of(),
|
|
market,
|
|
storage,
|
|
useConfirmedOnly,
|
|
Map.of());
|
|
|
|
return new TriggerBranchRule(scope, market, strategyId, side, trigger, ctx);
|
|
}
|
|
|
|
/** 하위 호환 오버로드 (useConfirmedOnly=false) */
|
|
public Rule buildTriggerRule(String conditionJson,
|
|
String market,
|
|
long strategyId,
|
|
String side,
|
|
String triggerCandleType,
|
|
Map<String, Map<String, Object>> indicatorParams,
|
|
Map<String, Map<String, Object>> indicatorVisual,
|
|
Ta4jStorage storage) {
|
|
return buildTriggerRule(conditionJson, market, strategyId, side, triggerCandleType,
|
|
indicatorParams, indicatorVisual, storage, false);
|
|
}
|
|
|
|
BranchScope parseScope(String conditionJson) {
|
|
if (conditionJson == null || conditionJson.isBlank()) {
|
|
return new BranchScope("SINGLE", List.of());
|
|
}
|
|
try {
|
|
return parseScopeNode(objectMapper.readTree(conditionJson));
|
|
} catch (Exception e) {
|
|
log.warn("[TriggerBranch] JSON 파싱 실패: {}", e.getMessage());
|
|
return new BranchScope("SINGLE", List.of());
|
|
}
|
|
}
|
|
|
|
private BranchScope parseScopeNode(JsonNode node) {
|
|
if (node == null || node.isNull()) {
|
|
return new BranchScope("SINGLE", List.of());
|
|
}
|
|
|
|
String type = node.path("type").asText("");
|
|
|
|
if ("TIMEFRAME".equals(type)) {
|
|
List<String> types = readTimeframeCandleTypes(node);
|
|
JsonNode sub = firstChild(node);
|
|
if (types.size() <= 1) {
|
|
String ct = types.isEmpty() ? "1m" : types.get(0);
|
|
return new BranchScope("SINGLE", List.of(new BranchDef(ct, sub)));
|
|
}
|
|
List<BranchDef> branches = new ArrayList<>();
|
|
for (String ct : types) {
|
|
branches.add(new BranchDef(ct, sub));
|
|
}
|
|
return new BranchScope("OR", branches);
|
|
}
|
|
|
|
if ("AND".equals(type) || "OR".equals(type)) {
|
|
JsonNode children = node.path("children");
|
|
if (children.isArray() && !children.isEmpty() && allTimeframeChildren(children)) {
|
|
List<BranchDef> branches = new ArrayList<>();
|
|
for (JsonNode child : children) {
|
|
JsonNode sub = firstChild(child);
|
|
for (String ct : readTimeframeCandleTypes(child)) {
|
|
branches.add(new BranchDef(ct, sub));
|
|
}
|
|
}
|
|
return new BranchScope(type, branches);
|
|
}
|
|
}
|
|
|
|
JsonNode wrapped = findTimeframeWrapper(node);
|
|
if (wrapped != null) {
|
|
List<String> types = readTimeframeCandleTypes(wrapped);
|
|
JsonNode sub = firstChild(wrapped);
|
|
JsonNode subtree = sub != null && !sub.isNull() ? sub : node;
|
|
if (types.size() <= 1) {
|
|
String ct = types.isEmpty() ? "1m" : types.get(0);
|
|
return new BranchScope("SINGLE", List.of(new BranchDef(ct, subtree)));
|
|
}
|
|
List<BranchDef> branches = new ArrayList<>();
|
|
for (String ct : types) {
|
|
branches.add(new BranchDef(ct, subtree));
|
|
}
|
|
return new BranchScope("OR", branches);
|
|
}
|
|
|
|
String inferred = StrategyDslTimeframeNormalizer.inferPrimaryCandleType(node);
|
|
return new BranchScope("SINGLE", List.of(new BranchDef(inferred, node)));
|
|
}
|
|
|
|
/** 레거시 DSL — 루트가 TIMEFRAME이 아닐 때 하위 TIMEFRAME 래퍼 탐색 */
|
|
private static JsonNode findTimeframeWrapper(JsonNode node) {
|
|
if (node == null || node.isNull()) return null;
|
|
if ("TIMEFRAME".equals(node.path("type").asText(""))) return node;
|
|
|
|
JsonNode children = node.path("children");
|
|
if (children.isArray()) {
|
|
for (JsonNode child : children) {
|
|
JsonNode found = findTimeframeWrapper(child);
|
|
if (found != null) return found;
|
|
}
|
|
}
|
|
JsonNode child = node.path("child");
|
|
if (!child.isMissingNode() && !child.isNull()) {
|
|
return findTimeframeWrapper(child);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
private static JsonNode firstChild(JsonNode timeframeNode) {
|
|
JsonNode children = timeframeNode.path("children");
|
|
if (children.isArray() && !children.isEmpty()) return children.get(0);
|
|
return timeframeNode.path("child");
|
|
}
|
|
|
|
private static boolean allTimeframeChildren(JsonNode children) {
|
|
for (JsonNode c : children) {
|
|
if (!"TIMEFRAME".equals(c.path("type").asText(""))) return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/** START 다중 분봉 — 각 마감봉마다 동일 조건 트리를 독립 평가 */
|
|
private static List<String> readTimeframeCandleTypes(JsonNode timeframeNode) {
|
|
return new ArrayList<>(StrategyDslTimeframeNormalizer.resolveStartCandleTypes(timeframeNode));
|
|
}
|
|
|
|
private final class TriggerBranchRule implements Rule {
|
|
private final BranchScope scope;
|
|
private final String market;
|
|
private final long strategyId;
|
|
private final String side;
|
|
private final String triggerCandleType;
|
|
private final StrategyDslToTa4jAdapter.RuleBuildContext ctx;
|
|
|
|
TriggerBranchRule(BranchScope scope,
|
|
String market,
|
|
long strategyId,
|
|
String side,
|
|
String triggerCandleType,
|
|
StrategyDslToTa4jAdapter.RuleBuildContext ctx) {
|
|
this.scope = scope;
|
|
this.market = market;
|
|
this.strategyId = strategyId;
|
|
this.side = side;
|
|
this.triggerCandleType = triggerCandleType;
|
|
this.ctx = ctx;
|
|
}
|
|
|
|
@Override
|
|
public boolean isSatisfied(int index, TradingRecord tradingRecord) {
|
|
Optional<BranchDef> triggerBranch = scope.branches().stream()
|
|
.filter(b -> b.candleType().equals(triggerCandleType))
|
|
.findFirst();
|
|
if (triggerBranch.isEmpty()) return false;
|
|
|
|
BarSeries triggerSeries = ctx.resolveSeries(triggerCandleType);
|
|
if (triggerSeries.isEmpty()) return false;
|
|
|
|
int evalIndex = Math.min(index, triggerSeries.getEndIndex());
|
|
if (evalIndex < 0) return false;
|
|
|
|
boolean triggerSat = evaluateBranch(triggerBranch.get(), evalIndex);
|
|
branchCache.put(market, strategyId, side, triggerCandleType, triggerSat, evalIndex);
|
|
|
|
if ("OR".equals(scope.combineOp()) || "SINGLE".equals(scope.combineOp())) {
|
|
return triggerSat;
|
|
}
|
|
|
|
for (BranchDef branch : scope.branches()) {
|
|
StrategyBranchStateCache.BranchState state =
|
|
branchCache.get(market, strategyId, side, branch.candleType());
|
|
if (state == null || !state.satisfied()) return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
private boolean evaluateBranch(BranchDef branch, int index) {
|
|
JsonNode subtree = branch.subtree();
|
|
if (subtree == null || subtree.isNull()) return false;
|
|
|
|
BarSeries branchSeries = ctx.resolveSeries(branch.candleType());
|
|
if (branchSeries.isEmpty()) return false;
|
|
|
|
int branchIndex;
|
|
if (branch.candleType().equals(triggerCandleType)) {
|
|
branchIndex = index;
|
|
} else if (ctx.useConfirmedOnly()) {
|
|
// CANDLE_CLOSE: 마지막 확정봉 사용 (provisional 봉 오염 방지)
|
|
branchIndex = Math.max(branchSeries.getBeginIndex(), branchSeries.getEndIndex() - 1);
|
|
} else {
|
|
// REALTIME_TICK: 현재 봉(provisional 포함) 사용
|
|
branchIndex = branchSeries.getEndIndex();
|
|
}
|
|
if (branchIndex < 0 || branchIndex >= branchSeries.getBarCount()) return false;
|
|
|
|
StrategyDslToTa4jAdapter.RuleBuildContext branchCtx = ctx.withPrimary(branchSeries);
|
|
try {
|
|
Rule rule = adapter.toRule(subtree, branchCtx);
|
|
return rule.isSatisfied(branchIndex, null);
|
|
} catch (Exception e) {
|
|
log.debug("[TriggerBranch] 분기 평가 실패 {} {}: {}",
|
|
market, branch.candleType(), e.getMessage());
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
}
|