From e2816b037fe4cd08c314315d3948c95c9b70fb28 Mon Sep 17 00:00:00 2001 From: Macbook Date: Thu, 28 May 2026 09:20:06 +0900 Subject: [PATCH] =?UTF-8?q?=EC=A0=84=EB=9E=B5=ED=8E=B8=EC=A7=91=EA=B8=B0?= =?UTF-8?q?=20=EA=B8=B0=EC=A4=80=EA=B0=92=20=EB=B3=B4=EC=A1=B0=EC=A7=80?= =?UTF-8?q?=ED=91=9C=20=EC=84=A4=EC=A0=95=EA=B0=92=20=EB=8F=99=EA=B8=B0?= =?UTF-8?q?=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/IndicatorHlineResolver.java | 139 +++++++++++++++++ .../service/LiveConditionStatusService.java | 24 +-- .../service/LiveStrategyEvaluator.java | 6 +- .../service/StrategyDslToTa4jAdapter.java | 59 +++++-- .../StrategyTriggerBranchEvaluator.java | 4 +- .../StrategyTriggerBranchEvaluatorTest.java | 14 +- .../src/components/StrategyEditorPage.tsx | 17 +- frontend/src/components/Toolbar.tsx | 2 +- .../strategyEditor/ConditionNodeSettings.tsx | 37 +++-- frontend/src/hooks/useDraggablePanel.ts | 22 ++- frontend/src/hooks/useIndicatorSettings.ts | 24 ++- frontend/src/utils/compositeIndicators.ts | 5 +- frontend/src/utils/conditionPeriods.ts | 108 ++++++++++--- .../src/utils/strategyConditionNormalize.ts | 141 +++++++++++++++++ frontend/src/utils/strategyConditionSerde.ts | 3 +- frontend/src/utils/strategyDefSync.ts | 19 ++- frontend/src/utils/strategyEditorShared.tsx | 146 ++++++++++-------- frontend/src/utils/thresholdSymbols.ts | 93 +++++++++++ 18 files changed, 710 insertions(+), 153 deletions(-) create mode 100644 backend/src/main/java/com/goldenchart/service/IndicatorHlineResolver.java create mode 100644 frontend/src/utils/strategyConditionNormalize.ts create mode 100644 frontend/src/utils/thresholdSymbols.ts diff --git a/backend/src/main/java/com/goldenchart/service/IndicatorHlineResolver.java b/backend/src/main/java/com/goldenchart/service/IndicatorHlineResolver.java new file mode 100644 index 0000000..78362a4 --- /dev/null +++ b/backend/src/main/java/com/goldenchart/service/IndicatorHlineResolver.java @@ -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> 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> indicatorVisual) { + + String registryKey = StrategyDslToTa4jAdapter.registryKeyForDsl(dslIndicatorType); + if (indicatorVisual == null || registryKey == null) return defaultForRole(dslIndicatorType, role); + + Map visual = indicatorVisual.get(registryKey); + if (visual == null) return defaultForRole(dslIndicatorType, role); + + @SuppressWarnings("unchecked") + List> hlines = (List>) 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 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 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> 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; + }; + } +} diff --git a/backend/src/main/java/com/goldenchart/service/LiveConditionStatusService.java b/backend/src/main/java/com/goldenchart/service/LiveConditionStatusService.java index f9c1a22..905b479 100644 --- a/backend/src/main/java/com/goldenchart/service/LiveConditionStatusService.java +++ b/backend/src/main/java/com/goldenchart/service/LiveConditionStatusService.java @@ -59,6 +59,8 @@ public class LiveConditionStatusService { Map> params = indicatorSettingsService.getAll(userId, deviceId); + Map> visual = + indicatorSettingsService.getAllVisual(userId, deviceId); List 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> 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> 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) {} } diff --git a/backend/src/main/java/com/goldenchart/service/LiveStrategyEvaluator.java b/backend/src/main/java/com/goldenchart/service/LiveStrategyEvaluator.java index 3be8cde..0a688a0 100644 --- a/backend/src/main/java/com/goldenchart/service/LiveStrategyEvaluator.java +++ b/backend/src/main/java/com/goldenchart/service/LiveStrategyEvaluator.java @@ -322,6 +322,8 @@ public class LiveStrategyEvaluator { BarSeries series = ta4jStorage.getOrCreate(market, candleType); Map> indicatorParams = indicatorSettingsService.getAll(userId, deviceId); + Map> 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()); diff --git a/backend/src/main/java/com/goldenchart/service/StrategyDslToTa4jAdapter.java b/backend/src/main/java/com/goldenchart/service/StrategyDslToTa4jAdapter.java index febb21e..d5ce94b 100644 --- a/backend/src/main/java/com/goldenchart/service/StrategyDslToTa4jAdapter.java +++ b/backend/src/main/java/com/goldenchart/service/StrategyDslToTa4jAdapter.java @@ -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> indicatorParams, + Map> indicatorVisual, String market, Ta4jStorage storage ) { + /** visual 미전달 호환 */ + public RuleBuildContext(BarSeries primarySeries, + Map> 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> 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> 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> indicatorParams, + Map> 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 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> 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> 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> p) { - return buildNotRule(node, new RuleBuildContext(series, p, null, null)); + return buildNotRule(node, new RuleBuildContext(series, p, Map.of(), null, null)); } private List childRules(JsonNode node, BarSeries series, Map> 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 left = resolveField(leftField, indType, indParams, series, condPeriod, leftPeriod); - Indicator right = resolveField(rightField, indType, indParams, series, condPeriod, rightPeriod); + Indicator left = resolveField(leftField, indType, indParams, series, condPeriod, leftPeriod, cond, ctx); + Indicator 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 left = resolveField(leftField, indType, indParams, leftSeries, condPeriod, leftPeriod); - Indicator right = resolveField(rightField, indType, indParams, rightSeries, condPeriod, rightPeriod); + Indicator left = resolveField(leftField, indType, indParams, leftSeries, condPeriod, leftPeriod, cond, ctx); + Indicator 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 resolveField(String field, String indType, Map 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 resolveField(String field, String indType, + Map 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 diff --git a/backend/src/main/java/com/goldenchart/service/StrategyTriggerBranchEvaluator.java b/backend/src/main/java/com/goldenchart/service/StrategyTriggerBranchEvaluator.java index 462f687..163514c 100644 --- a/backend/src/main/java/com/goldenchart/service/StrategyTriggerBranchEvaluator.java +++ b/backend/src/main/java/com/goldenchart/service/StrategyTriggerBranchEvaluator.java @@ -47,6 +47,7 @@ public class StrategyTriggerBranchEvaluator { String side, String triggerCandleType, Map> indicatorParams, + Map> 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); diff --git a/backend/src/test/java/com/goldenchart/service/StrategyTriggerBranchEvaluatorTest.java b/backend/src/test/java/com/goldenchart/service/StrategyTriggerBranchEvaluatorTest.java index 6d68702..1afef45 100644 --- a/backend/src/test/java/com/goldenchart/service/StrategyTriggerBranchEvaluatorTest.java +++ b/backend/src/test/java/com/goldenchart/service/StrategyTriggerBranchEvaluatorTest.java @@ -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)); } diff --git a/frontend/src/components/StrategyEditorPage.tsx b/frontend/src/components/StrategyEditorPage.tsx index d7949be..bf3a27c 100644 --- a/frontend/src/components/StrategyEditorPage.tsx +++ b/frontend/src/components/StrategyEditorPage.tsx @@ -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(() => loadStratsLocal()); const [selectedId, setSelectedId] = useState(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', ); diff --git a/frontend/src/components/Toolbar.tsx b/frontend/src/components/Toolbar.tsx index f857632..128ee3b 100644 --- a/frontend/src/components/Toolbar.tsx +++ b/frontend/src/components/Toolbar.tsx @@ -511,7 +511,7 @@ const IndDropdown: React.FC = ({ onHeaderPointerDown, headerTouchStyle, panelStyle, headerCursor, - } = useDraggablePanel({ centerOnMount: true, initialPosition: { x: 0, y: 56 } }); + } = useDraggablePanel({ centerOnMount: true }); return ( // 배경 클릭 시 닫힘 diff --git a/frontend/src/components/strategyEditor/ConditionNodeSettings.tsx b/frontend/src/components/strategyEditor/ConditionNodeSettings.tsx index 64c9002..fa1022a 100644 --- a/frontend/src/components/strategyEditor/ConditionNodeSettings.tsx +++ b/frontend/src/components/strategyEditor/ConditionNodeSettings.tsx @@ -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({ /> ) : null} - {showThreshold && rightThreshold != null && ( - + {showThreshold && chartRef != null && ( + <> + + + )} {condition.composite && (

{compositeDisplayName(condition.indicatorType)}

diff --git a/frontend/src/hooks/useDraggablePanel.ts b/frontend/src/hooks/useDraggablePanel.ts index 9f338f9..afab0f9 100644 --- a/frontend/src/hooks/useDraggablePanel.ts +++ b/frontend/src/hooks/useDraggablePanel.ts @@ -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(null); const panelRef = (externalRef ?? internalRef) as RefObject; - const [pos, setPos] = useState(initialPosition); + const [pos, setPos] = useState(() => + 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 { diff --git a/frontend/src/hooks/useIndicatorSettings.ts b/frontend/src/hooks/useIndicatorSettings.ts index c7210f6..53edc80 100644 --- a/frontend/src/hooks/useIndicatorSettings.ts +++ b/frontend/src/hooks/useIndicatorSettings.ts @@ -67,6 +67,9 @@ let _loadPromise: Promise | null = null; let _visualCache: VisualCache | null = null; let _visualLoadPromise: Promise | null = null; +/** 로그인 세션별 1회만 DB 로드 — 페이지 전환 시 캐시 유지 */ +let _loadedSessionKey: number | null = null; + function ensureLoaded(): Promise { 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); }); diff --git a/frontend/src/utils/compositeIndicators.ts b/frontend/src/utils/compositeIndicators.ts index ec27186..4d5ddf1 100644 --- a/frontend/src/utils/compositeIndicators.ts +++ b/frontend/src/utils/compositeIndicators.ts @@ -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 = { diff --git a/frontend/src/utils/conditionPeriods.ts b/frontend/src/utils/conditionPeriods.ts index f6067a6..24c6067 100644 --- a/frontend/src/utils/conditionPeriods.ts +++ b/frontend/src/utils/conditionPeriods.ts @@ -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 }, +): 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 }, ): 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 }, +): 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( diff --git a/frontend/src/utils/strategyConditionNormalize.ts b/frontend/src/utils/strategyConditionNormalize.ts new file mode 100644 index 0000000..cdded17 --- /dev/null +++ b/frontend/src/utils/strategyConditionNormalize.ts @@ -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; +} diff --git a/frontend/src/utils/strategyConditionSerde.ts b/frontend/src/utils/strategyConditionSerde.ts index 5637471..5e0e123 100644 --- a/frontend/src/utils/strategyConditionSerde.ts +++ b/frontend/src/utils/strategyConditionSerde.ts @@ -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); diff --git a/frontend/src/utils/strategyDefSync.ts b/frontend/src/utils/strategyDefSync.ts index 694496f..d2127d3 100644 --- a/frontend/src/utils/strategyDefSync.ts +++ b/frontend/src/utils/strategyDefSync.ts @@ -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; diff --git a/frontend/src/utils/strategyEditorShared.tsx b/frontend/src/utils/strategyEditorShared.tsx index 3006407..bea28ba 100644 --- a/frontend/src/utils/strategyEditorShared.tsx +++ b/frontend/src/utils/strategyEditorShared.tsx @@ -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 = { - 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 L = opts.find(o => o.value === lv)?.label ?? c.leftField ?? indName; + 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 = ({ 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 = ({ 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 = { OVERBOUGHT_70: 70, NEUTRAL_50: 50, OVERSOLD_30: 30, OVERBOUGHT_80: 80, OVERSOLD_20: 20, @@ -1005,7 +1024,8 @@ export const CondEditor: React.FC = ({ 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 = ({ 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))} /> {/* 조건 */} diff --git a/frontend/src/utils/thresholdSymbols.ts b/frontend/src/utils/thresholdSymbols.ts new file mode 100644 index 0000000..4b3d0e9 --- /dev/null +++ b/frontend/src/utils/thresholdSymbols.ts @@ -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, +): 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 | 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 | 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; + } +}