전략편집기 기준값 보조지표 설정값 동기화
This commit is contained in:
@@ -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);
|
||||
|
||||
+7
-7
@@ -66,7 +66,7 @@ class StrategyTriggerBranchEvaluatorTest {
|
||||
""";
|
||||
|
||||
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");
|
||||
int idx = s1m.getEndIndex();
|
||||
assertDoesNotThrow(() -> rule1m.isSatisfied(idx, null));
|
||||
@@ -98,7 +98,7 @@ class StrategyTriggerBranchEvaluatorTest {
|
||||
""";
|
||||
|
||||
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");
|
||||
int idx1m = s1m.getEndIndex();
|
||||
|
||||
@@ -134,7 +134,7 @@ class StrategyTriggerBranchEvaluatorTest {
|
||||
assertEquals("5m", scope.branches().get(2).candleType());
|
||||
|
||||
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");
|
||||
on1m.isSatisfied(s1m.getEndIndex(), null);
|
||||
assertNotNull(branchCache.get(MARKET, STRATEGY_ID, "buy", "1m"));
|
||||
@@ -144,14 +144,14 @@ class StrategyTriggerBranchEvaluatorTest {
|
||||
|
||||
branchCache.invalidateStrategy(STRATEGY_ID);
|
||||
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");
|
||||
on3m.isSatisfied(s3m.getEndIndex(), null);
|
||||
assertNotNull(branchCache.get(MARKET, STRATEGY_ID, "buy", "3m"));
|
||||
assertNull(branchCache.get(MARKET, STRATEGY_ID, "buy", "1m"));
|
||||
|
||||
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),
|
||||
"3m 인덱스로 1m 트리거 Rule 평가 시 false");
|
||||
}
|
||||
@@ -177,12 +177,12 @@ class StrategyTriggerBranchEvaluatorTest {
|
||||
assertEquals("3m", scope.branches().get(0).candleType());
|
||||
|
||||
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");
|
||||
assertDoesNotThrow(() -> on3m.isSatisfied(s3m.getEndIndex(), null));
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
updateStartCandleTypes,
|
||||
type EditorConditionState,
|
||||
} from '../utils/strategyConditionSerde';
|
||||
import { migrateLogicRootForEditor } from '../utils/strategyConditionNormalize';
|
||||
import { persistStrategyEvaluationTimeframes } from '../utils/strategyTimeframeSync';
|
||||
import {
|
||||
START_NODE_ID,
|
||||
@@ -141,7 +142,7 @@ export default function StrategyEditorPage({ theme }: Props) {
|
||||
() => buildStrategyEditorDefFromSettings(getParams, getVisualConfig),
|
||||
[getParams, getVisualConfig, settingsRevision],
|
||||
);
|
||||
const settingsSyncRef = useRef(0);
|
||||
const settingsSyncRef = useRef(-1);
|
||||
|
||||
const [strategies, setStrategies] = useState<StrategyDto[]>(() => loadStratsLocal());
|
||||
const [selectedId, setSelectedId] = useState<number | null>(null);
|
||||
@@ -289,6 +290,7 @@ export default function StrategyEditorPage({ theme }: Props) {
|
||||
|
||||
/** 보조지표 설정 변경 시 오버라이드되지 않은 조건 노드·캔버스 기준값 동기화 */
|
||||
useEffect(() => {
|
||||
if (!settingsLoaded) return;
|
||||
if (settingsRevision <= settingsSyncRef.current) return;
|
||||
settingsSyncRef.current = settingsRevision;
|
||||
|
||||
@@ -316,7 +318,7 @@ export default function StrategyEditorPage({ theme }: Props) {
|
||||
extraRoots: syncExtraRoots(prev.extraRoots ?? {}, 'sell'),
|
||||
}));
|
||||
bumpLayoutSeed(layoutStrategyKey, signalTab);
|
||||
}, [settingsRevision, DEF, layoutStrategyKey, signalTab, bumpLayoutSeed]);
|
||||
}, [settingsLoaded, settingsRevision, DEF, layoutStrategyKey, signalTab, bumpLayoutSeed]);
|
||||
|
||||
const resetFlowLayout = useCallback((strategyKey: string, tab: 'buy' | 'sell' = 'buy') => {
|
||||
const emptyBuy = emptySignalFlowLayout();
|
||||
@@ -553,15 +555,18 @@ export default function StrategyEditorPage({ theme }: Props) {
|
||||
const sellDecoded = decodeConditionForEditor(s.sellCondition ?? null);
|
||||
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(
|
||||
buyDecoded.root,
|
||||
stored?.buy.orphans ?? [],
|
||||
buyMigrated.root,
|
||||
buyMigrated.orphans,
|
||||
DEF,
|
||||
'buy',
|
||||
);
|
||||
const sellSynced = syncLogicRootWithGlobalDef(
|
||||
sellDecoded.root,
|
||||
stored?.sell.orphans ?? [],
|
||||
sellMigrated.root,
|
||||
sellMigrated.orphans,
|
||||
DEF,
|
||||
'sell',
|
||||
);
|
||||
|
||||
@@ -511,7 +511,7 @@ const IndDropdown: React.FC<IndDropdownProps> = ({
|
||||
onHeaderPointerDown, headerTouchStyle,
|
||||
panelStyle,
|
||||
headerCursor,
|
||||
} = useDraggablePanel({ centerOnMount: true, initialPosition: { x: 0, y: 56 } });
|
||||
} = useDraggablePanel({ centerOnMount: true });
|
||||
|
||||
return (
|
||||
// 배경 클릭 시 닫힘
|
||||
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
getCompositeRightCandleType,
|
||||
getConditionRightPeriod,
|
||||
getConditionThreshold,
|
||||
getChartReferenceThreshold,
|
||||
isThresholdOverridden,
|
||||
getConditionValuePeriod,
|
||||
getCompositePeriodPresetOptions,
|
||||
getPeriodPresetOptions,
|
||||
@@ -52,8 +54,9 @@ export default function ConditionNodeSettings({
|
||||
}, [onClose]);
|
||||
|
||||
const inheritedThreshold = getDefaultConditionFields(condition.indicatorType, signalType, def).r;
|
||||
const rightThreshold = getConditionThreshold(condition, 'right', inheritedThreshold);
|
||||
const showThreshold = hasEditableThreshold(condition) && rightThreshold != null;
|
||||
const chartRef = getChartReferenceThreshold(condition, 'right', inheritedThreshold, def);
|
||||
const strategyThreshold = getConditionThreshold(condition, 'right', inheritedThreshold, def);
|
||||
const showThreshold = hasEditableThreshold(condition) && chartRef != null;
|
||||
const periodPresets = getPeriodPresetOptions(condition.indicatorType, def);
|
||||
const thresholdPresets = getThresholdPresetOptions(condition.indicatorType);
|
||||
const thresholdBounds = getThresholdBounds(condition.indicatorType);
|
||||
@@ -139,18 +142,24 @@ export default function ConditionNodeSettings({
|
||||
/>
|
||||
</label>
|
||||
) : null}
|
||||
{showThreshold && rightThreshold != null && (
|
||||
{showThreshold && chartRef != null && (
|
||||
<>
|
||||
<label className="se-flow-settings-field se-flow-settings-field--readonly">
|
||||
<span>차트 기준값 (보조지표 설정)</span>
|
||||
<output className="se-flow-settings-readout">{chartRef}</output>
|
||||
</label>
|
||||
<label className="se-flow-settings-field">
|
||||
<span>임계값</span>
|
||||
<span>전략 조건값{isThresholdOverridden(condition) ? ' (전용)' : ''}</span>
|
||||
<ComboNumberInput
|
||||
value={rightThreshold}
|
||||
value={strategyThreshold ?? chartRef}
|
||||
options={thresholdPresets}
|
||||
min={thresholdBounds.min}
|
||||
max={thresholdBounds.max}
|
||||
allowDecimal
|
||||
onChange={v => onChange(setConditionThreshold(condition, 'right', v))}
|
||||
onChange={v => onChange(setConditionThreshold(condition, 'right', v, def))}
|
||||
/>
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
{condition.composite && (
|
||||
<p className="se-flow-settings-hint">{compositeDisplayName(condition.indicatorType)}</p>
|
||||
|
||||
@@ -14,6 +14,17 @@ export interface UseDraggablePanelOptions {
|
||||
}
|
||||
|
||||
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 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 dragOrigin = useRef({ mx: 0, my: 0, px: 0, py: 0 });
|
||||
const centeredOnce = useRef(false);
|
||||
@@ -39,13 +53,14 @@ export function useDraggablePanel(options: UseDraggablePanelOptions = {}) {
|
||||
if (!el) return;
|
||||
|
||||
const placeCenter = () => {
|
||||
const w = el.offsetWidth || 400;
|
||||
const h = el.offsetHeight || 320;
|
||||
const w = el.offsetWidth || DEFAULT_CENTER_ESTIMATE.width;
|
||||
const h = el.offsetHeight || DEFAULT_CENTER_ESTIMATE.height;
|
||||
setPos({
|
||||
x: Math.max(8, (window.innerWidth - w) / 2),
|
||||
y: Math.max(8, (window.innerHeight - h) / 2),
|
||||
});
|
||||
centeredOnce.current = true;
|
||||
setPositionReady(true);
|
||||
};
|
||||
|
||||
placeCenter();
|
||||
@@ -95,6 +110,7 @@ export function useDraggablePanel(options: UseDraggablePanelOptions = {}) {
|
||||
position: 'fixed',
|
||||
left: pos.x,
|
||||
top: pos.y,
|
||||
...(centerOnMount && !positionReady ? { visibility: 'hidden' as const } : null),
|
||||
};
|
||||
|
||||
return {
|
||||
|
||||
@@ -67,6 +67,9 @@ let _loadPromise: Promise<AllSettings> | null = null;
|
||||
let _visualCache: VisualCache | null = null;
|
||||
let _visualLoadPromise: Promise<VisualCache> | null = null;
|
||||
|
||||
/** 로그인 세션별 1회만 DB 로드 — 페이지 전환 시 캐시 유지 */
|
||||
let _loadedSessionKey: number | null = null;
|
||||
|
||||
function ensureLoaded(): Promise<AllSettings> {
|
||||
if (_cache !== null) return Promise.resolve(_cache);
|
||||
if (_loadPromise) return _loadPromise;
|
||||
@@ -110,6 +113,7 @@ export function invalidateIndicatorSettingsCache() {
|
||||
_loadPromise = null;
|
||||
_visualCache = null;
|
||||
_visualLoadPromise = null;
|
||||
_loadedSessionKey = null;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -120,11 +124,24 @@ export function useIndicatorSettings(sessionKey = 0) {
|
||||
() => _cache !== null && _visualCache !== null,
|
||||
);
|
||||
|
||||
// 로그인/로그아웃 시 지표 설정 재로드
|
||||
// 로그인/로그아웃 시 지표 설정 재로드 (페이지 전환만으로는 캐시 유지)
|
||||
useEffect(() => {
|
||||
setSettingsLoaded(false);
|
||||
invalidateIndicatorSettingsCache();
|
||||
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([
|
||||
ensureLoaded().catch(err =>
|
||||
console.error('[useIndicatorSettings] params load failed', err),
|
||||
@@ -133,6 +150,7 @@ export function useIndicatorSettings(sessionKey = 0) {
|
||||
console.error('[useIndicatorSettings] visual load failed', err),
|
||||
),
|
||||
]).then(() => {
|
||||
_loadedSessionKey = sessionKey;
|
||||
setSettingsLoaded(true);
|
||||
setSettingsRevision(r => r + 1);
|
||||
});
|
||||
|
||||
@@ -103,9 +103,10 @@ export function compositeValueField(ind: string, period: number): string {
|
||||
}
|
||||
}
|
||||
|
||||
/** K_ 접두 임계값 필드 — 기간 파싱·복합지표 승격에서 제외 */
|
||||
/** K_ 접두 임계값·hline 역할 심볼 — 기간 파싱·복합지표 승격에서 제외 */
|
||||
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> = {
|
||||
|
||||
@@ -18,6 +18,15 @@ import {
|
||||
parsePriorExtremePeriod,
|
||||
syncPriceExtremeSimpleFields,
|
||||
} 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';
|
||||
|
||||
export interface IndicatorPeriodDef {
|
||||
@@ -164,29 +173,99 @@ export function thresholdField(value: number): string {
|
||||
}
|
||||
|
||||
export function hasEditableThreshold(cond: ConditionDSL): boolean {
|
||||
return parseThresholdField(cond.rightField) != null
|
||||
|| parseThresholdField(cond.leftField) != null;
|
||||
return isThresholdFieldOrSymbol(cond.rightField)
|
||||
|| 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(
|
||||
cond: ConditionDSL,
|
||||
side: 'left' | 'right',
|
||||
inheritedField?: string,
|
||||
def?: { hlThresh: Record<string, { over?: number; mid?: number; under?: number }> },
|
||||
): 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)) {
|
||||
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;
|
||||
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(
|
||||
cond: ConditionDSL,
|
||||
period: number,
|
||||
@@ -213,21 +292,6 @@ export function setConditionRightPeriod(cond: ConditionDSL, period: number): Con
|
||||
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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { normalizeLogicRootForPersistence } from './strategyConditionNormalize';
|
||||
import type { LogicNode, ConditionDSL } from './strategyTypes';
|
||||
import { generateNodeId } from './strategyTypes';
|
||||
import { subtreeToFlatOrphans } from './strategyFlowLayout';
|
||||
@@ -283,7 +284,7 @@ export function encodeConditionForSave(state: EditorConditionState): LogicNode |
|
||||
if (branches.length === 1) {
|
||||
const { candleType, candleTypes, root } = branches[0];
|
||||
const types = candleTypes?.length ? candleTypes : [candleType];
|
||||
return wrapTimeframes(types, root);
|
||||
return wrapTimeframes(types, normalizeLogicRootForPersistence(root)!);
|
||||
}
|
||||
|
||||
const combineOp = normalizeStartCombineOp(state.startCombineOp);
|
||||
|
||||
@@ -15,12 +15,14 @@ import {
|
||||
isRightPeriodOverridden,
|
||||
isThresholdOverridden,
|
||||
isValuePeriodOverridden,
|
||||
parseThresholdField,
|
||||
usesValuePeriodField,
|
||||
VALUE_FIELD_PREFIX,
|
||||
} from './conditionPeriods';
|
||||
import {
|
||||
defaultInheritedThresholdField,
|
||||
inferThresholdSymbolFromLegacy,
|
||||
} from './thresholdSymbols';
|
||||
import type { DefType } from './strategyEditorShared';
|
||||
import { getDefaultConditionFields } from './strategyEditorShared';
|
||||
|
||||
function applyGlobalDefToCondition(
|
||||
cond: ConditionDSL,
|
||||
@@ -61,11 +63,14 @@ function applyGlobalDefToCondition(
|
||||
}
|
||||
}
|
||||
|
||||
if (!isThresholdOverridden(cond) && parseThresholdField(next.rightField) != null) {
|
||||
const defaults = getDefaultConditionFields(cond.indicatorType, signalType, def);
|
||||
next.rightField = defaults.r;
|
||||
const parsed = parseThresholdField(defaults.r);
|
||||
if (parsed != null) next.targetValue = parsed;
|
||||
if (!isThresholdOverridden(cond) && next.rightField?.startsWith('K_')) {
|
||||
next.rightField = inferThresholdSymbolFromLegacy(
|
||||
next.rightField,
|
||||
cond.indicatorType,
|
||||
def.hlThresh,
|
||||
) ?? defaultInheritedThresholdField(cond.indicatorType);
|
||||
const { targetValue: _t, ...rest } = next;
|
||||
next = { ...rest, thresholdOverride: false };
|
||||
}
|
||||
|
||||
return next;
|
||||
|
||||
@@ -27,12 +27,14 @@ import {
|
||||
getCompositePeriodPresetOptions,
|
||||
getConditionRightPeriod,
|
||||
getConditionThreshold,
|
||||
getChartReferenceThreshold,
|
||||
getConditionValuePeriod,
|
||||
getDefaultIndicatorPeriod,
|
||||
getPeriodPresetOptions,
|
||||
getThresholdBounds,
|
||||
getThresholdPresetOptions,
|
||||
initConditionPeriodsInherit,
|
||||
isThresholdOverridden,
|
||||
isValuePeriodFieldStored,
|
||||
parseThresholdField,
|
||||
resolveFieldCustomKind,
|
||||
@@ -44,6 +46,14 @@ import {
|
||||
setConditionRightPeriod,
|
||||
setConditionValuePeriod,
|
||||
} 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';
|
||||
|
||||
export interface StrategyDto {
|
||||
@@ -292,13 +302,6 @@ export function buildStrategyEditorDef(): DefType {
|
||||
return buildDef([]);
|
||||
}
|
||||
|
||||
/**
|
||||
* hline 임계값을 K_ 접두어 DSL 필드값으로 변환.
|
||||
* 예: 70 → "K_70", -100 → "K_-100"
|
||||
* 백엔드 StrategyDslToTa4jAdapter에서 "K_" 접두어를 파싱하여 FixedDecimalIndicator 생성.
|
||||
*/
|
||||
const kv = (price: number) => `K_${price}`;
|
||||
|
||||
// ─── 공통 조건 옵션 ───────────────────────────────────────────────────────────
|
||||
const COND_OPTIONS = [
|
||||
{ v: 'GT', l: '초과 (>)' },
|
||||
@@ -361,9 +364,9 @@ export const getFieldOpts = (
|
||||
const rsiP = cond ? getConditionValuePeriod({ ...cond, indicatorType: 'RSI' }, DEF) : DEF.rsiPeriod;
|
||||
return condOpts([
|
||||
{ value:'RSI_VALUE', label:`RSI 라인(${rsiP}일)` },
|
||||
{ value:kv(over), label:`${overTerm}(${over})` },
|
||||
{ value:kv(mid), label:`중앙선(${mid})` },
|
||||
{ value:kv(under), label:`${underTerm}(${under})` },
|
||||
{ value:THRESHOLD_HL_OVER, label:`${overTerm}(${over})` },
|
||||
{ value:THRESHOLD_HL_MID, label:`중앙선(${mid})` },
|
||||
{ value:THRESHOLD_HL_UNDER, label:`${underTerm}(${under})` },
|
||||
]);
|
||||
}
|
||||
case 'STOCHASTIC': {
|
||||
@@ -371,9 +374,9 @@ export const getFieldOpts = (
|
||||
return condOpts([
|
||||
{ value:'STOCH_K', label:`Stochastic %K(${DEF.stochK}일)` },
|
||||
{ value:'STOCH_D', label:`Stochastic %D(${DEF.stochD}일)` },
|
||||
{ value:kv(over), label:`${overTerm}(${over})` },
|
||||
{ value:kv(mid), label:`중앙선(${mid})` },
|
||||
{ value:kv(under), label:`${underTerm}(${under})` },
|
||||
{ value:THRESHOLD_HL_OVER, label:`${overTerm}(${over})` },
|
||||
{ value:THRESHOLD_HL_MID, label:`중앙선(${mid})` },
|
||||
{ value:THRESHOLD_HL_UNDER, label:`${underTerm}(${under})` },
|
||||
]);
|
||||
}
|
||||
case 'CCI': {
|
||||
@@ -381,18 +384,18 @@ export const getFieldOpts = (
|
||||
const cciP = cond ? getConditionValuePeriod({ ...cond, indicatorType: 'CCI' }, DEF) : DEF.cciPeriod;
|
||||
return condOpts([
|
||||
{ value:'CCI_VALUE', label:`CCI 라인(${cciP}일)` },
|
||||
{ value:kv(over), label:`${overTerm}(${over})` },
|
||||
{ value:kv(mid), label:`중앙선(${mid})` },
|
||||
{ value:kv(under), label:`${underTerm}(${under})` },
|
||||
{ value:THRESHOLD_HL_OVER, label:`${overTerm}(${over})` },
|
||||
{ value:THRESHOLD_HL_MID, label:`중앙선(${mid})` },
|
||||
{ value:THRESHOLD_HL_UNDER, label:`${underTerm}(${under})` },
|
||||
]);
|
||||
}
|
||||
case 'ADX': {
|
||||
const { over, mid, under } = th({ over: 40, mid: 25, under: 20 });
|
||||
return condOpts([
|
||||
{ value:'ADX_VALUE', label:`ADX 라인(${DEF.adxPeriod}일)` },
|
||||
{ value:kv(over), label:`${overTerm}(${over})` },
|
||||
{ value:kv(mid), label:`중앙선(${mid})` },
|
||||
{ value:kv(under), label:`${underTerm}(${under})` },
|
||||
{ value:THRESHOLD_HL_OVER, label:`${overTerm}(${over})` },
|
||||
{ value:THRESHOLD_HL_MID, label:`중앙선(${mid})` },
|
||||
{ value:THRESHOLD_HL_UNDER, label:`${underTerm}(${under})` },
|
||||
]);
|
||||
}
|
||||
case 'TRIX': {
|
||||
@@ -400,7 +403,7 @@ export const getFieldOpts = (
|
||||
return condOpts([
|
||||
{ value:'TRIX_VALUE', label:`TRIX 라인(${DEF.trixPeriod}일)` },
|
||||
{ value:'TRIX_SIGNAL', label:`TRIX 시그널(${DEF.trixSignal}일)` },
|
||||
{ value:kv(mid), label:`중앙선(${mid})` },
|
||||
{ value:THRESHOLD_HL_MID, label:`중앙선(${mid})` },
|
||||
]);
|
||||
}
|
||||
case 'DMI': {
|
||||
@@ -408,9 +411,9 @@ export const getFieldOpts = (
|
||||
return condOpts([
|
||||
{ value:'PDI', label:`DI+(${DEF.dmiPeriod}일)` },
|
||||
{ value:'MDI', label:`DI-(${DEF.dmiPeriod}일)` },
|
||||
{ value:kv(over), label:`과열(${over})` },
|
||||
{ value:kv(mid), label:`중간값(${mid})` },
|
||||
{ value:kv(under), label:`침체(${under})` },
|
||||
{ value:THRESHOLD_HL_OVER, label:`과열(${over})` },
|
||||
{ value:THRESHOLD_HL_MID, label:`중간값(${mid})` },
|
||||
{ value:THRESHOLD_HL_UNDER, label:`침체(${under})` },
|
||||
]);
|
||||
}
|
||||
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}일)` })),
|
||||
{ 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.dispShort}`, label:`단기 이격도(${DEF.dispShort}일)` },
|
||||
{ value:`DISPARITY${DEF.dispMid}`, label:`중기 이격도(${DEF.dispMid}일)` },
|
||||
{ value:`DISPARITY${DEF.dispLong}`, label:`장기 이격도(${DEF.dispLong}일)` },
|
||||
{ value:kv(100), label:'중앙선(100)' },
|
||||
{ value:THRESHOLD_HL_MID, label:`중앙선(${mid})` },
|
||||
]);
|
||||
}
|
||||
case 'PSYCHOLOGICAL':
|
||||
case 'NEW_PSYCHOLOGICAL': {
|
||||
const { over, mid, under } = th({ over:75, mid:50, under:25 });
|
||||
return condOpts([
|
||||
{ value:'PSY_VALUE', label:`심리도 라인(${DEF.newPsy}일)` },
|
||||
{ value:kv(over), label:`${overTerm}(${over})` },
|
||||
{ value:kv(mid), label:`중앙선(${mid})` },
|
||||
{ value:kv(under), label:`${underTerm}(${under})` },
|
||||
{ value:THRESHOLD_HL_OVER, label:`${overTerm}(${over})` },
|
||||
{ value:THRESHOLD_HL_MID, label:`중앙선(${mid})` },
|
||||
{ value:THRESHOLD_HL_UNDER, label:`${underTerm}(${under})` },
|
||||
]);
|
||||
}
|
||||
case 'INVEST_PSYCHOLOGICAL': {
|
||||
const { over, mid, under } = th({ over:75, mid:50, under:25 });
|
||||
return condOpts([
|
||||
{ value:'INVEST_PSY_VALUE', label:`투자심리도 라인(${DEF.investPsy}일)` },
|
||||
{ value:kv(over), label:`${overTerm}(${over})` },
|
||||
{ value:kv(mid), label:`중앙선(${mid})` },
|
||||
{ value:kv(under), label:`${underTerm}(${under})` },
|
||||
{ value:THRESHOLD_HL_OVER, label:`${overTerm}(${over})` },
|
||||
{ value:THRESHOLD_HL_MID, label:`중앙선(${mid})` },
|
||||
{ value:THRESHOLD_HL_UNDER, label:`${underTerm}(${under})` },
|
||||
]);
|
||||
}
|
||||
case 'WILLIAMS_R': {
|
||||
const { over, mid, under } = th({ over:-20, mid:-50, under:-80 });
|
||||
return condOpts([
|
||||
{ value:'WILLIAMS_R_VALUE', label:`Williams %R 라인(${DEF.williamsR}일)` },
|
||||
{ value:kv(over), label:`${overTerm}(${over})` },
|
||||
{ value:kv(mid), label:`중앙선(${mid})` },
|
||||
{ value:kv(under), label:`${underTerm}(${under})` },
|
||||
{ value:THRESHOLD_HL_OVER, label:`${overTerm}(${over})` },
|
||||
{ value:THRESHOLD_HL_MID, label:`중앙선(${mid})` },
|
||||
{ value:THRESHOLD_HL_UNDER, label:`${underTerm}(${under})` },
|
||||
]);
|
||||
}
|
||||
case 'BWI': {
|
||||
const { over, mid, under } = th({ over:80, mid:50, under:20 });
|
||||
return condOpts([
|
||||
{ value:'BWI_VALUE', label:`BWI 라인(${DEF.bwiPeriod}일)` },
|
||||
{ value:kv(over), label:`${overTerm}(${over})` },
|
||||
{ value:kv(mid), label:`중앙선(${mid})` },
|
||||
{ value:kv(under), label:`${underTerm}(${under})` },
|
||||
{ value:THRESHOLD_HL_OVER, label:`${overTerm}(${over})` },
|
||||
{ value:THRESHOLD_HL_MID, label:`중앙선(${mid})` },
|
||||
{ value:THRESHOLD_HL_UNDER, label:`${underTerm}(${under})` },
|
||||
]);
|
||||
}
|
||||
case 'VR': {
|
||||
const { over, mid, under } = th({ over:200, mid:100, under:50 });
|
||||
return condOpts([
|
||||
{ value:'VR_VALUE', label:`VR 라인(${DEF.vrPeriod}일)` },
|
||||
{ value:kv(over), label:`${overTerm}(${over})` },
|
||||
{ value:kv(mid), label:`중앙선(${mid})` },
|
||||
{ value:kv(under), label:`${underTerm}(${under})` },
|
||||
{ value:THRESHOLD_HL_OVER, label:`${overTerm}(${over})` },
|
||||
{ value:THRESHOLD_HL_MID, label:`중앙선(${mid})` },
|
||||
{ value:THRESHOLD_HL_UNDER, label:`${underTerm}(${under})` },
|
||||
]);
|
||||
}
|
||||
case 'VOLUME_OSC': {
|
||||
const { mid } = th({ mid:0 });
|
||||
return condOpts([
|
||||
{ value:'VOLUME_OSC_VALUE', label:`거래량 오실레이터 값(${DEF.volOscShort}일/${DEF.volOscLong}일)` },
|
||||
{ value:kv(mid), label:`중앙선(${mid})` },
|
||||
{ value:THRESHOLD_HL_MID, label:`중앙선(${mid})` },
|
||||
]);
|
||||
}
|
||||
case 'MACD': {
|
||||
@@ -497,7 +503,7 @@ export const getFieldOpts = (
|
||||
{ value:'MACD_LINE', label:`MACD 라인(${DEF.macdFast}일/${DEF.macdSlow}일)` },
|
||||
{ value:'SIGNAL_LINE', label:`시그널 라인(${DEF.macdSignal}일)` },
|
||||
{ value:'HISTOGRAM', label:'히스토그램' },
|
||||
{ value:kv(mid), label:`중앙선(${mid})` },
|
||||
{ value:THRESHOLD_HL_MID, label:`중앙선(${mid})` },
|
||||
]);
|
||||
}
|
||||
case 'BOLLINGER': return condOpts([
|
||||
@@ -559,30 +565,25 @@ export const getFieldOpts = (
|
||||
};
|
||||
|
||||
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}> = {
|
||||
RSI: { l:'RSI_VALUE', r: over(70) },
|
||||
RSI: { l:'RSI_VALUE', r: THRESHOLD_HL_OVER },
|
||||
STOCHASTIC: { l:'STOCH_K', r:'STOCH_D' },
|
||||
CCI: { l:'CCI_VALUE', r: over(100) },
|
||||
ADX: { l:'ADX_VALUE', r: kv(adxMid) },
|
||||
CCI: { l:'CCI_VALUE', r: THRESHOLD_HL_OVER },
|
||||
ADX: { l:'ADX_VALUE', r: THRESHOLD_HL_MID },
|
||||
TRIX: { l:'TRIX_VALUE', r:'TRIX_SIGNAL' },
|
||||
DMI: { l:'PDI', r:'MDI' },
|
||||
OBV: { l:'OBV_LINE', r:'OBV_SIGNAL' },
|
||||
VOLUME: { l:'VOLUME_VALUE', r:'VOLUME_MA' },
|
||||
MA: { l:`MA${DEF.maLines[0]}`, r:`MA${DEF.maLines[1]}` },
|
||||
EMA: { l:`EMA${DEF.maLines[0]}`, r:`EMA${DEF.maLines[1]}` },
|
||||
DISPARITY: { l:`DISPARITY${DEF.dispUltra}`, r: mid(100) },
|
||||
PSYCHOLOGICAL: { l:'PSY_VALUE', r: over(75) },
|
||||
NEW_PSYCHOLOGICAL: { l:'PSY_VALUE', r: over(75) },
|
||||
INVEST_PSYCHOLOGICAL: { l:'INVEST_PSY_VALUE', r: over(75) },
|
||||
WILLIAMS_R: { l:'WILLIAMS_R_VALUE', r: over(-20) },
|
||||
BWI: { l:'BWI_VALUE', r: over(80) },
|
||||
VR: { l:'VR_VALUE', r: over(200) },
|
||||
VOLUME_OSC: { l:'VOLUME_OSC_VALUE', r: mid(0) },
|
||||
DISPARITY: { l:`DISPARITY${DEF.dispUltra}`, r: THRESHOLD_HL_MID },
|
||||
PSYCHOLOGICAL: { l:'PSY_VALUE', r: THRESHOLD_HL_OVER },
|
||||
NEW_PSYCHOLOGICAL: { l:'PSY_VALUE', r: THRESHOLD_HL_OVER },
|
||||
INVEST_PSYCHOLOGICAL: { l:'INVEST_PSY_VALUE', r: THRESHOLD_HL_OVER },
|
||||
WILLIAMS_R: { l:'WILLIAMS_R_VALUE', r: THRESHOLD_HL_OVER },
|
||||
BWI: { l:'BWI_VALUE', r: THRESHOLD_HL_OVER },
|
||||
VR: { l:'VR_VALUE', r: THRESHOLD_HL_OVER },
|
||||
VOLUME_OSC: { l:'VOLUME_OSC_VALUE', r: THRESHOLD_HL_MID },
|
||||
MACD: { l:'MACD_LINE', r:'SIGNAL_LINE' },
|
||||
BOLLINGER: { l:'CLOSE_PRICE', r:'UPPER_BAND' },
|
||||
DONCHIAN: signalType === 'buy'
|
||||
@@ -682,10 +683,17 @@ export const nodeToText = (node: LogicNode, DEF: DefType = DEF_DEFAULTS): string
|
||||
const tf = lCt !== rCt ? ` [${lCt}/${rCt}]` : ` [${lCt}]`;
|
||||
return `${indName} - ${L} ${C} ${R}${tf}`;
|
||||
}
|
||||
const defaults = getDefaultConditionFields(c.indicatorType, 'buy', DEF);
|
||||
const lv = resolveFieldOptionValue(c.indicatorType, c.leftField);
|
||||
const rv = resolveFieldOptionValue(c.indicatorType, c.rightField);
|
||||
const rvRaw = c.rightField;
|
||||
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
|
||||
?? (chartRef != null && !isThresholdOverridden(c) ? `기준(${chartRef})` : null)
|
||||
?? (rv && parseThresholdField(rv) != null ? `임계(${parseThresholdField(rv)})` : rv ?? '');
|
||||
if (R && R !== '선택안함') return `${indName} - ${L} ${C} ${R}`;
|
||||
return `${indName} - ${L} ${C}`;
|
||||
@@ -800,7 +808,13 @@ export const CondEditor: React.FC<CondEditorProps> = ({ cond, signalType, onChan
|
||||
return isValid(v) ? v! : defaults.l;
|
||||
};
|
||||
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;
|
||||
};
|
||||
|
||||
@@ -812,6 +826,11 @@ export const CondEditor: React.FC<CondEditorProps> = ({ cond, signalType, onChan
|
||||
|
||||
const handleCondType = (newType: string) => onChange(applyCondTypeDefaults(normalized, newType, def));
|
||||
const handleRight = (v: string) => {
|
||||
if (isThresholdSymbol(v)) {
|
||||
const { targetValue: _t, ...rest } = normalized;
|
||||
onChange({ ...rest, rightField: v, thresholdOverride: false });
|
||||
return;
|
||||
}
|
||||
const thresholds: Record<string,number> = {
|
||||
OVERBOUGHT_70: 70, NEUTRAL_50: 50, OVERSOLD_30: 30,
|
||||
OVERBOUGHT_80: 80, OVERSOLD_20: 20,
|
||||
@@ -1005,7 +1024,8 @@ export const CondEditor: React.FC<CondEditorProps> = ({ cond, signalType, onChan
|
||||
fieldValue={rightValue}
|
||||
isPresetField={fieldOpts.some(o => o.value === 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)
|
||||
?? 0}
|
||||
numberPresets={getThresholdPresetOptions(normalized.indicatorType)}
|
||||
@@ -1014,7 +1034,7 @@ export const CondEditor: React.FC<CondEditorProps> = ({ cond, signalType, onChan
|
||||
allowDecimal={normalized.indicatorType === 'CCI'}
|
||||
disabled={isSlopeType || isHoldType}
|
||||
onFieldChange={handleRight}
|
||||
onCustomNumberChange={v => onChange(setConditionThreshold(normalized, 'right', v))}
|
||||
onCustomNumberChange={v => onChange(setConditionThreshold(normalized, 'right', v, def))}
|
||||
/>
|
||||
</div>
|
||||
{/* 조건 */}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user