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

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 = Map<String, Map<String, Object>> params =
indicatorSettingsService.getAll(userId, deviceId); indicatorSettingsService.getAll(userId, deviceId);
Map<String, Map<String, Object>> visual =
indicatorSettingsService.getAllVisual(userId, deviceId);
List<PendingCond> pending = new ArrayList<>(); List<PendingCond> pending = new ArrayList<>();
collectConditions(strategy.getBuyConditionJson(), "1m", "buy", pending); collectConditions(strategy.getBuyConditionJson(), "1m", "buy", pending);
@@ -84,12 +86,12 @@ public class LiveConditionStatusService {
for (PendingCond p : pending) { for (PendingCond p : pending) {
String tf = LiveStrategyTimeframeService.normalize(p.timeframe()); String tf = LiveStrategyTimeframeService.normalize(p.timeframe());
if (!ta4jStorage.exists(market, tf)) { if (!ta4jStorage.exists(market, tf)) {
rows.add(toRowUnevaluated(p)); rows.add(toRowUnevaluated(p, visual));
continue; continue;
} }
BarSeries series = ta4jStorage.getOrCreate(market, tf); BarSeries series = ta4jStorage.getOrCreate(market, tf);
if (series.isEmpty()) { if (series.isEmpty()) {
rows.add(toRowUnevaluated(p)); rows.add(toRowUnevaluated(p, visual));
continue; continue;
} }
int index = series.getEndIndex(); int index = series.getEndIndex();
@@ -100,12 +102,12 @@ public class LiveConditionStatusService {
StrategyDslToTa4jAdapter.RuleBuildContext ctx = StrategyDslToTa4jAdapter.RuleBuildContext ctx =
new StrategyDslToTa4jAdapter.RuleBuildContext( new StrategyDslToTa4jAdapter.RuleBuildContext(
series, params, market, ta4jStorage); series, params, visual, market, ta4jStorage);
Rule rule = adapter.toRule(wrapper, ctx); Rule rule = adapter.toRule(wrapper, ctx);
boolean satisfied = rule.isSatisfied(index, null); boolean satisfied = rule.isSatisfied(index, null);
Double current = adapter.readConditionFieldValue( Double current = adapter.readConditionFieldValue(
p.condition(), series, params, index, true); p.condition(), series, params, index, true);
Double target = readTargetNumeric(p.condition()); Double target = readTargetNumeric(p.condition(), visual);
if (target == null) { if (target == null) {
target = adapter.readConditionFieldValue( target = adapter.readConditionFieldValue(
p.condition(), series, params, index, false); p.condition(), series, params, index, false);
@@ -115,7 +117,7 @@ public class LiveConditionStatusService {
if (satisfied) met++; if (satisfied) met++;
} catch (Exception e) { } catch (Exception e) {
log.debug("[LiveCondition] eval fail {} {}: {}", market, p.id(), e.getMessage()); 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(); JsonNode c = p.condition();
String indType = c.path("indicatorType").asText(""); String indType = c.path("indicatorType").asText("");
String plotKey = plotKeyFromCondition(c, indType); String plotKey = plotKeyFromCondition(c, indType);
String condType = c.path("conditionType").asText("GT"); String condType = c.path("conditionType").asText("GT");
String condLabel = CONDITION_LABEL.getOrDefault(condType, condType); String condLabel = CONDITION_LABEL.getOrDefault(condType, condType);
Double target = readTargetNumeric(c); Double target = readTargetNumeric(c, visual);
return LiveConditionRowDto.builder() return LiveConditionRowDto.builder()
.id(p.id()) .id(p.id())
.indicatorType(indType) .indicatorType(indType)
@@ -300,14 +303,17 @@ public class LiveConditionStatusService {
return String.format("%.2f", v); 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()) { if (cond.has("targetValue") && !cond.path("targetValue").isNull()) {
return cond.path("targetValue").asDouble(); return cond.path("targetValue").asDouble();
} }
if (cond.has("compareValue") && !cond.path("compareValue").isNull()) { if (cond.has("compareValue") && !cond.path("compareValue").isNull()) {
return cond.path("compareValue").asDouble(); return cond.path("compareValue").asDouble();
} }
String rf = cond.path("rightField").asText("");
if (rf.startsWith("K_")) { if (rf.startsWith("K_")) {
try { return Double.parseDouble(rf.substring(2)); } catch (NumberFormatException ignored) {} try { return Double.parseDouble(rf.substring(2)); } catch (NumberFormatException ignored) {}
} }
@@ -322,6 +322,8 @@ public class LiveStrategyEvaluator {
BarSeries series = ta4jStorage.getOrCreate(market, candleType); BarSeries series = ta4jStorage.getOrCreate(market, candleType);
Map<String, Map<String, Object>> indicatorParams = Map<String, Map<String, Object>> indicatorParams =
indicatorSettingsService.getAll(userId, deviceId); indicatorSettingsService.getAll(userId, deviceId);
Map<String, Map<String, Object>> indicatorVisual =
indicatorSettingsService.getAllVisual(userId, deviceId);
int barCount = series.getBarCount(); int barCount = series.getBarCount();
if (barCount < 2) { if (barCount < 2) {
@@ -333,10 +335,10 @@ public class LiveStrategyEvaluator {
try { try {
Rule entryRule = triggerBranchEvaluator.buildTriggerRule( Rule entryRule = triggerBranchEvaluator.buildTriggerRule(
strategy.getBuyConditionJson(), market, strategyId, "buy", strategy.getBuyConditionJson(), market, strategyId, "buy",
candleType, indicatorParams, ta4jStorage); candleType, indicatorParams, indicatorVisual, ta4jStorage);
Rule exitRule = triggerBranchEvaluator.buildTriggerRule( Rule exitRule = triggerBranchEvaluator.buildTriggerRule(
strategy.getSellConditionJson(), market, strategyId, "sell", strategy.getSellConditionJson(), market, strategyId, "sell",
candleType, indicatorParams, ta4jStorage); candleType, indicatorParams, indicatorVisual, ta4jStorage);
return new TriggerRules(entryRule, exitRule); return new TriggerRules(entryRule, exitRule);
} catch (Exception e) { } catch (Exception e) {
log.error("[Evaluator] Trigger Rule 빌드 실패 strategyId={}: {}", strategyId, e.getMessage()); log.error("[Evaluator] Trigger Rule 빌드 실패 strategyId={}: {}", strategyId, e.getMessage());
@@ -96,6 +96,11 @@ public class StrategyDslToTa4jAdapter {
* 매핑 없으면 원본 반환 (이미 레지스트리 형식인 경우). * 매핑 없으면 원본 반환 (이미 레지스트리 형식인 경우).
*/ */
private static String toRegistryKey(String dslType) { 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); return DSL_TO_REGISTRY.getOrDefault(dslType, dslType);
} }
@@ -104,9 +109,18 @@ public class StrategyDslToTa4jAdapter {
public record RuleBuildContext( public record RuleBuildContext(
BarSeries primarySeries, BarSeries primarySeries,
Map<String, Map<String, Object>> indicatorParams, Map<String, Map<String, Object>> indicatorParams,
Map<String, Map<String, Object>> indicatorVisual,
String market, String market,
Ta4jStorage storage 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) { BarSeries resolveSeries(String candleType) {
String ct = LiveStrategyTimeframeService.normalize(candleType); String ct = LiveStrategyTimeframeService.normalize(candleType);
if (storage != null && market != null && !market.isBlank() if (storage != null && market != null && !market.isBlank()
@@ -119,13 +133,20 @@ public class StrategyDslToTa4jAdapter {
public Rule toRule(JsonNode node, BarSeries series, public Rule toRule(JsonNode node, BarSeries series,
Map<String, Map<String, Object>> indicatorParams) { 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, public Rule toRule(JsonNode node, BarSeries series,
Map<String, Map<String, Object>> indicatorParams, Map<String, Map<String, Object>> indicatorParams,
String market, Ta4jStorage storage) { 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) { public Rule toRule(JsonNode node, RuleBuildContext ctx) {
@@ -178,7 +199,7 @@ public class StrategyDslToTa4jAdapter {
String ct = node.path("candleType").asText("1m"); String ct = node.path("candleType").asText("1m");
BarSeries branchSeries = ctx.resolveSeries(ct); BarSeries branchSeries = ctx.resolveSeries(ct);
RuleBuildContext branchCtx = new RuleBuildContext( 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); List<Rule> rules = childRules(node, branchCtx);
if (rules.isEmpty()) return new BooleanRule(false); if (rules.isEmpty()) return new BooleanRule(false);
Rule inner = rules.get(0); Rule inner = rules.get(0);
@@ -243,22 +264,22 @@ public class StrategyDslToTa4jAdapter {
private Rule buildAndRule(JsonNode node, BarSeries series, private Rule buildAndRule(JsonNode node, BarSeries series,
Map<String, Map<String, Object>> p) { 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, private Rule buildOrRule(JsonNode node, BarSeries series,
Map<String, Map<String, Object>> p) { 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, private Rule buildNotRule(JsonNode node, BarSeries series,
Map<String, Map<String, Object>> p) { 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, private List<Rule> childRules(JsonNode node, BarSeries series,
Map<String, Map<String, Object>> p) { 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 ───────────────────────────────────────────────────────────── // ── CONDITION ─────────────────────────────────────────────────────────────
@@ -299,8 +320,8 @@ public class StrategyDslToTa4jAdapter {
: Map.of(); : Map.of();
try { try {
Indicator<Num> left = resolveField(leftField, indType, indParams, series, condPeriod, leftPeriod); Indicator<Num> left = resolveField(leftField, indType, indParams, series, condPeriod, leftPeriod, cond, ctx);
Indicator<Num> right = resolveField(rightField, indType, indParams, series, condPeriod, rightPeriod); Indicator<Num> right = resolveField(rightField, indType, indParams, series, condPeriod, rightPeriod, cond, ctx);
Rule core = switch (condType) { Rule core = switch (condType) {
case "GT" -> new OverIndicatorRule(left, right); case "GT" -> new OverIndicatorRule(left, right);
@@ -447,8 +468,8 @@ public class StrategyDslToTa4jAdapter {
BarSeries rightSeries = ctx.resolveSeries(rightCt); BarSeries rightSeries = ctx.resolveSeries(rightCt);
try { try {
Indicator<Num> left = resolveField(leftField, indType, indParams, leftSeries, condPeriod, leftPeriod); Indicator<Num> left = resolveField(leftField, indType, indParams, leftSeries, condPeriod, leftPeriod, cond, ctx);
Indicator<Num> right = resolveField(rightField, indType, indParams, rightSeries, condPeriod, rightPeriod); Indicator<Num> right = resolveField(rightField, indType, indParams, rightSeries, condPeriod, rightPeriod, cond, ctx);
return new CrossTimeframeCompositeRule(condType, left, right, trigger, leftSeries, rightSeries); return new CrossTimeframeCompositeRule(condType, left, right, trigger, leftSeries, rightSeries);
} catch (Exception e) { } catch (Exception e) {
log.warn("[Adapter] 복합 교차시간봉 빌드 실패 ind={} cond={}: {}", log.warn("[Adapter] 복합 교차시간봉 빌드 실패 ind={} cond={}: {}",
@@ -515,7 +536,8 @@ public class StrategyDslToTa4jAdapter {
private Indicator<Num> resolveField(String field, String indType, private Indicator<Num> resolveField(String field, String indType,
Map<String, Object> p, BarSeries s, 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")) { if (field == null || field.isBlank() || field.equals("NONE")) {
return new ConstantIndicator<>(s, s.numFactory().numOf(0)); return new ConstantIndicator<>(s, s.numFactory().numOf(0));
} }
@@ -526,6 +548,11 @@ public class StrategyDslToTa4jAdapter {
case "LOW_PRICE" -> new LowPriceIndicator(s); case "LOW_PRICE" -> new LowPriceIndicator(s);
case "VOLUME_VALUE" -> new VolumeIndicator(s, 1); case "VOLUME_VALUE" -> new VolumeIndicator(s, 1);
default -> { 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); double constant = resolveConstantField(field);
if (!Double.isNaN(constant)) { if (!Double.isNaN(constant)) {
yield new ConstantIndicator<>(s, s.numFactory().numOf(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) { private double resolveConstantField(String field) {
// K_ 접두사: 프론트엔드가 hline 실제값으로 생성한 상수 필드 // K_ 접두사: 프론트엔드가 hline 실제값으로 생성한 상수 필드
// 예) K_60 → 60, K_-100 → -100, K_0 → 0 // 예) K_60 → 60, K_-100 → -100, K_0 → 0
@@ -47,6 +47,7 @@ public class StrategyTriggerBranchEvaluator {
String side, String side,
String triggerCandleType, String triggerCandleType,
Map<String, Map<String, Object>> indicatorParams, Map<String, Map<String, Object>> indicatorParams,
Map<String, Map<String, Object>> indicatorVisual,
Ta4jStorage storage) { Ta4jStorage storage) {
BranchScope scope = parseScope(conditionJson); BranchScope scope = parseScope(conditionJson);
if (scope.branches().isEmpty()) return new BooleanRule(false); if (scope.branches().isEmpty()) return new BooleanRule(false);
@@ -56,6 +57,7 @@ public class StrategyTriggerBranchEvaluator {
new StrategyDslToTa4jAdapter.RuleBuildContext( new StrategyDslToTa4jAdapter.RuleBuildContext(
storage.getOrCreate(market, trigger), storage.getOrCreate(market, trigger),
indicatorParams, indicatorParams,
indicatorVisual != null ? indicatorVisual : Map.of(),
market, market,
storage); storage);
@@ -230,7 +232,7 @@ public class StrategyTriggerBranchEvaluator {
StrategyDslToTa4jAdapter.RuleBuildContext branchCtx = StrategyDslToTa4jAdapter.RuleBuildContext branchCtx =
new StrategyDslToTa4jAdapter.RuleBuildContext( new StrategyDslToTa4jAdapter.RuleBuildContext(
branchSeries, ctx.indicatorParams(), ctx.market(), ctx.storage()); branchSeries, ctx.indicatorParams(), ctx.indicatorVisual(), ctx.market(), ctx.storage());
try { try {
Rule rule = adapter.toRule(subtree, branchCtx); Rule rule = adapter.toRule(subtree, branchCtx);
return rule.isSatisfied(branchIndex, null); return rule.isSatisfied(branchIndex, null);
@@ -66,7 +66,7 @@ class StrategyTriggerBranchEvaluatorTest {
"""; """;
Rule rule1m = evaluator.buildTriggerRule(json, MARKET, STRATEGY_ID, "buy", Rule rule1m = evaluator.buildTriggerRule(json, MARKET, STRATEGY_ID, "buy",
"1m", Map.of(), storage); "1m", Map.of(), Map.of(), storage);
BarSeries s1m = storage.getOrCreate(MARKET, "1m"); BarSeries s1m = storage.getOrCreate(MARKET, "1m");
int idx = s1m.getEndIndex(); int idx = s1m.getEndIndex();
assertDoesNotThrow(() -> rule1m.isSatisfied(idx, null)); assertDoesNotThrow(() -> rule1m.isSatisfied(idx, null));
@@ -98,7 +98,7 @@ class StrategyTriggerBranchEvaluatorTest {
"""; """;
Rule rule1m = evaluator.buildTriggerRule(json, MARKET, STRATEGY_ID, "sell", Rule rule1m = evaluator.buildTriggerRule(json, MARKET, STRATEGY_ID, "sell",
"1m", Map.of(), storage); "1m", Map.of(), Map.of(), storage);
BarSeries s1m = storage.getOrCreate(MARKET, "1m"); BarSeries s1m = storage.getOrCreate(MARKET, "1m");
int idx1m = s1m.getEndIndex(); int idx1m = s1m.getEndIndex();
@@ -134,7 +134,7 @@ class StrategyTriggerBranchEvaluatorTest {
assertEquals("5m", scope.branches().get(2).candleType()); assertEquals("5m", scope.branches().get(2).candleType());
Rule on1m = evaluator.buildTriggerRule(json, MARKET, STRATEGY_ID, "buy", Rule on1m = evaluator.buildTriggerRule(json, MARKET, STRATEGY_ID, "buy",
"1m", Map.of(), storage); "1m", Map.of(), Map.of(), storage);
BarSeries s1m = storage.getOrCreate(MARKET, "1m"); BarSeries s1m = storage.getOrCreate(MARKET, "1m");
on1m.isSatisfied(s1m.getEndIndex(), null); on1m.isSatisfied(s1m.getEndIndex(), null);
assertNotNull(branchCache.get(MARKET, STRATEGY_ID, "buy", "1m")); assertNotNull(branchCache.get(MARKET, STRATEGY_ID, "buy", "1m"));
@@ -144,14 +144,14 @@ class StrategyTriggerBranchEvaluatorTest {
branchCache.invalidateStrategy(STRATEGY_ID); branchCache.invalidateStrategy(STRATEGY_ID);
Rule on3m = evaluator.buildTriggerRule(json, MARKET, STRATEGY_ID, "buy", Rule on3m = evaluator.buildTriggerRule(json, MARKET, STRATEGY_ID, "buy",
"3m", Map.of(), storage); "3m", Map.of(), Map.of(), storage);
BarSeries s3m = storage.getOrCreate(MARKET, "3m"); BarSeries s3m = storage.getOrCreate(MARKET, "3m");
on3m.isSatisfied(s3m.getEndIndex(), null); on3m.isSatisfied(s3m.getEndIndex(), null);
assertNotNull(branchCache.get(MARKET, STRATEGY_ID, "buy", "3m")); assertNotNull(branchCache.get(MARKET, STRATEGY_ID, "buy", "3m"));
assertNull(branchCache.get(MARKET, STRATEGY_ID, "buy", "1m")); assertNull(branchCache.get(MARKET, STRATEGY_ID, "buy", "1m"));
Rule on5mWrongTrigger = evaluator.buildTriggerRule(json, MARKET, STRATEGY_ID, "buy", Rule on5mWrongTrigger = evaluator.buildTriggerRule(json, MARKET, STRATEGY_ID, "buy",
"1m", Map.of(), storage); "1m", Map.of(), Map.of(), storage);
assertFalse(on5mWrongTrigger.isSatisfied(s3m.getEndIndex(), null), assertFalse(on5mWrongTrigger.isSatisfied(s3m.getEndIndex(), null),
"3m 인덱스로 1m 트리거 Rule 평가 시 false"); "3m 인덱스로 1m 트리거 Rule 평가 시 false");
} }
@@ -177,12 +177,12 @@ class StrategyTriggerBranchEvaluatorTest {
assertEquals("3m", scope.branches().get(0).candleType()); assertEquals("3m", scope.branches().get(0).candleType());
Rule on3m = evaluator.buildTriggerRule(json, MARKET, STRATEGY_ID, "buy", Rule on3m = evaluator.buildTriggerRule(json, MARKET, STRATEGY_ID, "buy",
"3m", Map.of(), storage); "3m", Map.of(), Map.of(), storage);
BarSeries s3m = storage.getOrCreate(MARKET, "3m"); BarSeries s3m = storage.getOrCreate(MARKET, "3m");
assertDoesNotThrow(() -> on3m.isSatisfied(s3m.getEndIndex(), null)); assertDoesNotThrow(() -> on3m.isSatisfied(s3m.getEndIndex(), null));
Rule on1m = evaluator.buildTriggerRule(json, MARKET, STRATEGY_ID, "buy", Rule on1m = evaluator.buildTriggerRule(json, MARKET, STRATEGY_ID, "buy",
"1m", Map.of(), storage); "1m", Map.of(), Map.of(), storage);
assertFalse(on1m.isSatisfied(storage.getOrCreate(MARKET, "1m").getEndIndex(), null)); assertFalse(on1m.isSatisfied(storage.getOrCreate(MARKET, "1m").getEndIndex(), null));
} }
+11 -6
View File
@@ -34,6 +34,7 @@ import {
updateStartCandleTypes, updateStartCandleTypes,
type EditorConditionState, type EditorConditionState,
} from '../utils/strategyConditionSerde'; } from '../utils/strategyConditionSerde';
import { migrateLogicRootForEditor } from '../utils/strategyConditionNormalize';
import { persistStrategyEvaluationTimeframes } from '../utils/strategyTimeframeSync'; import { persistStrategyEvaluationTimeframes } from '../utils/strategyTimeframeSync';
import { import {
START_NODE_ID, START_NODE_ID,
@@ -141,7 +142,7 @@ export default function StrategyEditorPage({ theme }: Props) {
() => buildStrategyEditorDefFromSettings(getParams, getVisualConfig), () => buildStrategyEditorDefFromSettings(getParams, getVisualConfig),
[getParams, getVisualConfig, settingsRevision], [getParams, getVisualConfig, settingsRevision],
); );
const settingsSyncRef = useRef(0); const settingsSyncRef = useRef(-1);
const [strategies, setStrategies] = useState<StrategyDto[]>(() => loadStratsLocal()); const [strategies, setStrategies] = useState<StrategyDto[]>(() => loadStratsLocal());
const [selectedId, setSelectedId] = useState<number | null>(null); const [selectedId, setSelectedId] = useState<number | null>(null);
@@ -289,6 +290,7 @@ export default function StrategyEditorPage({ theme }: Props) {
/** 보조지표 설정 변경 시 오버라이드되지 않은 조건 노드·캔버스 기준값 동기화 */ /** 보조지표 설정 변경 시 오버라이드되지 않은 조건 노드·캔버스 기준값 동기화 */
useEffect(() => { useEffect(() => {
if (!settingsLoaded) return;
if (settingsRevision <= settingsSyncRef.current) return; if (settingsRevision <= settingsSyncRef.current) return;
settingsSyncRef.current = settingsRevision; settingsSyncRef.current = settingsRevision;
@@ -316,7 +318,7 @@ export default function StrategyEditorPage({ theme }: Props) {
extraRoots: syncExtraRoots(prev.extraRoots ?? {}, 'sell'), extraRoots: syncExtraRoots(prev.extraRoots ?? {}, 'sell'),
})); }));
bumpLayoutSeed(layoutStrategyKey, signalTab); bumpLayoutSeed(layoutStrategyKey, signalTab);
}, [settingsRevision, DEF, layoutStrategyKey, signalTab, bumpLayoutSeed]); }, [settingsLoaded, settingsRevision, DEF, layoutStrategyKey, signalTab, bumpLayoutSeed]);
const resetFlowLayout = useCallback((strategyKey: string, tab: 'buy' | 'sell' = 'buy') => { const resetFlowLayout = useCallback((strategyKey: string, tab: 'buy' | 'sell' = 'buy') => {
const emptyBuy = emptySignalFlowLayout(); const emptyBuy = emptySignalFlowLayout();
@@ -553,15 +555,18 @@ export default function StrategyEditorPage({ theme }: Props) {
const sellDecoded = decodeConditionForEditor(s.sellCondition ?? null); const sellDecoded = decodeConditionForEditor(s.sellCondition ?? null);
const stored = loadStrategyFlowLayout(String(s.id)); const stored = loadStrategyFlowLayout(String(s.id));
const buyMigrated = migrateLogicRootForEditor(buyDecoded.root, stored?.buy.orphans ?? [], DEF, 'buy');
const sellMigrated = migrateLogicRootForEditor(sellDecoded.root, stored?.sell.orphans ?? [], DEF, 'sell');
const buySynced = syncLogicRootWithGlobalDef( const buySynced = syncLogicRootWithGlobalDef(
buyDecoded.root, buyMigrated.root,
stored?.buy.orphans ?? [], buyMigrated.orphans,
DEF, DEF,
'buy', 'buy',
); );
const sellSynced = syncLogicRootWithGlobalDef( const sellSynced = syncLogicRootWithGlobalDef(
sellDecoded.root, sellMigrated.root,
stored?.sell.orphans ?? [], sellMigrated.orphans,
DEF, DEF,
'sell', 'sell',
); );
+1 -1
View File
@@ -511,7 +511,7 @@ const IndDropdown: React.FC<IndDropdownProps> = ({
onHeaderPointerDown, headerTouchStyle, onHeaderPointerDown, headerTouchStyle,
panelStyle, panelStyle,
headerCursor, headerCursor,
} = useDraggablePanel({ centerOnMount: true, initialPosition: { x: 0, y: 56 } }); } = useDraggablePanel({ centerOnMount: true });
return ( return (
// 배경 클릭 시 닫힘 // 배경 클릭 시 닫힘
@@ -6,6 +6,8 @@ import {
getCompositeRightCandleType, getCompositeRightCandleType,
getConditionRightPeriod, getConditionRightPeriod,
getConditionThreshold, getConditionThreshold,
getChartReferenceThreshold,
isThresholdOverridden,
getConditionValuePeriod, getConditionValuePeriod,
getCompositePeriodPresetOptions, getCompositePeriodPresetOptions,
getPeriodPresetOptions, getPeriodPresetOptions,
@@ -52,8 +54,9 @@ export default function ConditionNodeSettings({
}, [onClose]); }, [onClose]);
const inheritedThreshold = getDefaultConditionFields(condition.indicatorType, signalType, def).r; const inheritedThreshold = getDefaultConditionFields(condition.indicatorType, signalType, def).r;
const rightThreshold = getConditionThreshold(condition, 'right', inheritedThreshold); const chartRef = getChartReferenceThreshold(condition, 'right', inheritedThreshold, def);
const showThreshold = hasEditableThreshold(condition) && rightThreshold != null; const strategyThreshold = getConditionThreshold(condition, 'right', inheritedThreshold, def);
const showThreshold = hasEditableThreshold(condition) && chartRef != null;
const periodPresets = getPeriodPresetOptions(condition.indicatorType, def); const periodPresets = getPeriodPresetOptions(condition.indicatorType, def);
const thresholdPresets = getThresholdPresetOptions(condition.indicatorType); const thresholdPresets = getThresholdPresetOptions(condition.indicatorType);
const thresholdBounds = getThresholdBounds(condition.indicatorType); const thresholdBounds = getThresholdBounds(condition.indicatorType);
@@ -139,18 +142,24 @@ export default function ConditionNodeSettings({
/> />
</label> </label>
) : null} ) : null}
{showThreshold && rightThreshold != null && ( {showThreshold && chartRef != null && (
<label className="se-flow-settings-field"> <>
<span></span> <label className="se-flow-settings-field se-flow-settings-field--readonly">
<ComboNumberInput <span> ( )</span>
value={rightThreshold} <output className="se-flow-settings-readout">{chartRef}</output>
options={thresholdPresets} </label>
min={thresholdBounds.min} <label className="se-flow-settings-field">
max={thresholdBounds.max} <span> {isThresholdOverridden(condition) ? ' (전용)' : ''}</span>
allowDecimal <ComboNumberInput
onChange={v => onChange(setConditionThreshold(condition, 'right', v))} value={strategyThreshold ?? chartRef}
/> options={thresholdPresets}
</label> min={thresholdBounds.min}
max={thresholdBounds.max}
allowDecimal
onChange={v => onChange(setConditionThreshold(condition, 'right', v, def))}
/>
</label>
</>
)} )}
{condition.composite && ( {condition.composite && (
<p className="se-flow-settings-hint">{compositeDisplayName(condition.indicatorType)}</p> <p className="se-flow-settings-hint">{compositeDisplayName(condition.indicatorType)}</p>
+19 -3
View File
@@ -14,6 +14,17 @@ export interface UseDraggablePanelOptions {
} }
const DEFAULT_POS: DraggablePosition = { x: 80, y: 72 }; const DEFAULT_POS: DraggablePosition = { x: 80, y: 72 };
const DEFAULT_CENTER_ESTIMATE = { width: 520, height: 560 };
function estimateCenterPosition(
width = DEFAULT_CENTER_ESTIMATE.width,
height = DEFAULT_CENTER_ESTIMATE.height,
): DraggablePosition {
return {
x: Math.max(8, (window.innerWidth - width) / 2),
y: Math.max(8, (window.innerHeight - height) / 2),
};
}
/** /**
* 팝업 패널 타이틀바 드래그 이동 (마우스·터치·펜) * 팝업 패널 타이틀바 드래그 이동 (마우스·터치·펜)
@@ -27,7 +38,10 @@ export function useDraggablePanel(options: UseDraggablePanelOptions = {}) {
const internalRef = useRef<HTMLDivElement>(null); const internalRef = useRef<HTMLDivElement>(null);
const panelRef = (externalRef ?? internalRef) as RefObject<HTMLDivElement | null>; const panelRef = (externalRef ?? internalRef) as RefObject<HTMLDivElement | null>;
const [pos, setPos] = useState<DraggablePosition>(initialPosition); const [pos, setPos] = useState<DraggablePosition>(() =>
centerOnMount ? estimateCenterPosition() : initialPosition,
);
const [positionReady, setPositionReady] = useState(!centerOnMount);
const [dragging, setDragging] = useState(false); const [dragging, setDragging] = useState(false);
const dragOrigin = useRef({ mx: 0, my: 0, px: 0, py: 0 }); const dragOrigin = useRef({ mx: 0, my: 0, px: 0, py: 0 });
const centeredOnce = useRef(false); const centeredOnce = useRef(false);
@@ -39,13 +53,14 @@ export function useDraggablePanel(options: UseDraggablePanelOptions = {}) {
if (!el) return; if (!el) return;
const placeCenter = () => { const placeCenter = () => {
const w = el.offsetWidth || 400; const w = el.offsetWidth || DEFAULT_CENTER_ESTIMATE.width;
const h = el.offsetHeight || 320; const h = el.offsetHeight || DEFAULT_CENTER_ESTIMATE.height;
setPos({ setPos({
x: Math.max(8, (window.innerWidth - w) / 2), x: Math.max(8, (window.innerWidth - w) / 2),
y: Math.max(8, (window.innerHeight - h) / 2), y: Math.max(8, (window.innerHeight - h) / 2),
}); });
centeredOnce.current = true; centeredOnce.current = true;
setPositionReady(true);
}; };
placeCenter(); placeCenter();
@@ -95,6 +110,7 @@ export function useDraggablePanel(options: UseDraggablePanelOptions = {}) {
position: 'fixed', position: 'fixed',
left: pos.x, left: pos.x,
top: pos.y, top: pos.y,
...(centerOnMount && !positionReady ? { visibility: 'hidden' as const } : null),
}; };
return { return {
+21 -3
View File
@@ -67,6 +67,9 @@ let _loadPromise: Promise<AllSettings> | null = null;
let _visualCache: VisualCache | null = null; let _visualCache: VisualCache | null = null;
let _visualLoadPromise: Promise<VisualCache> | null = null; let _visualLoadPromise: Promise<VisualCache> | null = null;
/** 로그인 세션별 1회만 DB 로드 — 페이지 전환 시 캐시 유지 */
let _loadedSessionKey: number | null = null;
function ensureLoaded(): Promise<AllSettings> { function ensureLoaded(): Promise<AllSettings> {
if (_cache !== null) return Promise.resolve(_cache); if (_cache !== null) return Promise.resolve(_cache);
if (_loadPromise) return _loadPromise; if (_loadPromise) return _loadPromise;
@@ -110,6 +113,7 @@ export function invalidateIndicatorSettingsCache() {
_loadPromise = null; _loadPromise = null;
_visualCache = null; _visualCache = null;
_visualLoadPromise = null; _visualLoadPromise = null;
_loadedSessionKey = null;
} }
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
@@ -120,11 +124,24 @@ export function useIndicatorSettings(sessionKey = 0) {
() => _cache !== null && _visualCache !== null, () => _cache !== null && _visualCache !== null,
); );
// 로그인/로그아웃 시 지표 설정 재로드 // 로그인/로그아웃 시 지표 설정 재로드 (페이지 전환만으로는 캐시 유지)
useEffect(() => { useEffect(() => {
setSettingsLoaded(false);
invalidateIndicatorSettingsCache();
const unsub = subscribeIndicatorSettings(() => setSettingsRevision(r => r + 1)); const unsub = subscribeIndicatorSettings(() => setSettingsRevision(r => r + 1));
const cacheWarm = _loadedSessionKey === sessionKey
&& _cache !== null
&& _visualCache !== null;
if (cacheWarm) {
setSettingsLoaded(true);
return unsub;
}
if (_loadedSessionKey !== sessionKey) {
invalidateIndicatorSettingsCache();
}
setSettingsLoaded(false);
Promise.all([ Promise.all([
ensureLoaded().catch(err => ensureLoaded().catch(err =>
console.error('[useIndicatorSettings] params load failed', err), console.error('[useIndicatorSettings] params load failed', err),
@@ -133,6 +150,7 @@ export function useIndicatorSettings(sessionKey = 0) {
console.error('[useIndicatorSettings] visual load failed', err), console.error('[useIndicatorSettings] visual load failed', err),
), ),
]).then(() => { ]).then(() => {
_loadedSessionKey = sessionKey;
setSettingsLoaded(true); setSettingsLoaded(true);
setSettingsRevision(r => r + 1); setSettingsRevision(r => r + 1);
}); });
+3 -2
View File
@@ -103,9 +103,10 @@ export function compositeValueField(ind: string, period: number): string {
} }
} }
/** K_ 접두 임계값 필드 — 기간 파싱·복합지표 승격에서 제외 */ /** K_ 접두 임계값·hline 역할 심볼 — 기간 파싱·복합지표 승격에서 제외 */
export function isThresholdField(field?: string): boolean { export function isThresholdField(field?: string): boolean {
return !!field && field.startsWith('K_'); return !!field && (field.startsWith('K_')
|| field === 'HL_OVER' || field === 'HL_MID' || field === 'HL_UNDER');
} }
const COMPOSITE_VALUE_PREFIX: Record<string, string> = { const COMPOSITE_VALUE_PREFIX: Record<string, string> = {
+86 -22
View File
@@ -18,6 +18,15 @@ import {
parsePriorExtremePeriod, parsePriorExtremePeriod,
syncPriceExtremeSimpleFields, syncPriceExtremeSimpleFields,
} from './priceExtremeIndicators'; } from './priceExtremeIndicators';
import {
isThresholdFieldOrSymbol,
isThresholdSymbol,
resolveInheritedThresholdField,
resolveThresholdFromDef,
THRESHOLD_HL_MID,
THRESHOLD_HL_OVER,
THRESHOLD_HL_UNDER,
} from './thresholdSymbols';
import { DEFAULT_START_CANDLE, normalizeStartCandleType } from './strategyStartNodes'; import { DEFAULT_START_CANDLE, normalizeStartCandleType } from './strategyStartNodes';
export interface IndicatorPeriodDef { export interface IndicatorPeriodDef {
@@ -164,29 +173,99 @@ export function thresholdField(value: number): string {
} }
export function hasEditableThreshold(cond: ConditionDSL): boolean { export function hasEditableThreshold(cond: ConditionDSL): boolean {
return parseThresholdField(cond.rightField) != null return isThresholdFieldOrSymbol(cond.rightField)
|| parseThresholdField(cond.leftField) != null; || isThresholdFieldOrSymbol(cond.leftField)
|| parseThresholdField(cond.rightField) != null;
}
/** 보조지표 차트(DB) hline 기준값 — 전략 오버라이드와 무관하게 항상 DEF에서 조회 */
export function getChartReferenceThreshold(
cond: ConditionDSL,
side: 'left' | 'right',
inheritedField: string | undefined,
def: { hlThresh: Record<string, { over?: number; mid?: number; under?: number }> },
): number | null {
const raw = side === 'left' ? cond.leftField : (inheritedField ?? cond.rightField);
if (!raw) return null;
const field = isThresholdOverridden(cond)
? raw
: resolveInheritedThresholdField(raw, cond.indicatorType, def.hlThresh);
if (!field) return null;
return resolveThresholdFromDef(field, cond.indicatorType, def.hlThresh);
} }
/** /**
* 임계값 표시 — thresholdOverride 가 false 이면 inheritedField(보조지표 설정 DEF) 우선. * 전략 평가용 임계값 — thresholdOverride=true 이면 targetValue/K_, 아니면 차트 DEF.
*/ */
export function getConditionThreshold( export function getConditionThreshold(
cond: ConditionDSL, cond: ConditionDSL,
side: 'left' | 'right', side: 'left' | 'right',
inheritedField?: string, inheritedField?: string,
def?: { hlThresh: Record<string, { over?: number; mid?: number; under?: number }> },
): number | null { ): number | null {
if (!isThresholdOverridden(cond) && inheritedField) {
const fromDef = parseThresholdField(inheritedField);
if (fromDef != null) return fromDef;
}
if (isThresholdOverridden(cond) && cond.targetValue != null && Number.isFinite(cond.targetValue)) { if (isThresholdOverridden(cond) && cond.targetValue != null && Number.isFinite(cond.targetValue)) {
return cond.targetValue; return cond.targetValue;
} }
if (!isThresholdOverridden(cond)) {
const rawField = inheritedField ?? (side === 'left' ? cond.leftField : cond.rightField);
const roleField = def
? resolveInheritedThresholdField(rawField, cond.indicatorType, def.hlThresh)
: rawField;
if (def && roleField) {
const fromDef = resolveThresholdFromDef(roleField, cond.indicatorType, def.hlThresh);
if (fromDef != null) return fromDef;
}
if (inheritedField && def) {
const inheritedRole = resolveInheritedThresholdField(
inheritedField,
cond.indicatorType,
def.hlThresh,
);
const fromInherited = resolveThresholdFromDef(
inheritedRole,
cond.indicatorType,
def.hlThresh,
);
if (fromInherited != null) return fromInherited;
}
}
const field = side === 'left' ? cond.leftField : cond.rightField; const field = side === 'left' ? cond.leftField : cond.rightField;
return parseThresholdField(field); return parseThresholdField(field);
} }
export function setConditionThreshold(
cond: ConditionDSL,
side: 'left' | 'right',
value: number,
def?: { hlThresh: Record<string, { over?: number; mid?: number; under?: number }> },
): ConditionDSL {
const fieldKey = side === 'left' ? 'leftField' : 'rightField';
const current = cond[fieldKey];
if (!isThresholdFieldOrSymbol(current)) return cond;
const inheritedField = side === 'right'
? (current?.startsWith('K_') || isThresholdSymbol(current) ? current : THRESHOLD_HL_OVER)
: current;
const chartRef = def
? getChartReferenceThreshold(cond, side, inheritedField, def)
: null;
if (chartRef != null && Math.abs(chartRef - value) < 0.0001) {
const role = isThresholdSymbol(inheritedField)
? inheritedField
: (side === 'right' ? THRESHOLD_HL_OVER : inheritedField);
const { targetValue: _t, ...rest } = cond;
return { ...rest, [fieldKey]: role, thresholdOverride: false };
}
return {
...cond,
[fieldKey]: thresholdField(value),
targetValue: value,
thresholdOverride: true,
};
}
export function setConditionValuePeriod( export function setConditionValuePeriod(
cond: ConditionDSL, cond: ConditionDSL,
period: number, period: number,
@@ -213,21 +292,6 @@ export function setConditionRightPeriod(cond: ConditionDSL, period: number): Con
return syncCompositeFields({ ...cond, composite: true, rightPeriod: p, rightPeriodOverride: true }); return syncCompositeFields({ ...cond, composite: true, rightPeriod: p, rightPeriodOverride: true });
} }
export function setConditionThreshold(
cond: ConditionDSL,
side: 'left' | 'right',
value: number,
): ConditionDSL {
const fieldKey = side === 'left' ? 'leftField' : 'rightField';
const current = cond[fieldKey];
if (!current?.startsWith('K_')) return cond;
return {
...cond,
[fieldKey]: thresholdField(value),
targetValue: value,
thresholdOverride: true,
};
}
/** 신규 조건 노드 — 보조지표 설정 기본값 상속(오버라이드 없음) */ /** 신규 조건 노드 — 보조지표 설정 기본값 상속(오버라이드 없음) */
export function initConditionPeriodsInherit( export function initConditionPeriodsInherit(
@@ -0,0 +1,141 @@
/** 전략 조건 DSL — 보조지표 DB 설정과 임계값·기간 상inherit 정규화 */
import type { ConditionDSL, LogicNode } from './strategyTypes';
import type { DefType } from './strategyEditorShared';
import { getDefaultConditionFields } from './strategyEditorShared';
import {
isThresholdOverridden,
isValuePeriodOverridden,
parseThresholdField,
usesValuePeriodField,
VALUE_FIELD_PREFIX,
} from './conditionPeriods';
import {
defaultInheritedThresholdField,
inferThresholdSymbolFromLegacy,
isThresholdFieldOrSymbol,
isThresholdSymbol,
THRESHOLD_HL_MID,
THRESHOLD_HL_OVER,
} from './thresholdSymbols';
function migrateConditionThreshold(
cond: ConditionDSL,
def: DefType,
signalType: 'buy' | 'sell',
): ConditionDSL {
if (isThresholdOverridden(cond)) return cond;
let rightField = cond.rightField;
if (rightField?.startsWith('K_')) {
rightField = inferThresholdSymbolFromLegacy(rightField, cond.indicatorType, def.hlThresh);
}
if (!rightField || (!isThresholdSymbol(rightField) && !rightField.startsWith('K_'))) {
const defaults = getDefaultConditionFields(cond.indicatorType, signalType, def);
if (isThresholdFieldOrSymbol(defaults.r)) {
rightField = defaults.r;
}
}
const next: ConditionDSL = {
...cond,
rightField,
thresholdOverride: false,
};
const { targetValue: _t, ...rest } = next;
return rest;
}
function migrateConditionPeriod(cond: ConditionDSL): ConditionDSL {
if (isValuePeriodOverridden(cond)) return cond;
const prefix = VALUE_FIELD_PREFIX[cond.indicatorType];
if (!prefix || !usesValuePeriodField(cond)) return cond;
const lf = cond.leftField ?? '';
if (lf.startsWith(`${prefix}_`)) {
const { period: _p, ...rest } = cond;
return { ...rest, leftField: prefix, valuePeriodOverride: false };
}
return cond;
}
export function migrateConditionForEditor(
cond: ConditionDSL,
def: DefType,
signalType: 'buy' | 'sell',
): ConditionDSL {
return migrateConditionPeriod(migrateConditionThreshold(cond, def, signalType));
}
function migrateLogicNode(
node: LogicNode,
def: DefType,
signalType: 'buy' | 'sell',
): LogicNode {
let next = node;
if (node.type === 'CONDITION' && node.condition) {
next = { ...node, condition: migrateConditionForEditor(node.condition, def, signalType) };
}
if (next.children?.length) {
next = { ...next, children: next.children.map(c => migrateLogicNode(c, def, signalType)) };
}
return next;
}
/** 전략 로드·편집기 진입 시 레거시 K_ 스냅샷 → hline 역할 심볼 */
export function migrateLogicRootForEditor(
root: LogicNode | null,
orphans: LogicNode[],
def: DefType,
signalType: 'buy' | 'sell',
): { root: LogicNode | null; orphans: LogicNode[] } {
return {
root: root ? migrateLogicNode(root, def, signalType) : null,
orphans: orphans.map(n => migrateLogicNode(n, def, signalType)),
};
}
/** 저장 직전 — 상속 모드는 숫자 스냅샷·targetValue 제거 */
export function normalizeConditionForPersistence(cond: ConditionDSL): ConditionDSL {
let next: ConditionDSL = { ...cond };
if (!isThresholdOverridden(next)) {
if (next.rightField?.startsWith('K_')) {
next.rightField = defaultInheritedThresholdField(next.indicatorType);
}
if (!isThresholdSymbol(next.rightField)) {
const role = next.indicatorType === 'DISPARITY' || next.indicatorType === 'VOLUME_OSC'
|| next.indicatorType === 'TRIX'
? THRESHOLD_HL_MID : THRESHOLD_HL_OVER;
if (parseThresholdField(next.rightField) != null || !next.rightField) {
next.rightField = role;
}
}
const { targetValue: _t, ...rest } = next;
next = { ...rest, thresholdOverride: false };
}
if (!isValuePeriodOverridden(next)) {
const prefix = VALUE_FIELD_PREFIX[next.indicatorType];
if (prefix && usesValuePeriodField(next)) {
next.leftField = prefix;
}
const { period: _p, ...rest } = next;
next = rest;
}
return next;
}
function normalizeLogicNode(node: LogicNode): LogicNode {
let next = node;
if (node.type === 'CONDITION' && node.condition) {
next = { ...node, condition: normalizeConditionForPersistence(node.condition) };
}
if (next.children?.length) {
next = { ...next, children: next.children.map(normalizeLogicNode) };
}
return next;
}
export function normalizeLogicRootForPersistence(root: LogicNode | null): LogicNode | null {
return root ? normalizeLogicNode(root) : null;
}
+2 -1
View File
@@ -1,3 +1,4 @@
import { normalizeLogicRootForPersistence } from './strategyConditionNormalize';
import type { LogicNode, ConditionDSL } from './strategyTypes'; import type { LogicNode, ConditionDSL } from './strategyTypes';
import { generateNodeId } from './strategyTypes'; import { generateNodeId } from './strategyTypes';
import { subtreeToFlatOrphans } from './strategyFlowLayout'; import { subtreeToFlatOrphans } from './strategyFlowLayout';
@@ -283,7 +284,7 @@ export function encodeConditionForSave(state: EditorConditionState): LogicNode |
if (branches.length === 1) { if (branches.length === 1) {
const { candleType, candleTypes, root } = branches[0]; const { candleType, candleTypes, root } = branches[0];
const types = candleTypes?.length ? candleTypes : [candleType]; const types = candleTypes?.length ? candleTypes : [candleType];
return wrapTimeframes(types, root); return wrapTimeframes(types, normalizeLogicRootForPersistence(root)!);
} }
const combineOp = normalizeStartCombineOp(state.startCombineOp); const combineOp = normalizeStartCombineOp(state.startCombineOp);
+12 -7
View File
@@ -15,12 +15,14 @@ import {
isRightPeriodOverridden, isRightPeriodOverridden,
isThresholdOverridden, isThresholdOverridden,
isValuePeriodOverridden, isValuePeriodOverridden,
parseThresholdField,
usesValuePeriodField, usesValuePeriodField,
VALUE_FIELD_PREFIX, VALUE_FIELD_PREFIX,
} from './conditionPeriods'; } from './conditionPeriods';
import {
defaultInheritedThresholdField,
inferThresholdSymbolFromLegacy,
} from './thresholdSymbols';
import type { DefType } from './strategyEditorShared'; import type { DefType } from './strategyEditorShared';
import { getDefaultConditionFields } from './strategyEditorShared';
function applyGlobalDefToCondition( function applyGlobalDefToCondition(
cond: ConditionDSL, cond: ConditionDSL,
@@ -61,11 +63,14 @@ function applyGlobalDefToCondition(
} }
} }
if (!isThresholdOverridden(cond) && parseThresholdField(next.rightField) != null) { if (!isThresholdOverridden(cond) && next.rightField?.startsWith('K_')) {
const defaults = getDefaultConditionFields(cond.indicatorType, signalType, def); next.rightField = inferThresholdSymbolFromLegacy(
next.rightField = defaults.r; next.rightField,
const parsed = parseThresholdField(defaults.r); cond.indicatorType,
if (parsed != null) next.targetValue = parsed; def.hlThresh,
) ?? defaultInheritedThresholdField(cond.indicatorType);
const { targetValue: _t, ...rest } = next;
next = { ...rest, thresholdOverride: false };
} }
return next; return next;
+83 -63
View File
@@ -27,12 +27,14 @@ import {
getCompositePeriodPresetOptions, getCompositePeriodPresetOptions,
getConditionRightPeriod, getConditionRightPeriod,
getConditionThreshold, getConditionThreshold,
getChartReferenceThreshold,
getConditionValuePeriod, getConditionValuePeriod,
getDefaultIndicatorPeriod, getDefaultIndicatorPeriod,
getPeriodPresetOptions, getPeriodPresetOptions,
getThresholdBounds, getThresholdBounds,
getThresholdPresetOptions, getThresholdPresetOptions,
initConditionPeriodsInherit, initConditionPeriodsInherit,
isThresholdOverridden,
isValuePeriodFieldStored, isValuePeriodFieldStored,
parseThresholdField, parseThresholdField,
resolveFieldCustomKind, resolveFieldCustomKind,
@@ -44,6 +46,14 @@ import {
setConditionRightPeriod, setConditionRightPeriod,
setConditionValuePeriod, setConditionValuePeriod,
} from '../utils/conditionPeriods'; } from '../utils/conditionPeriods';
import {
THRESHOLD_HL_MID,
THRESHOLD_HL_OVER,
THRESHOLD_HL_UNDER,
isThresholdFieldOrSymbol,
isThresholdSymbol,
resolveInheritedThresholdField,
} from '../utils/thresholdSymbols';
import { STRATEGY_CANDLE_TYPE_OPTIONS } from '../utils/strategyStartNodes'; import { STRATEGY_CANDLE_TYPE_OPTIONS } from '../utils/strategyStartNodes';
export interface StrategyDto { export interface StrategyDto {
@@ -292,13 +302,6 @@ export function buildStrategyEditorDef(): DefType {
return buildDef([]); return buildDef([]);
} }
/**
* hline 임계값을 K_ 접두어 DSL 필드값으로 변환.
* 예: 70 → "K_70", -100 → "K_-100"
* 백엔드 StrategyDslToTa4jAdapter에서 "K_" 접두어를 파싱하여 FixedDecimalIndicator 생성.
*/
const kv = (price: number) => `K_${price}`;
// ─── 공통 조건 옵션 ─────────────────────────────────────────────────────────── // ─── 공통 조건 옵션 ───────────────────────────────────────────────────────────
const COND_OPTIONS = [ const COND_OPTIONS = [
{ v: 'GT', l: '초과 (>)' }, { v: 'GT', l: '초과 (>)' },
@@ -361,9 +364,9 @@ export const getFieldOpts = (
const rsiP = cond ? getConditionValuePeriod({ ...cond, indicatorType: 'RSI' }, DEF) : DEF.rsiPeriod; const rsiP = cond ? getConditionValuePeriod({ ...cond, indicatorType: 'RSI' }, DEF) : DEF.rsiPeriod;
return condOpts([ return condOpts([
{ value:'RSI_VALUE', label:`RSI 라인(${rsiP}일)` }, { value:'RSI_VALUE', label:`RSI 라인(${rsiP}일)` },
{ value:kv(over), label:`${overTerm}(${over})` }, { value:THRESHOLD_HL_OVER, label:`${overTerm}(${over})` },
{ value:kv(mid), label:`중앙선(${mid})` }, { value:THRESHOLD_HL_MID, label:`중앙선(${mid})` },
{ value:kv(under), label:`${underTerm}(${under})` }, { value:THRESHOLD_HL_UNDER, label:`${underTerm}(${under})` },
]); ]);
} }
case 'STOCHASTIC': { case 'STOCHASTIC': {
@@ -371,9 +374,9 @@ export const getFieldOpts = (
return condOpts([ return condOpts([
{ value:'STOCH_K', label:`Stochastic %K(${DEF.stochK}일)` }, { value:'STOCH_K', label:`Stochastic %K(${DEF.stochK}일)` },
{ value:'STOCH_D', label:`Stochastic %D(${DEF.stochD}일)` }, { value:'STOCH_D', label:`Stochastic %D(${DEF.stochD}일)` },
{ value:kv(over), label:`${overTerm}(${over})` }, { value:THRESHOLD_HL_OVER, label:`${overTerm}(${over})` },
{ value:kv(mid), label:`중앙선(${mid})` }, { value:THRESHOLD_HL_MID, label:`중앙선(${mid})` },
{ value:kv(under), label:`${underTerm}(${under})` }, { value:THRESHOLD_HL_UNDER, label:`${underTerm}(${under})` },
]); ]);
} }
case 'CCI': { case 'CCI': {
@@ -381,18 +384,18 @@ export const getFieldOpts = (
const cciP = cond ? getConditionValuePeriod({ ...cond, indicatorType: 'CCI' }, DEF) : DEF.cciPeriod; const cciP = cond ? getConditionValuePeriod({ ...cond, indicatorType: 'CCI' }, DEF) : DEF.cciPeriod;
return condOpts([ return condOpts([
{ value:'CCI_VALUE', label:`CCI 라인(${cciP}일)` }, { value:'CCI_VALUE', label:`CCI 라인(${cciP}일)` },
{ value:kv(over), label:`${overTerm}(${over})` }, { value:THRESHOLD_HL_OVER, label:`${overTerm}(${over})` },
{ value:kv(mid), label:`중앙선(${mid})` }, { value:THRESHOLD_HL_MID, label:`중앙선(${mid})` },
{ value:kv(under), label:`${underTerm}(${under})` }, { value:THRESHOLD_HL_UNDER, label:`${underTerm}(${under})` },
]); ]);
} }
case 'ADX': { case 'ADX': {
const { over, mid, under } = th({ over: 40, mid: 25, under: 20 }); const { over, mid, under } = th({ over: 40, mid: 25, under: 20 });
return condOpts([ return condOpts([
{ value:'ADX_VALUE', label:`ADX 라인(${DEF.adxPeriod}일)` }, { value:'ADX_VALUE', label:`ADX 라인(${DEF.adxPeriod}일)` },
{ value:kv(over), label:`${overTerm}(${over})` }, { value:THRESHOLD_HL_OVER, label:`${overTerm}(${over})` },
{ value:kv(mid), label:`중앙선(${mid})` }, { value:THRESHOLD_HL_MID, label:`중앙선(${mid})` },
{ value:kv(under), label:`${underTerm}(${under})` }, { value:THRESHOLD_HL_UNDER, label:`${underTerm}(${under})` },
]); ]);
} }
case 'TRIX': { case 'TRIX': {
@@ -400,7 +403,7 @@ export const getFieldOpts = (
return condOpts([ return condOpts([
{ value:'TRIX_VALUE', label:`TRIX 라인(${DEF.trixPeriod}일)` }, { value:'TRIX_VALUE', label:`TRIX 라인(${DEF.trixPeriod}일)` },
{ value:'TRIX_SIGNAL', label:`TRIX 시그널(${DEF.trixSignal}일)` }, { value:'TRIX_SIGNAL', label:`TRIX 시그널(${DEF.trixSignal}일)` },
{ value:kv(mid), label:`중앙선(${mid})` }, { value:THRESHOLD_HL_MID, label:`중앙선(${mid})` },
]); ]);
} }
case 'DMI': { case 'DMI': {
@@ -408,9 +411,9 @@ export const getFieldOpts = (
return condOpts([ return condOpts([
{ value:'PDI', label:`DI+(${DEF.dmiPeriod}일)` }, { value:'PDI', label:`DI+(${DEF.dmiPeriod}일)` },
{ value:'MDI', label:`DI-(${DEF.dmiPeriod}일)` }, { value:'MDI', label:`DI-(${DEF.dmiPeriod}일)` },
{ value:kv(over), label:`과열(${over})` }, { value:THRESHOLD_HL_OVER, label:`과열(${over})` },
{ value:kv(mid), label:`중간값(${mid})` }, { value:THRESHOLD_HL_MID, label:`중간값(${mid})` },
{ value:kv(under), label:`침체(${under})` }, { value:THRESHOLD_HL_UNDER, label:`침체(${under})` },
]); ]);
} }
case 'OBV': return condOpts([ case 'OBV': return condOpts([
@@ -431,64 +434,67 @@ export const getFieldOpts = (
...DEF.maLines.slice(0,4).map((p, i) => ({ value:`EMA${p}`, label:`EMA${i+1}(${p}일)` })), ...DEF.maLines.slice(0,4).map((p, i) => ({ value:`EMA${p}`, label:`EMA${i+1}(${p}일)` })),
{ value:'CLOSE_PRICE', label:'종가' }, { value:'CLOSE_PRICE', label:'종가' },
]; ];
case 'DISPARITY': return condOpts([ case 'DISPARITY': {
const { mid } = th({ mid: 100 });
return condOpts([
{ value:`DISPARITY${DEF.dispUltra}`, label:`초단기 이격도(${DEF.dispUltra}일)` }, { value:`DISPARITY${DEF.dispUltra}`, label:`초단기 이격도(${DEF.dispUltra}일)` },
{ value:`DISPARITY${DEF.dispShort}`, label:`단기 이격도(${DEF.dispShort}일)` }, { value:`DISPARITY${DEF.dispShort}`, label:`단기 이격도(${DEF.dispShort}일)` },
{ value:`DISPARITY${DEF.dispMid}`, label:`중기 이격도(${DEF.dispMid}일)` }, { value:`DISPARITY${DEF.dispMid}`, label:`중기 이격도(${DEF.dispMid}일)` },
{ value:`DISPARITY${DEF.dispLong}`, label:`장기 이격도(${DEF.dispLong}일)` }, { value:`DISPARITY${DEF.dispLong}`, label:`장기 이격도(${DEF.dispLong}일)` },
{ value:kv(100), label:'중앙선(100)' }, { value:THRESHOLD_HL_MID, label:`중앙선(${mid})` },
]); ]);
}
case 'PSYCHOLOGICAL': case 'PSYCHOLOGICAL':
case 'NEW_PSYCHOLOGICAL': { case 'NEW_PSYCHOLOGICAL': {
const { over, mid, under } = th({ over:75, mid:50, under:25 }); const { over, mid, under } = th({ over:75, mid:50, under:25 });
return condOpts([ return condOpts([
{ value:'PSY_VALUE', label:`심리도 라인(${DEF.newPsy}일)` }, { value:'PSY_VALUE', label:`심리도 라인(${DEF.newPsy}일)` },
{ value:kv(over), label:`${overTerm}(${over})` }, { value:THRESHOLD_HL_OVER, label:`${overTerm}(${over})` },
{ value:kv(mid), label:`중앙선(${mid})` }, { value:THRESHOLD_HL_MID, label:`중앙선(${mid})` },
{ value:kv(under), label:`${underTerm}(${under})` }, { value:THRESHOLD_HL_UNDER, label:`${underTerm}(${under})` },
]); ]);
} }
case 'INVEST_PSYCHOLOGICAL': { case 'INVEST_PSYCHOLOGICAL': {
const { over, mid, under } = th({ over:75, mid:50, under:25 }); const { over, mid, under } = th({ over:75, mid:50, under:25 });
return condOpts([ return condOpts([
{ value:'INVEST_PSY_VALUE', label:`투자심리도 라인(${DEF.investPsy}일)` }, { value:'INVEST_PSY_VALUE', label:`투자심리도 라인(${DEF.investPsy}일)` },
{ value:kv(over), label:`${overTerm}(${over})` }, { value:THRESHOLD_HL_OVER, label:`${overTerm}(${over})` },
{ value:kv(mid), label:`중앙선(${mid})` }, { value:THRESHOLD_HL_MID, label:`중앙선(${mid})` },
{ value:kv(under), label:`${underTerm}(${under})` }, { value:THRESHOLD_HL_UNDER, label:`${underTerm}(${under})` },
]); ]);
} }
case 'WILLIAMS_R': { case 'WILLIAMS_R': {
const { over, mid, under } = th({ over:-20, mid:-50, under:-80 }); const { over, mid, under } = th({ over:-20, mid:-50, under:-80 });
return condOpts([ return condOpts([
{ value:'WILLIAMS_R_VALUE', label:`Williams %R 라인(${DEF.williamsR}일)` }, { value:'WILLIAMS_R_VALUE', label:`Williams %R 라인(${DEF.williamsR}일)` },
{ value:kv(over), label:`${overTerm}(${over})` }, { value:THRESHOLD_HL_OVER, label:`${overTerm}(${over})` },
{ value:kv(mid), label:`중앙선(${mid})` }, { value:THRESHOLD_HL_MID, label:`중앙선(${mid})` },
{ value:kv(under), label:`${underTerm}(${under})` }, { value:THRESHOLD_HL_UNDER, label:`${underTerm}(${under})` },
]); ]);
} }
case 'BWI': { case 'BWI': {
const { over, mid, under } = th({ over:80, mid:50, under:20 }); const { over, mid, under } = th({ over:80, mid:50, under:20 });
return condOpts([ return condOpts([
{ value:'BWI_VALUE', label:`BWI 라인(${DEF.bwiPeriod}일)` }, { value:'BWI_VALUE', label:`BWI 라인(${DEF.bwiPeriod}일)` },
{ value:kv(over), label:`${overTerm}(${over})` }, { value:THRESHOLD_HL_OVER, label:`${overTerm}(${over})` },
{ value:kv(mid), label:`중앙선(${mid})` }, { value:THRESHOLD_HL_MID, label:`중앙선(${mid})` },
{ value:kv(under), label:`${underTerm}(${under})` }, { value:THRESHOLD_HL_UNDER, label:`${underTerm}(${under})` },
]); ]);
} }
case 'VR': { case 'VR': {
const { over, mid, under } = th({ over:200, mid:100, under:50 }); const { over, mid, under } = th({ over:200, mid:100, under:50 });
return condOpts([ return condOpts([
{ value:'VR_VALUE', label:`VR 라인(${DEF.vrPeriod}일)` }, { value:'VR_VALUE', label:`VR 라인(${DEF.vrPeriod}일)` },
{ value:kv(over), label:`${overTerm}(${over})` }, { value:THRESHOLD_HL_OVER, label:`${overTerm}(${over})` },
{ value:kv(mid), label:`중앙선(${mid})` }, { value:THRESHOLD_HL_MID, label:`중앙선(${mid})` },
{ value:kv(under), label:`${underTerm}(${under})` }, { value:THRESHOLD_HL_UNDER, label:`${underTerm}(${under})` },
]); ]);
} }
case 'VOLUME_OSC': { case 'VOLUME_OSC': {
const { mid } = th({ mid:0 }); const { mid } = th({ mid:0 });
return condOpts([ return condOpts([
{ value:'VOLUME_OSC_VALUE', label:`거래량 오실레이터 값(${DEF.volOscShort}일/${DEF.volOscLong}일)` }, { value:'VOLUME_OSC_VALUE', label:`거래량 오실레이터 값(${DEF.volOscShort}일/${DEF.volOscLong}일)` },
{ value:kv(mid), label:`중앙선(${mid})` }, { value:THRESHOLD_HL_MID, label:`중앙선(${mid})` },
]); ]);
} }
case 'MACD': { case 'MACD': {
@@ -497,7 +503,7 @@ export const getFieldOpts = (
{ value:'MACD_LINE', label:`MACD 라인(${DEF.macdFast}일/${DEF.macdSlow}일)` }, { value:'MACD_LINE', label:`MACD 라인(${DEF.macdFast}일/${DEF.macdSlow}일)` },
{ value:'SIGNAL_LINE', label:`시그널 라인(${DEF.macdSignal}일)` }, { value:'SIGNAL_LINE', label:`시그널 라인(${DEF.macdSignal}일)` },
{ value:'HISTOGRAM', label:'히스토그램' }, { value:'HISTOGRAM', label:'히스토그램' },
{ value:kv(mid), label:`중앙선(${mid})` }, { value:THRESHOLD_HL_MID, label:`중앙선(${mid})` },
]); ]);
} }
case 'BOLLINGER': return condOpts([ case 'BOLLINGER': return condOpts([
@@ -559,30 +565,25 @@ export const getFieldOpts = (
}; };
const getDefaultConditionFields = (ind: string, signalType: 'buy'|'sell', DEF: DefType): { l: string; r: string } => { const getDefaultConditionFields = (ind: string, signalType: 'buy'|'sell', DEF: DefType): { l: string; r: string } => {
// 저장된 hline 과열선 값 우선 사용
const over = (def: number) => kv(DEF.hlThresh[ind]?.over ?? def);
const mid = (def: number) => kv(DEF.hlThresh[ind]?.mid ?? def);
const adxMid = DEF.hlThresh['ADX']?.mid ?? 25;
const map: Record<string, {l:string;r:string}> = { const map: Record<string, {l:string;r:string}> = {
RSI: { l:'RSI_VALUE', r: over(70) }, RSI: { l:'RSI_VALUE', r: THRESHOLD_HL_OVER },
STOCHASTIC: { l:'STOCH_K', r:'STOCH_D' }, STOCHASTIC: { l:'STOCH_K', r:'STOCH_D' },
CCI: { l:'CCI_VALUE', r: over(100) }, CCI: { l:'CCI_VALUE', r: THRESHOLD_HL_OVER },
ADX: { l:'ADX_VALUE', r: kv(adxMid) }, ADX: { l:'ADX_VALUE', r: THRESHOLD_HL_MID },
TRIX: { l:'TRIX_VALUE', r:'TRIX_SIGNAL' }, TRIX: { l:'TRIX_VALUE', r:'TRIX_SIGNAL' },
DMI: { l:'PDI', r:'MDI' }, DMI: { l:'PDI', r:'MDI' },
OBV: { l:'OBV_LINE', r:'OBV_SIGNAL' }, OBV: { l:'OBV_LINE', r:'OBV_SIGNAL' },
VOLUME: { l:'VOLUME_VALUE', r:'VOLUME_MA' }, VOLUME: { l:'VOLUME_VALUE', r:'VOLUME_MA' },
MA: { l:`MA${DEF.maLines[0]}`, r:`MA${DEF.maLines[1]}` }, MA: { l:`MA${DEF.maLines[0]}`, r:`MA${DEF.maLines[1]}` },
EMA: { l:`EMA${DEF.maLines[0]}`, r:`EMA${DEF.maLines[1]}` }, EMA: { l:`EMA${DEF.maLines[0]}`, r:`EMA${DEF.maLines[1]}` },
DISPARITY: { l:`DISPARITY${DEF.dispUltra}`, r: mid(100) }, DISPARITY: { l:`DISPARITY${DEF.dispUltra}`, r: THRESHOLD_HL_MID },
PSYCHOLOGICAL: { l:'PSY_VALUE', r: over(75) }, PSYCHOLOGICAL: { l:'PSY_VALUE', r: THRESHOLD_HL_OVER },
NEW_PSYCHOLOGICAL: { l:'PSY_VALUE', r: over(75) }, NEW_PSYCHOLOGICAL: { l:'PSY_VALUE', r: THRESHOLD_HL_OVER },
INVEST_PSYCHOLOGICAL: { l:'INVEST_PSY_VALUE', r: over(75) }, INVEST_PSYCHOLOGICAL: { l:'INVEST_PSY_VALUE', r: THRESHOLD_HL_OVER },
WILLIAMS_R: { l:'WILLIAMS_R_VALUE', r: over(-20) }, WILLIAMS_R: { l:'WILLIAMS_R_VALUE', r: THRESHOLD_HL_OVER },
BWI: { l:'BWI_VALUE', r: over(80) }, BWI: { l:'BWI_VALUE', r: THRESHOLD_HL_OVER },
VR: { l:'VR_VALUE', r: over(200) }, VR: { l:'VR_VALUE', r: THRESHOLD_HL_OVER },
VOLUME_OSC: { l:'VOLUME_OSC_VALUE', r: mid(0) }, VOLUME_OSC: { l:'VOLUME_OSC_VALUE', r: THRESHOLD_HL_MID },
MACD: { l:'MACD_LINE', r:'SIGNAL_LINE' }, MACD: { l:'MACD_LINE', r:'SIGNAL_LINE' },
BOLLINGER: { l:'CLOSE_PRICE', r:'UPPER_BAND' }, BOLLINGER: { l:'CLOSE_PRICE', r:'UPPER_BAND' },
DONCHIAN: signalType === 'buy' DONCHIAN: signalType === 'buy'
@@ -682,10 +683,17 @@ export const nodeToText = (node: LogicNode, DEF: DefType = DEF_DEFAULTS): string
const tf = lCt !== rCt ? ` [${lCt}/${rCt}]` : ` [${lCt}]`; const tf = lCt !== rCt ? ` [${lCt}/${rCt}]` : ` [${lCt}]`;
return `${indName} - ${L} ${C} ${R}${tf}`; return `${indName} - ${L} ${C} ${R}${tf}`;
} }
const defaults = getDefaultConditionFields(c.indicatorType, 'buy', DEF);
const lv = resolveFieldOptionValue(c.indicatorType, c.leftField); const lv = resolveFieldOptionValue(c.indicatorType, c.leftField);
const rv = resolveFieldOptionValue(c.indicatorType, c.rightField); const rvRaw = c.rightField;
const L = opts.find(o => o.value === lv)?.label ?? c.leftField ?? indName; const rv = isThresholdOverridden(c)
? resolveFieldOptionValue(c.indicatorType, rvRaw)
: (resolveInheritedThresholdField(rvRaw, c.indicatorType, DEF.hlThresh)
?? resolveFieldOptionValue(c.indicatorType, rvRaw));
const L = opts.find(o => o.value === lv)?.label ?? c.leftField ?? indName;
const chartRef = getChartReferenceThreshold(c, 'right', defaults.r, DEF);
const R = opts.find(o => o.value === rv)?.label const R = opts.find(o => o.value === rv)?.label
?? (chartRef != null && !isThresholdOverridden(c) ? `기준(${chartRef})` : null)
?? (rv && parseThresholdField(rv) != null ? `임계(${parseThresholdField(rv)})` : rv ?? ''); ?? (rv && parseThresholdField(rv) != null ? `임계(${parseThresholdField(rv)})` : rv ?? '');
if (R && R !== '선택안함') return `${indName} - ${L} ${C} ${R}`; if (R && R !== '선택안함') return `${indName} - ${L} ${C} ${R}`;
return `${indName} - ${L} ${C}`; return `${indName} - ${L} ${C}`;
@@ -800,7 +808,13 @@ export const CondEditor: React.FC<CondEditorProps> = ({ cond, signalType, onChan
return isValid(v) ? v! : defaults.l; return isValid(v) ? v! : defaults.l;
}; };
const getRightValue = () => { const getRightValue = () => {
const v = resolveFieldOptionValue(normalized.indicatorType, normalized.rightField); const raw = normalized.rightField;
const v = isThresholdOverridden(normalized)
? resolveFieldOptionValue(normalized.indicatorType, raw)
: (isThresholdFieldOrSymbol(raw)
? (resolveInheritedThresholdField(raw, normalized.indicatorType, def.hlThresh)
?? resolveFieldOptionValue(normalized.indicatorType, raw))
: resolveFieldOptionValue(normalized.indicatorType, raw));
return isValid(v) ? v! : defaults.r; return isValid(v) ? v! : defaults.r;
}; };
@@ -812,6 +826,11 @@ export const CondEditor: React.FC<CondEditorProps> = ({ cond, signalType, onChan
const handleCondType = (newType: string) => onChange(applyCondTypeDefaults(normalized, newType, def)); const handleCondType = (newType: string) => onChange(applyCondTypeDefaults(normalized, newType, def));
const handleRight = (v: string) => { const handleRight = (v: string) => {
if (isThresholdSymbol(v)) {
const { targetValue: _t, ...rest } = normalized;
onChange({ ...rest, rightField: v, thresholdOverride: false });
return;
}
const thresholds: Record<string,number> = { const thresholds: Record<string,number> = {
OVERBOUGHT_70: 70, NEUTRAL_50: 50, OVERSOLD_30: 30, OVERBOUGHT_70: 70, NEUTRAL_50: 50, OVERSOLD_30: 30,
OVERBOUGHT_80: 80, OVERSOLD_20: 20, OVERBOUGHT_80: 80, OVERSOLD_20: 20,
@@ -1005,7 +1024,8 @@ export const CondEditor: React.FC<CondEditorProps> = ({ cond, signalType, onChan
fieldValue={rightValue} fieldValue={rightValue}
isPresetField={fieldOpts.some(o => o.value === normalized.rightField)} isPresetField={fieldOpts.some(o => o.value === normalized.rightField)}
customKind={resolveFieldCustomKind(normalized.indicatorType, normalized.rightField)} customKind={resolveFieldCustomKind(normalized.indicatorType, normalized.rightField)}
customNumber={getConditionThreshold(normalized, 'right', inheritedThresholdField) customNumber={getConditionThreshold(normalized, 'right', inheritedThresholdField, def)
?? getChartReferenceThreshold(normalized, 'right', inheritedThresholdField, def)
?? parseThresholdField(normalized.rightField) ?? parseThresholdField(normalized.rightField)
?? 0} ?? 0}
numberPresets={getThresholdPresetOptions(normalized.indicatorType)} numberPresets={getThresholdPresetOptions(normalized.indicatorType)}
@@ -1014,7 +1034,7 @@ export const CondEditor: React.FC<CondEditorProps> = ({ cond, signalType, onChan
allowDecimal={normalized.indicatorType === 'CCI'} allowDecimal={normalized.indicatorType === 'CCI'}
disabled={isSlopeType || isHoldType} disabled={isSlopeType || isHoldType}
onFieldChange={handleRight} onFieldChange={handleRight}
onCustomNumberChange={v => onChange(setConditionThreshold(normalized, 'right', v))} onCustomNumberChange={v => onChange(setConditionThreshold(normalized, 'right', v, def))}
/> />
</div> </div>
{/* 조건 */} {/* 조건 */}
+93
View File
@@ -0,0 +1,93 @@
/** 보조지표 차트 hline 역할 — DSL에 숫자(K_70) 대신 저장해 DB 설정 변경 시 자동 반영 */
export const THRESHOLD_HL_OVER = 'HL_OVER';
export const THRESHOLD_HL_MID = 'HL_MID';
export const THRESHOLD_HL_UNDER = 'HL_UNDER';
export type ThresholdHlineRole = typeof THRESHOLD_HL_OVER | typeof THRESHOLD_HL_MID | typeof THRESHOLD_HL_UNDER;
export function isThresholdSymbol(field?: string): boolean {
return field === THRESHOLD_HL_OVER || field === THRESHOLD_HL_MID || field === THRESHOLD_HL_UNDER;
}
export function isThresholdFieldOrSymbol(field?: string): boolean {
return !!field && (field.startsWith('K_') || isThresholdSymbol(field));
}
type HlThresh = { over?: number; mid?: number; under?: number };
export function resolveThresholdFromDef(
field: string | undefined,
indicatorType: string,
hlThresh: Record<string, HlThresh>,
): number | null {
if (!field) return null;
const th = hlThresh[indicatorType] ?? {};
if (field === THRESHOLD_HL_OVER) return th.over ?? null;
if (field === THRESHOLD_HL_MID) return th.mid ?? null;
if (field === THRESHOLD_HL_UNDER) return th.under ?? null;
if (field.startsWith('K_')) {
const n = parseFloat(field.slice(2));
return Number.isFinite(n) ? n : null;
}
return null;
}
/** 레거시 K_ 값 → hline 역할 심볼 (정확 일치 우선, 없으면 가장 가까운 역할) */
export function inferThresholdSymbolFromLegacy(
field: string | undefined,
indicatorType: string,
hlThresh: Record<string, HlThresh>,
): string | undefined {
if (!field?.startsWith('K_')) return field;
const val = parseFloat(field.slice(2));
if (!Number.isFinite(val)) return field;
const th = hlThresh[indicatorType] ?? {};
const eps = 0.0001;
if (th.over != null && Math.abs(th.over - val) < eps) return THRESHOLD_HL_OVER;
if (th.mid != null && Math.abs(th.mid - val) < eps) return THRESHOLD_HL_MID;
if (th.under != null && Math.abs(th.under - val) < eps) return THRESHOLD_HL_UNDER;
const candidates: { role: ThresholdHlineRole; v: number }[] = [];
if (th.over != null) candidates.push({ role: THRESHOLD_HL_OVER, v: th.over });
if (th.mid != null) candidates.push({ role: THRESHOLD_HL_MID, v: th.mid });
if (th.under != null) candidates.push({ role: THRESHOLD_HL_UNDER, v: th.under });
if (candidates.length === 0) return defaultInheritedThresholdField(indicatorType);
let best = candidates[0];
let bestDiff = Math.abs(best.v - val);
for (const c of candidates.slice(1)) {
const d = Math.abs(c.v - val);
if (d < bestDiff) {
best = c;
bestDiff = d;
}
}
return best.role;
}
/** 상속 모드(thresholdOverride=false) — K_ 스냅샷을 hline 역할 심볼로 정규화 */
export function resolveInheritedThresholdField(
field: string | undefined,
indicatorType: string,
hlThresh: Record<string, HlThresh>,
): string | undefined {
if (!field) return field;
if (isThresholdSymbol(field)) return field;
if (field.startsWith('K_')) {
return inferThresholdSymbolFromLegacy(field, indicatorType, hlThresh)
?? defaultInheritedThresholdField(indicatorType);
}
return field;
}
/** 상속 모드 기본 rightField — 과열선 역할 */
export function defaultInheritedThresholdField(indicatorType: string): string {
switch (indicatorType) {
case 'DISPARITY':
case 'VOLUME_OSC':
case 'TRIX':
return THRESHOLD_HL_MID;
default:
return THRESHOLD_HL_OVER;
}
}