Files
goldenChart/backend/src/main/java/com/goldenchart/service/StrategyConditionTimeframeService.java
T
2026-05-31 01:23:47 +09:00

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;
}
}