318 lines
12 KiB
Java
318 lines
12 KiB
Java
package com.goldenchart.service;
|
|
|
|
import com.fasterxml.jackson.databind.JsonNode;
|
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
|
import com.goldenchart.entity.GcStrategy;
|
|
import com.goldenchart.repository.GcStrategyRepository;
|
|
import lombok.extern.slf4j.Slf4j;
|
|
import org.springframework.context.annotation.Lazy;
|
|
import org.springframework.stereotype.Service;
|
|
|
|
import java.util.LinkedHashSet;
|
|
import java.util.List;
|
|
import java.util.Optional;
|
|
import java.util.Set;
|
|
import java.util.concurrent.ConcurrentHashMap;
|
|
|
|
/**
|
|
* 전략 DSL(buy/sell LogicNode)에 포함된 조건 판별 시간봉 수집.
|
|
* START / TIMEFRAME / AND|OR+TIMEFRAME 분기와 동일 규칙.
|
|
*/
|
|
@Service
|
|
@Slf4j
|
|
public class StrategyConditionTimeframeService {
|
|
|
|
private final GcStrategyRepository strategyRepo;
|
|
private final ObjectMapper objectMapper;
|
|
private final StrategyDslTimeframeNormalizer dslNormalizer;
|
|
private final LiveStrategyEvaluator liveStrategyEvaluator;
|
|
|
|
private final ConcurrentHashMap<Long, Set<String>> cache = new ConcurrentHashMap<>();
|
|
|
|
public StrategyConditionTimeframeService(
|
|
GcStrategyRepository strategyRepo,
|
|
ObjectMapper objectMapper,
|
|
StrategyDslTimeframeNormalizer dslNormalizer,
|
|
@Lazy LiveStrategyEvaluator liveStrategyEvaluator) {
|
|
this.strategyRepo = strategyRepo;
|
|
this.objectMapper = objectMapper;
|
|
this.dslNormalizer = dslNormalizer;
|
|
this.liveStrategyEvaluator = liveStrategyEvaluator;
|
|
}
|
|
|
|
public Set<String> collectForStrategy(long strategyId) {
|
|
return cache.computeIfAbsent(strategyId, this::loadFromDb);
|
|
}
|
|
|
|
public List<String> collectForStrategyList(long strategyId) {
|
|
return List.copyOf(collectForStrategy(strategyId));
|
|
}
|
|
|
|
public boolean usesTimeframe(long strategyId, String candleType) {
|
|
String ct = LiveStrategyTimeframeService.normalize(candleType);
|
|
return collectForStrategy(strategyId).contains(ct);
|
|
}
|
|
|
|
public void invalidate(long strategyId) {
|
|
cache.remove(strategyId);
|
|
}
|
|
|
|
public void invalidateAll() {
|
|
cache.clear();
|
|
}
|
|
|
|
private Set<String> loadFromDb(long strategyId) {
|
|
Optional<GcStrategy> opt = strategyRepo.findById(strategyId);
|
|
if (opt.isEmpty()) return Set.of("1m");
|
|
GcStrategy strategy = opt.get();
|
|
ensureDslRepaired(strategy);
|
|
|
|
Set<String> buyOut = new LinkedHashSet<>();
|
|
Set<String> sellOut = new LinkedHashSet<>();
|
|
boolean buyScoped = collectStartScopeFromJson(strategy.getBuyConditionJson(), buyOut);
|
|
boolean sellScoped = collectStartScopeFromJson(strategy.getSellConditionJson(), sellOut);
|
|
|
|
// 평가 분봉은 buy/sell DSL(TIMEFRAME·START)이 단일 원천 — flow_layout 은 편집기 UI 보조
|
|
if (buyScoped && !buyOut.isEmpty()) {
|
|
return sanitizeEvaluationTimeframes(buyOut, strategy);
|
|
}
|
|
if (sellScoped && !sellOut.isEmpty()) {
|
|
return sanitizeEvaluationTimeframes(sellOut, strategy);
|
|
}
|
|
|
|
Set<String> out = new LinkedHashSet<>();
|
|
|
|
// 레거시: START(TIMEFRAME) 래핑 없는 DSL
|
|
if (!buyScoped) {
|
|
collectLegacyFromJson(strategy.getBuyConditionJson(), out, strategy.getName());
|
|
}
|
|
if (!sellScoped) {
|
|
collectLegacyFromJson(strategy.getSellConditionJson(), out, strategy.getName());
|
|
}
|
|
|
|
if (out.isEmpty()) {
|
|
Set<String> fromFlowLayout = collectFromFlowLayoutJson(strategy.getFlowLayoutJson());
|
|
if (!fromFlowLayout.isEmpty()) {
|
|
return sanitizeEvaluationTimeframes(fromFlowLayout, strategy);
|
|
}
|
|
String fromName = StrategyDslTimeframeNormalizer.inferFromStrategyName(strategy.getName());
|
|
return Set.of(fromName != null ? fromName : "1m");
|
|
}
|
|
return sanitizeEvaluationTimeframes(out, strategy);
|
|
}
|
|
|
|
/**
|
|
* START/flow 에만 남은 stale 1m 제거 — CONDITION 에 선언된 분봉 기준(기본값 1m 제외).
|
|
*/
|
|
private Set<String> sanitizeEvaluationTimeframes(Set<String> scope, GcStrategy strategy) {
|
|
if (!scope.contains("1m") || scope.size() <= 1) return scope;
|
|
|
|
Set<String> conditionDeclared = collectConditionDeclaredFromStrategy(strategy);
|
|
if (conditionDeclared.contains("1m") && conditionDeclared.size() > 1) {
|
|
return scope;
|
|
}
|
|
if (!conditionDeclared.contains("1m")) {
|
|
Set<String> filtered = new LinkedHashSet<>(scope);
|
|
filtered.remove("1m");
|
|
return filtered.isEmpty() ? scope : filtered;
|
|
}
|
|
if (conditionDeclared.equals(Set.of("1m"))) {
|
|
Set<String> filtered = new LinkedHashSet<>(scope);
|
|
filtered.remove("1m");
|
|
return filtered.isEmpty() ? scope : filtered;
|
|
}
|
|
return scope;
|
|
}
|
|
|
|
private Set<String> collectConditionDeclaredFromStrategy(GcStrategy strategy) {
|
|
Set<String> out = new LinkedHashSet<>();
|
|
collectConditionDeclaredFromJson(strategy.getBuyConditionJson(), out);
|
|
collectConditionDeclaredFromJson(strategy.getSellConditionJson(), out);
|
|
return out;
|
|
}
|
|
|
|
private void collectConditionDeclaredFromJson(String json, Set<String> out) {
|
|
if (json == null || json.isBlank()) return;
|
|
try {
|
|
out.addAll(StrategyDslTimeframeNormalizer.collectConditionDeclaredCandleTypes(
|
|
objectMapper.readTree(json)));
|
|
} catch (Exception e) {
|
|
log.warn("[StrategyTimeframes] condition-declared JSON 파싱 실패: {}", e.getMessage());
|
|
}
|
|
}
|
|
|
|
/** TIMEFRAME 누락·잘못된 1m 래핑을 전략명·DSL 기준으로 DB 보정 */
|
|
private void ensureDslRepaired(GcStrategy strategy) {
|
|
boolean changed = false;
|
|
String name = strategy.getName();
|
|
|
|
String buyJson = strategy.getBuyConditionJson();
|
|
String sellJson = strategy.getSellConditionJson();
|
|
if (buyJson != null && !buyJson.isBlank()
|
|
&& sellJson != null && !sellJson.isBlank()) {
|
|
String alignedSell = dslNormalizer.alignSellStartTimeframeToBuy(buyJson, sellJson);
|
|
if (!alignedSell.equals(sellJson)) {
|
|
strategy.setSellConditionJson(alignedSell);
|
|
sellJson = alignedSell;
|
|
changed = true;
|
|
}
|
|
}
|
|
|
|
if (strategy.getBuyConditionJson() != null && !strategy.getBuyConditionJson().isBlank()) {
|
|
String fixed = dslNormalizer.normalizeJson(strategy.getBuyConditionJson(), name);
|
|
if (!fixed.equals(strategy.getBuyConditionJson())) {
|
|
strategy.setBuyConditionJson(fixed);
|
|
changed = true;
|
|
}
|
|
}
|
|
if (sellJson != null && !sellJson.isBlank()) {
|
|
String fixed = dslNormalizer.normalizeJson(sellJson, name);
|
|
if (!fixed.equals(strategy.getSellConditionJson())) {
|
|
strategy.setSellConditionJson(fixed);
|
|
changed = true;
|
|
}
|
|
}
|
|
|
|
if (changed) {
|
|
strategyRepo.save(strategy);
|
|
liveStrategyEvaluator.invalidateStrategy(strategy.getId());
|
|
log.info("[StrategyTimeframes] DSL 분봉 보정 strategyId={} name={}",
|
|
strategy.getId(), name);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* START 루트 TIMEFRAME(candleTypes)만 수집 — 조건 노드 leftCandleType·내부 1m 기본값은 제외.
|
|
*
|
|
* @return JSON 에 START 스코프가 있으면 true (빈 조건이어도)
|
|
*/
|
|
private boolean collectStartScopeFromJson(String json, Set<String> out) {
|
|
if (json == null || json.isBlank()) return false;
|
|
try {
|
|
return collectStartScopeFromNode(objectMapper.readTree(json), out);
|
|
} catch (Exception e) {
|
|
log.warn("[StrategyTimeframes] JSON 파싱 실패: {}", e.getMessage());
|
|
return false;
|
|
}
|
|
}
|
|
|
|
private boolean collectStartScopeFromNode(JsonNode node, Set<String> out) {
|
|
if (node == null || node.isNull()) return false;
|
|
return collectTimeframesFromNode(node, out);
|
|
}
|
|
|
|
/** 레거시 DSL — START 래핑 없을 때만 조건 트리·전략명에서 추론 */
|
|
private void collectLegacyFromJson(String json, Set<String> out, String strategyName) {
|
|
if (json == null || json.isBlank()) return;
|
|
try {
|
|
collectLegacyFromNode(objectMapper.readTree(json), out, strategyName);
|
|
} catch (Exception e) {
|
|
log.warn("[StrategyTimeframes] legacy JSON 파싱 실패: {}", e.getMessage());
|
|
addFallbackTimeframe(out, strategyName);
|
|
}
|
|
}
|
|
|
|
private void collectLegacyFromNode(JsonNode node, Set<String> out, String strategyName) {
|
|
if (node == null || node.isNull()) return;
|
|
|
|
Set<String> meaningful = StrategyDslTimeframeNormalizer.collectMeaningfulCandleTypes(node);
|
|
if (!meaningful.isEmpty()) {
|
|
out.addAll(meaningful);
|
|
return;
|
|
}
|
|
|
|
String fromName = StrategyDslTimeframeNormalizer.inferFromStrategyName(strategyName);
|
|
if (fromName != null) {
|
|
out.add(fromName);
|
|
return;
|
|
}
|
|
|
|
addFallbackTimeframe(out, strategyName);
|
|
}
|
|
|
|
private void addFallbackTimeframe(Set<String> out, String strategyName) {
|
|
String fromName = StrategyDslTimeframeNormalizer.inferFromStrategyName(strategyName);
|
|
out.add(fromName != null ? fromName : "1m");
|
|
}
|
|
|
|
/**
|
|
* 트리에서 TIMEFRAME 노드를 수집. 발견 시 true.
|
|
*/
|
|
private boolean collectTimeframesFromNode(JsonNode node, Set<String> out) {
|
|
if (node == null || node.isNull()) return false;
|
|
String type = node.path("type").asText("");
|
|
|
|
if ("TIMEFRAME".equals(type)) {
|
|
addTimeframeCandleTypes(node, out);
|
|
return true;
|
|
}
|
|
|
|
if ("AND".equals(type) || "OR".equals(type)) {
|
|
JsonNode children = node.path("children");
|
|
if (children.isArray() && !children.isEmpty()) {
|
|
if (allTimeframeChildren(children)) {
|
|
for (JsonNode tf : children) {
|
|
addTimeframeCandleTypes(tf, out);
|
|
}
|
|
return true;
|
|
}
|
|
boolean found = false;
|
|
for (JsonNode child : children) {
|
|
found |= collectTimeframesFromNode(child, out);
|
|
}
|
|
return found;
|
|
}
|
|
}
|
|
|
|
JsonNode child = node.path("child");
|
|
if (!child.isMissingNode() && !child.isNull()) {
|
|
return collectTimeframesFromNode(child, out);
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
private static boolean allTimeframeChildren(JsonNode children) {
|
|
for (JsonNode c : children) {
|
|
if (!"TIMEFRAME".equals(c.path("type").asText(""))) return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/** TIMEFRAME 노드 — candleTypes 배열 또는 단일 candleType (stale 1m 제거 반영) */
|
|
static void addTimeframeCandleTypes(JsonNode timeframeNode, Set<String> out) {
|
|
for (String ct : StrategyDslTimeframeNormalizer.resolveStartCandleTypes(timeframeNode)) {
|
|
out.add(ct);
|
|
}
|
|
}
|
|
|
|
/** flow_layout_json.startMeta — 편집기 Logic Expression·START 분봉 (DSL과 동기 저장) */
|
|
private Set<String> collectFromFlowLayoutJson(String flowLayoutJson) {
|
|
Set<String> out = new LinkedHashSet<>();
|
|
if (flowLayoutJson == null || flowLayoutJson.isBlank()) return out;
|
|
try {
|
|
JsonNode root = objectMapper.readTree(flowLayoutJson);
|
|
for (String side : new String[] { "buy", "sell" }) {
|
|
JsonNode startMeta = root.path(side).path("startMeta");
|
|
if (!startMeta.isObject()) continue;
|
|
startMeta.fields().forEachRemaining(entry -> {
|
|
JsonNode meta = entry.getValue();
|
|
JsonNode types = meta.path("candleTypes");
|
|
if (types.isArray() && !types.isEmpty()) {
|
|
for (JsonNode t : types) {
|
|
String ct = LiveStrategyTimeframeService.normalize(t.asText(""));
|
|
if (!ct.isBlank()) out.add(ct);
|
|
}
|
|
} else {
|
|
String ct = LiveStrategyTimeframeService.normalize(meta.path("candleType").asText(""));
|
|
if (!ct.isBlank()) out.add(ct);
|
|
}
|
|
});
|
|
}
|
|
} catch (Exception e) {
|
|
log.warn("[StrategyTimeframes] flow_layout_json 파싱 실패: {}", e.getMessage());
|
|
}
|
|
return out;
|
|
}
|
|
}
|