Files
goldenChart/backend/src/main/java/com/goldenchart/service/LiveConditionStatusService.java
T
2026-06-05 10:32:40 +09:00

332 lines
14 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.LiveConditionRowDto;
import com.goldenchart.dto.LiveConditionStatusDto;
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("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;
@Transactional(readOnly = true)
public LiveConditionStatusDto evaluate(Long userId, String deviceId,
String market, long strategyId) {
Optional<GcStrategy> opt = strategyRepo.findById(strategyId);
if (opt.isEmpty()) {
return empty(market, strategyId);
}
GcStrategy strategy = opt.get();
Map<String, Map<String, Object>> params =
indicatorSettingsService.getAll(userId, deviceId);
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);
}
// 데이터 수신 보장 — 평가 전 해당 종목·봉 타입 pin + 동기 warm-up
Set<String> timeframes = new LinkedHashSet<>();
for (PendingCond p : pending) {
String tf = LiveStrategyTimeframeService.normalize(p.timeframe());
timeframes.add(tf);
subscriptionManager.ensureBarsReadySync(market, tf, 60);
}
subscriptionManager.ensureBarsReadySync(market, "1m", 60);
List<LiveConditionRowDto> rows = new ArrayList<>();
int met = 0;
int evaluable = 0;
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 = series.getEndIndex();
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);
return LiveConditionStatusDto.builder()
.market(market)
.strategyId(strategyId)
.timeframe(tfSummary)
.matchRate(matchRate)
.rows(rows)
.updatedAt(System.currentTimeMillis())
.build();
}
private LiveConditionStatusDto empty(String market, long strategyId) {
return LiveConditionStatusDto.builder()
.market(market)
.strategyId(strategyId)
.timeframe("")
.matchRate(0)
.rows(List.of())
.updatedAt(System.currentTimeMillis())
.build();
}
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;
String type = node.path("type").asText("");
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));
}
}
}
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 condLabel = CONDITION_LABEL.getOrDefault(condType, condType);
Double target = readTargetNumeric(c, visual);
return LiveConditionRowDto.builder()
.id(p.id())
.indicatorType(indType)
.displayName(indType)
.conditionType(condType)
.conditionLabel(condLabel)
.thresholdLabel(formatThresholdStatic(c, target))
.currentValue(null)
.targetValue(target)
.satisfied(null)
.timeframe(p.timeframe())
.side(p.side())
.plotKey(plotKey)
.build();
}
private String formatThresholdStatic(JsonNode cond, Double target) {
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 rf.replace('_', ' ');
}
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 condLabel = CONDITION_LABEL.getOrDefault(condType, condType);
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);
return switch (ct) {
case "LT", "CROSS_DOWN" -> "< " + v;
case "GT", "CROSS_UP" -> "> " + v;
case "GTE" -> "≥ " + v;
case "LTE" -> "≤ " + v;
case "EQ" -> "= " + v;
default -> rf.replace('_', ' ') + " (" + v + ")";
};
}
return rf.replace('_', ' ');
}
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;
}
}