전략편집기 기준값 보조지표 설정값 동기화

This commit is contained in:
Macbook
2026-05-28 09:20:06 +09:00
parent 9137864f48
commit e2816b037f
18 changed files with 710 additions and 153 deletions
@@ -0,0 +1,139 @@
package com.goldenchart.service;
import com.fasterxml.jackson.databind.JsonNode;
import java.util.List;
import java.util.Map;
/**
* 보조지표 visual_config hline — 전략 DSL HL_OVER/HL_MID/HL_UNDER 역할 해석.
*/
public final class IndicatorHlineResolver {
private IndicatorHlineResolver() {}
public static Double resolveThresholdField(
JsonNode cond,
String field,
String dslIndicatorType,
Map<String, Map<String, Object>> indicatorVisual) {
if (field == null || field.isBlank()) return null;
boolean override = cond != null && cond.path("thresholdOverride").asBoolean(false);
if (override) {
if (cond.has("targetValue") && !cond.path("targetValue").isNull()) {
return cond.path("targetValue").asDouble();
}
if (field.startsWith("K_")) {
try { return Double.parseDouble(field.substring(2)); } catch (NumberFormatException ignored) {}
}
return legacyNumericField(field);
}
if ("HL_OVER".equals(field) || "HL_MID".equals(field) || "HL_UNDER".equals(field)) {
Double fromVisual = fromHlineRole(field, dslIndicatorType, indicatorVisual);
if (fromVisual != null) return fromVisual;
}
if (field.startsWith("K_")) {
Double fromVisual = inferRoleFromLegacyK(field, dslIndicatorType, indicatorVisual);
if (fromVisual != null) return fromVisual;
try { return Double.parseDouble(field.substring(2)); } catch (NumberFormatException ignored) {}
}
return legacyNumericField(field);
}
private static Double fromHlineRole(
String role,
String dslIndicatorType,
Map<String, Map<String, Object>> indicatorVisual) {
String registryKey = StrategyDslToTa4jAdapter.registryKeyForDsl(dslIndicatorType);
if (indicatorVisual == null || registryKey == null) return defaultForRole(dslIndicatorType, role);
Map<String, Object> visual = indicatorVisual.get(registryKey);
if (visual == null) return defaultForRole(dslIndicatorType, role);
@SuppressWarnings("unchecked")
List<Map<String, Object>> hlines = (List<Map<String, Object>>) visual.get("hlines");
if (hlines == null || hlines.isEmpty()) return defaultForRole(dslIndicatorType, role);
String label = switch (role) {
case "HL_OVER" -> "과열선";
case "HL_MID" -> "중앙선";
case "HL_UNDER" -> "침체선";
default -> null;
};
if (label == null) return null;
for (Map<String, Object> h : hlines) {
Object lbl = h.get("label");
if (lbl != null && label.equals(lbl.toString())) {
Object price = h.get("price");
if (price instanceof Number n) return n.doubleValue();
}
}
// 기준선 폴백 (Disparity 등)
if ("HL_MID".equals(role)) {
for (Map<String, Object> h : hlines) {
Object lbl = h.get("label");
if (lbl != null && "기준선".equals(lbl.toString())) {
Object price = h.get("price");
if (price instanceof Number n) return n.doubleValue();
}
}
}
return defaultForRole(dslIndicatorType, role);
}
private static Double inferRoleFromLegacyK(
String kField,
String dslIndicatorType,
Map<String, Map<String, Object>> indicatorVisual) {
try {
double val = Double.parseDouble(kField.substring(2));
for (String role : List.of("HL_OVER", "HL_MID", "HL_UNDER")) {
Double roleVal = fromHlineRole(role, dslIndicatorType, indicatorVisual);
if (roleVal != null && Math.abs(roleVal - val) < 0.0001) {
return roleVal;
}
}
} catch (NumberFormatException ignored) {}
return null;
}
private static Double legacyNumericField(String field) {
if (field.contains("_NEG")) {
String[] parts = field.split("_NEG");
try { return -Double.parseDouble(parts[parts.length - 1]); } catch (NumberFormatException ignored) {}
}
if ("ZERO_LINE".equals(field)) return 0.0;
int idx = field.lastIndexOf('_');
if (idx >= 0 && idx < field.length() - 1) {
try { return Double.parseDouble(field.substring(idx + 1)); } catch (NumberFormatException ignored) {}
}
return null;
}
private static Double defaultForRole(String dslIndicatorType, String role) {
return switch (dslIndicatorType + ":" + role) {
case "RSI:HL_OVER" -> 70.0;
case "RSI:HL_MID" -> 50.0;
case "RSI:HL_UNDER" -> 30.0;
case "CCI:HL_OVER" -> 100.0;
case "CCI:HL_MID" -> 0.0;
case "CCI:HL_UNDER" -> -100.0;
case "STOCHASTIC:HL_OVER" -> 80.0;
case "STOCHASTIC:HL_MID" -> 50.0;
case "STOCHASTIC:HL_UNDER" -> 20.0;
case "ADX:HL_MID" -> 25.0;
case "WILLIAMS_R:HL_OVER" -> -20.0;
case "DISPARITY:HL_MID" -> 100.0;
case "VOLUME_OSC:HL_MID" -> 0.0;
case "TRIX:HL_MID" -> 0.0;
default -> null;
};
}
}
@@ -59,6 +59,8 @@ public class LiveConditionStatusService {
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);
@@ -84,12 +86,12 @@ public class LiveConditionStatusService {
for (PendingCond p : pending) {
String tf = LiveStrategyTimeframeService.normalize(p.timeframe());
if (!ta4jStorage.exists(market, tf)) {
rows.add(toRowUnevaluated(p));
rows.add(toRowUnevaluated(p, visual));
continue;
}
BarSeries series = ta4jStorage.getOrCreate(market, tf);
if (series.isEmpty()) {
rows.add(toRowUnevaluated(p));
rows.add(toRowUnevaluated(p, visual));
continue;
}
int index = series.getEndIndex();
@@ -100,12 +102,12 @@ public class LiveConditionStatusService {
StrategyDslToTa4jAdapter.RuleBuildContext ctx =
new StrategyDslToTa4jAdapter.RuleBuildContext(
series, params, market, ta4jStorage);
series, params, visual, market, ta4jStorage);
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());
Double target = readTargetNumeric(p.condition(), visual);
if (target == null) {
target = adapter.readConditionFieldValue(
p.condition(), series, params, index, false);
@@ -115,7 +117,7 @@ public class LiveConditionStatusService {
if (satisfied) met++;
} catch (Exception e) {
log.debug("[LiveCondition] eval fail {} {}: {}", market, p.id(), e.getMessage());
rows.add(toRowUnevaluated(p));
rows.add(toRowUnevaluated(p, visual));
}
}
@@ -194,13 +196,14 @@ public class LiveConditionStatusService {
}
}
private LiveConditionRowDto toRowUnevaluated(PendingCond p) {
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);
Double target = readTargetNumeric(c, visual);
return LiveConditionRowDto.builder()
.id(p.id())
.indicatorType(indType)
@@ -300,14 +303,17 @@ public class LiveConditionStatusService {
return String.format("%.2f", v);
}
private Double readTargetNumeric(JsonNode cond) {
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();
}
String rf = cond.path("rightField").asText("");
if (rf.startsWith("K_")) {
try { return Double.parseDouble(rf.substring(2)); } catch (NumberFormatException ignored) {}
}
@@ -322,6 +322,8 @@ public class LiveStrategyEvaluator {
BarSeries series = ta4jStorage.getOrCreate(market, candleType);
Map<String, Map<String, Object>> indicatorParams =
indicatorSettingsService.getAll(userId, deviceId);
Map<String, Map<String, Object>> indicatorVisual =
indicatorSettingsService.getAllVisual(userId, deviceId);
int barCount = series.getBarCount();
if (barCount < 2) {
@@ -333,10 +335,10 @@ public class LiveStrategyEvaluator {
try {
Rule entryRule = triggerBranchEvaluator.buildTriggerRule(
strategy.getBuyConditionJson(), market, strategyId, "buy",
candleType, indicatorParams, ta4jStorage);
candleType, indicatorParams, indicatorVisual, ta4jStorage);
Rule exitRule = triggerBranchEvaluator.buildTriggerRule(
strategy.getSellConditionJson(), market, strategyId, "sell",
candleType, indicatorParams, ta4jStorage);
candleType, indicatorParams, indicatorVisual, ta4jStorage);
return new TriggerRules(entryRule, exitRule);
} catch (Exception e) {
log.error("[Evaluator] Trigger Rule 빌드 실패 strategyId={}: {}", strategyId, e.getMessage());
@@ -96,6 +96,11 @@ public class StrategyDslToTa4jAdapter {
* 매핑 없으면 원본 반환 (이미 레지스트리 형식인 경우).
*/
private static String toRegistryKey(String dslType) {
return registryKeyForDsl(dslType);
}
/** DSL indicatorType → DB 레지스트리 키 (외부 hline resolver용) */
public static String registryKeyForDsl(String dslType) {
return DSL_TO_REGISTRY.getOrDefault(dslType, dslType);
}
@@ -104,9 +109,18 @@ public class StrategyDslToTa4jAdapter {
public record RuleBuildContext(
BarSeries primarySeries,
Map<String, Map<String, Object>> indicatorParams,
Map<String, Map<String, Object>> indicatorVisual,
String market,
Ta4jStorage storage
) {
/** visual 미전달 호환 */
public RuleBuildContext(BarSeries primarySeries,
Map<String, Map<String, Object>> indicatorParams,
String market,
Ta4jStorage storage) {
this(primarySeries, indicatorParams, Map.of(), market, storage);
}
BarSeries resolveSeries(String candleType) {
String ct = LiveStrategyTimeframeService.normalize(candleType);
if (storage != null && market != null && !market.isBlank()
@@ -119,13 +133,20 @@ public class StrategyDslToTa4jAdapter {
public Rule toRule(JsonNode node, BarSeries series,
Map<String, Map<String, Object>> indicatorParams) {
return toRule(node, new RuleBuildContext(series, indicatorParams, null, null));
return toRule(node, new RuleBuildContext(series, indicatorParams, Map.of(), null, null));
}
public Rule toRule(JsonNode node, BarSeries series,
Map<String, Map<String, Object>> indicatorParams,
String market, Ta4jStorage storage) {
return toRule(node, new RuleBuildContext(series, indicatorParams, market, storage));
return toRule(node, new RuleBuildContext(series, indicatorParams, Map.of(), market, storage));
}
public Rule toRule(JsonNode node, BarSeries series,
Map<String, Map<String, Object>> indicatorParams,
Map<String, Map<String, Object>> indicatorVisual,
String market, Ta4jStorage storage) {
return toRule(node, new RuleBuildContext(series, indicatorParams, indicatorVisual, market, storage));
}
public Rule toRule(JsonNode node, RuleBuildContext ctx) {
@@ -178,7 +199,7 @@ public class StrategyDslToTa4jAdapter {
String ct = node.path("candleType").asText("1m");
BarSeries branchSeries = ctx.resolveSeries(ct);
RuleBuildContext branchCtx = new RuleBuildContext(
branchSeries, ctx.indicatorParams(), ctx.market(), ctx.storage());
branchSeries, ctx.indicatorParams(), ctx.indicatorVisual(), ctx.market(), ctx.storage());
List<Rule> rules = childRules(node, branchCtx);
if (rules.isEmpty()) return new BooleanRule(false);
Rule inner = rules.get(0);
@@ -243,22 +264,22 @@ public class StrategyDslToTa4jAdapter {
private Rule buildAndRule(JsonNode node, BarSeries series,
Map<String, Map<String, Object>> p) {
return buildAndRule(node, new RuleBuildContext(series, p, null, null));
return buildAndRule(node, new RuleBuildContext(series, p, Map.of(), null, null));
}
private Rule buildOrRule(JsonNode node, BarSeries series,
Map<String, Map<String, Object>> p) {
return buildOrRule(node, new RuleBuildContext(series, p, null, null));
return buildOrRule(node, new RuleBuildContext(series, p, Map.of(), null, null));
}
private Rule buildNotRule(JsonNode node, BarSeries series,
Map<String, Map<String, Object>> p) {
return buildNotRule(node, new RuleBuildContext(series, p, null, null));
return buildNotRule(node, new RuleBuildContext(series, p, Map.of(), null, null));
}
private List<Rule> childRules(JsonNode node, BarSeries series,
Map<String, Map<String, Object>> p) {
return childRules(node, new RuleBuildContext(series, p, null, null));
return childRules(node, new RuleBuildContext(series, p, Map.of(), null, null));
}
// ── CONDITION ─────────────────────────────────────────────────────────────
@@ -299,8 +320,8 @@ public class StrategyDslToTa4jAdapter {
: Map.of();
try {
Indicator<Num> left = resolveField(leftField, indType, indParams, series, condPeriod, leftPeriod);
Indicator<Num> right = resolveField(rightField, indType, indParams, series, condPeriod, rightPeriod);
Indicator<Num> left = resolveField(leftField, indType, indParams, series, condPeriod, leftPeriod, cond, ctx);
Indicator<Num> right = resolveField(rightField, indType, indParams, series, condPeriod, rightPeriod, cond, ctx);
Rule core = switch (condType) {
case "GT" -> new OverIndicatorRule(left, right);
@@ -447,8 +468,8 @@ public class StrategyDslToTa4jAdapter {
BarSeries rightSeries = ctx.resolveSeries(rightCt);
try {
Indicator<Num> left = resolveField(leftField, indType, indParams, leftSeries, condPeriod, leftPeriod);
Indicator<Num> right = resolveField(rightField, indType, indParams, rightSeries, condPeriod, rightPeriod);
Indicator<Num> left = resolveField(leftField, indType, indParams, leftSeries, condPeriod, leftPeriod, cond, ctx);
Indicator<Num> right = resolveField(rightField, indType, indParams, rightSeries, condPeriod, rightPeriod, cond, ctx);
return new CrossTimeframeCompositeRule(condType, left, right, trigger, leftSeries, rightSeries);
} catch (Exception e) {
log.warn("[Adapter] 복합 교차시간봉 빌드 실패 ind={} cond={}: {}",
@@ -515,7 +536,8 @@ public class StrategyDslToTa4jAdapter {
private Indicator<Num> resolveField(String field, String indType,
Map<String, Object> p, BarSeries s,
int condPeriod, int sidePeriod) {
int condPeriod, int sidePeriod,
JsonNode cond, RuleBuildContext ctx) {
if (field == null || field.isBlank() || field.equals("NONE")) {
return new ConstantIndicator<>(s, s.numFactory().numOf(0));
}
@@ -526,6 +548,11 @@ public class StrategyDslToTa4jAdapter {
case "LOW_PRICE" -> new LowPriceIndicator(s);
case "VOLUME_VALUE" -> new VolumeIndicator(s, 1);
default -> {
Double hlineVal = IndicatorHlineResolver.resolveThresholdField(
cond, field, indType, ctx.indicatorVisual());
if (hlineVal != null) {
yield new ConstantIndicator<>(s, s.numFactory().numOf(hlineVal));
}
double constant = resolveConstantField(field);
if (!Double.isNaN(constant)) {
yield new ConstantIndicator<>(s, s.numFactory().numOf(constant));
@@ -535,6 +562,14 @@ public class StrategyDslToTa4jAdapter {
};
}
/** @deprecated cond/ctx 없이 호출 — hline 역할(HL_OVER) 미해석 */
private Indicator<Num> resolveField(String field, String indType,
Map<String, Object> p, BarSeries s,
int condPeriod, int sidePeriod) {
return resolveField(field, indType, p, s, condPeriod, sidePeriod, null,
new RuleBuildContext(s, Map.of(), Map.of(), null, null));
}
private double resolveConstantField(String field) {
// K_ 접두사: 프론트엔드가 hline 실제값으로 생성한 상수 필드
// 예) K_60 → 60, K_-100 → -100, K_0 → 0
@@ -47,6 +47,7 @@ public class StrategyTriggerBranchEvaluator {
String side,
String triggerCandleType,
Map<String, Map<String, Object>> indicatorParams,
Map<String, Map<String, Object>> indicatorVisual,
Ta4jStorage storage) {
BranchScope scope = parseScope(conditionJson);
if (scope.branches().isEmpty()) return new BooleanRule(false);
@@ -56,6 +57,7 @@ public class StrategyTriggerBranchEvaluator {
new StrategyDslToTa4jAdapter.RuleBuildContext(
storage.getOrCreate(market, trigger),
indicatorParams,
indicatorVisual != null ? indicatorVisual : Map.of(),
market,
storage);
@@ -230,7 +232,7 @@ public class StrategyTriggerBranchEvaluator {
StrategyDslToTa4jAdapter.RuleBuildContext branchCtx =
new StrategyDslToTa4jAdapter.RuleBuildContext(
branchSeries, ctx.indicatorParams(), ctx.market(), ctx.storage());
branchSeries, ctx.indicatorParams(), ctx.indicatorVisual(), ctx.market(), ctx.storage());
try {
Rule rule = adapter.toRule(subtree, branchCtx);
return rule.isSatisfied(branchIndex, null);