지표탭 추가 수정

This commit is contained in:
Macbook
2026-05-27 13:16:02 +09:00
parent c72b987ff7
commit f22abbdc19
14 changed files with 505 additions and 76 deletions
@@ -0,0 +1,194 @@
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의 확정봉 인덱스.
*/
public Rule buildTriggerRule(String conditionJson,
String market,
long strategyId,
String side,
String triggerCandleType,
Map<String, Map<String, Object>> indicatorParams,
Ta4jStorage storage) {
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,
market,
storage);
return new TriggerBranchRule(scope, market, strategyId, side, trigger, ctx);
}
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)) {
String ct = LiveStrategyTimeframeService.normalize(node.path("candleType").asText("1m"));
JsonNode sub = firstChild(node);
return new BranchScope("SINGLE", List.of(new BranchDef(ct, sub)));
}
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) {
String ct = LiveStrategyTimeframeService.normalize(
child.path("candleType").asText("1m"));
branches.add(new BranchDef(ct, firstChild(child)));
}
return new BranchScope(type, branches);
}
}
return new BranchScope("SINGLE", List.of(new BranchDef("1m", node)));
}
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;
}
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 = branch.candleType().equals(triggerCandleType)
? index
: branchSeries.getEndIndex();
if (branchIndex < 0 || branchIndex >= branchSeries.getBarCount()) return false;
StrategyDslToTa4jAdapter.RuleBuildContext branchCtx =
new StrategyDslToTa4jAdapter.RuleBuildContext(
branchSeries, ctx.indicatorParams(), ctx.market(), ctx.storage());
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;
}
}
}
}