From e5b5af093eb411c1a6d0af277bf8eb92b34cf843 Mon Sep 17 00:00:00 2001 From: Macbook Date: Thu, 18 Jun 2026 00:47:58 +0900 Subject: [PATCH] =?UTF-8?q?=EC=A7=81=EC=A0=91=EC=9E=85=EB=A0=A5=EA=B0=92?= =?UTF-8?q?=20=ED=8F=89=EA=B0=80=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/IndicatorHlineResolver.java | 33 +---- .../StrategyConditionThresholdNormalizer.java | 88 ++----------- .../service/IndicatorHlineResolverTest.java | 60 +++++++++ .../service/RsiThresholdCrossSignalTest.java | 65 +++++++++ ...ategyConditionThresholdNormalizerTest.java | 56 +++++++- frontend/scripts/test-threshold-normalize.mjs | 124 +++++++----------- .../strategyEditor/ConditionNodeSettings.tsx | 4 +- frontend/src/utils/conditionPeriods.ts | 101 ++++++-------- .../src/utils/strategyConditionNormalize.ts | 36 +---- .../src/utils/strategyDescriptionNarrative.ts | 4 +- frontend/src/utils/strategyEditorShared.tsx | 10 +- 11 files changed, 297 insertions(+), 284 deletions(-) create mode 100644 backend/src/test/java/com/goldenchart/service/IndicatorHlineResolverTest.java diff --git a/backend/src/main/java/com/goldenchart/service/IndicatorHlineResolver.java b/backend/src/main/java/com/goldenchart/service/IndicatorHlineResolver.java index 5fc0e18..2eeca3b 100644 --- a/backend/src/main/java/com/goldenchart/service/IndicatorHlineResolver.java +++ b/backend/src/main/java/com/goldenchart/service/IndicatorHlineResolver.java @@ -22,13 +22,15 @@ public final class IndicatorHlineResolver { boolean override = cond != null && cond.path("thresholdOverride").asBoolean(false); - // K_ 직접입력 — targetValue·override 시 필드명/스냅샷 숫자 우선 + // K_* = 직접입력 숫자 스냅샷 — hline 역할로 치환하지 않음 (K_55 → 55) if (field.startsWith("K_")) { if (cond != null && cond.has("targetValue") && !cond.path("targetValue").isNull()) { return cond.path("targetValue").asDouble(); } - if (override) { - try { return Double.parseDouble(field.substring(2)); } catch (NumberFormatException ignored) {} + try { + return Double.parseDouble(field.substring(2)); + } catch (NumberFormatException ignored) { + return null; } } @@ -36,9 +38,6 @@ public final class IndicatorHlineResolver { 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); } @@ -47,12 +46,6 @@ public final class IndicatorHlineResolver { 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); } @@ -99,22 +92,6 @@ public final class IndicatorHlineResolver { 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"); diff --git a/backend/src/main/java/com/goldenchart/service/StrategyConditionThresholdNormalizer.java b/backend/src/main/java/com/goldenchart/service/StrategyConditionThresholdNormalizer.java index c1197f9..7c4d898 100644 --- a/backend/src/main/java/com/goldenchart/service/StrategyConditionThresholdNormalizer.java +++ b/backend/src/main/java/com/goldenchart/service/StrategyConditionThresholdNormalizer.java @@ -3,12 +3,11 @@ package com.goldenchart.service; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.ObjectNode; -import java.util.Map; import java.util.Locale; /** - * 전략 조건 DSL — K_50 등 직접입력 스냅샷을 hline 역할(HL_MID 등)로 정규화. - * 프론트 normalizeConditionForPersistence 와 동일 의도. + * 전략 조건 DSL — 직접입력 K_* 필드에 targetValue·thresholdOverride 동기화. + * HL_* 프리셋은 그대로 두며, K_55 등 입력값을 중앙선 역할로 치환하지 않는다. */ public final class StrategyConditionThresholdNormalizer { @@ -44,82 +43,23 @@ public final class StrategyConditionThresholdNormalizer { if (!indType.isBlank()) { cond.put("indicatorType", indType); } - normalizeThresholdField(cond, "rightField", indType); - normalizeThresholdField(cond, "leftField", indType); + syncDirectKField(cond, "rightField"); + syncDirectKField(cond, "leftField"); } - private static void normalizeThresholdField(ObjectNode cond, String fieldKey, String indType) { + /** K_55 → targetValue 55, thresholdOverride true (평가 시 입력 숫자 그대로) */ + private static void syncDirectKField(ObjectNode cond, String fieldKey) { String field = cond.path(fieldKey).asText(""); - if (field.isBlank()) return; - - boolean override = cond.path("thresholdOverride").asBoolean(false); - Double value = readThresholdValue(cond, field); - - if (override || field.startsWith("K_")) { - String role = inferRoleForValue(indType, value, field); - if (role != null) { - cond.put(fieldKey, role); - cond.remove("targetValue"); - cond.put("thresholdOverride", false); - return; + if (!field.startsWith("K_")) return; + try { + double val = Double.parseDouble(field.substring(2)); + if (!cond.has("targetValue") || cond.path("targetValue").isNull()) { + cond.put("targetValue", val); } + cond.put("thresholdOverride", true); + } catch (NumberFormatException ignored) { + // 비정상 K_ 필드는 그대로 둠 } - - if (!override && field.startsWith("K_")) { - String role = inferRoleForValue(indType, value, field); - if (role != null) { - cond.put(fieldKey, role); - cond.remove("targetValue"); - cond.put("thresholdOverride", false); - } - } - } - - private static Double readThresholdValue(ObjectNode cond, String field) { - 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 null; - } - } - return null; - } - - private static String inferRoleForValue(String indicatorType, Double value, String field) { - if (value == null && field.startsWith("K_")) { - try { - value = Double.parseDouble(field.substring(2)); - } catch (NumberFormatException ignored) { - return null; - } - } - if (value == null || !Double.isFinite(value)) return null; - - double eps = 0.0001; - for (Map.Entry e : defaultRoles(indicatorType).entrySet()) { - if (Math.abs(e.getValue() - value) < eps) { - return e.getKey(); - } - } - return null; - } - - /** HL_OVER / HL_MID / HL_UNDER → 기본 숫자 (IndicatorHlineResolver.defaultForRole 과 동일) */ - private static Map defaultRoles(String indicatorType) { - return switch (indicatorType) { - case "RSI" -> Map.of("HL_OVER", 70.0, "HL_MID", 50.0, "HL_UNDER", 30.0); - case "STOCHASTIC" -> Map.of("HL_OVER", 80.0, "HL_MID", 50.0, "HL_UNDER", 20.0); - case "CCI" -> Map.of("HL_OVER", 100.0, "HL_MID", 0.0, "HL_UNDER", -100.0); - case "WILLIAMS_R" -> Map.of("HL_OVER", -20.0, "HL_MID", -50.0, "HL_UNDER", -80.0); - case "ADX" -> Map.of("HL_MID", 25.0); - case "DISPARITY" -> Map.of("HL_MID", 100.0); - case "VOLUME_OSC", "TRIX", "MACD" -> Map.of("HL_MID", 0.0); - default -> Map.of(); - }; } /** JSON 문자열 보정 — 파싱 실패 시 원본 반환 */ diff --git a/backend/src/test/java/com/goldenchart/service/IndicatorHlineResolverTest.java b/backend/src/test/java/com/goldenchart/service/IndicatorHlineResolverTest.java new file mode 100644 index 0000000..1c17172 --- /dev/null +++ b/backend/src/test/java/com/goldenchart/service/IndicatorHlineResolverTest.java @@ -0,0 +1,60 @@ +package com.goldenchart.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; + +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +class IndicatorHlineResolverTest { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + @Test + void k55_alwaysResolvesTo55_notNearestHline() throws Exception { + var cond = MAPPER.readTree(""" + {"thresholdOverride": false, "targetValue": null} + """); + Map> visual = Map.of( + "RSI", Map.of("hlines", java.util.List.of( + Map.of("label", "과열선", "price", 70), + Map.of("label", "중앙선", "price", 50), + Map.of("label", "침체선", "price", 30)))); + + Double val = IndicatorHlineResolver.resolveThresholdField( + cond, "K_55", "RSI", visual); + assertEquals(55.0, val, 0.0001); + } + + @Test + void k55_withTargetValue_prefersTargetValue() throws Exception { + var cond = MAPPER.readTree(""" + {"thresholdOverride": true, "targetValue": 55} + """); + Double val = IndicatorHlineResolver.resolveThresholdField( + cond, "K_55", "RSI", Map.of()); + assertEquals(55.0, val, 0.0001); + } + + @Test + void hlMid_usesVisualNotKSemantics() throws Exception { + var cond = MAPPER.readTree(""" + {"thresholdOverride": false} + """); + Map> visual = Map.of( + "RSI", Map.of("hlines", java.util.List.of( + Map.of("label", "중앙선", "price", 48)))); + + Double val = IndicatorHlineResolver.resolveThresholdField( + cond, "HL_MID", "RSI", visual); + assertEquals(48.0, val, 0.0001); + } + + @Test + void blankField_returnsNull() { + assertNull(IndicatorHlineResolver.resolveThresholdField( + null, "", "RSI", Map.of())); + } +} diff --git a/backend/src/test/java/com/goldenchart/service/RsiThresholdCrossSignalTest.java b/backend/src/test/java/com/goldenchart/service/RsiThresholdCrossSignalTest.java index 5ff32c7..d207e10 100644 --- a/backend/src/test/java/com/goldenchart/service/RsiThresholdCrossSignalTest.java +++ b/backend/src/test/java/com/goldenchart/service/RsiThresholdCrossSignalTest.java @@ -62,6 +62,71 @@ class RsiThresholdCrossSignalTest { """); } + @Test + void rsiCrossUp_k55_producesSignals() throws Exception { + List hits50 = scanHits(""" + { + "indicatorType": "RSI", + "conditionType": "CROSS_UP", + "leftField": "RSI_VALUE_9", + "rightField": "K_50", + "period": 9, + "valuePeriodOverride": true, + "thresholdOverride": true, + "targetValue": 50, + "candleRange": 1 + } + """, Map.of("RSI", Map.of("length", 9)), Map.of()); + List hits55 = scanHits(""" + { + "indicatorType": "RSI", + "conditionType": "CROSS_UP", + "leftField": "RSI_VALUE_9", + "rightField": "K_55", + "period": 9, + "valuePeriodOverride": true, + "thresholdOverride": true, + "targetValue": 55, + "candleRange": 1 + } + """, Map.of("RSI", Map.of("length", 9)), Map.of()); + org.junit.jupiter.api.Assertions.assertFalse(hits55.isEmpty(), + "K_55 should produce CROSS_UP signals on oscillating series"); + org.junit.jupiter.api.Assertions.assertTrue(hits55.size() <= hits50.size(), + "K_55 (higher threshold) should have <= hits than K_50"); + } + + @Test + void rsiCrossUp_k55_withoutTargetValue_usesFieldNumber() throws Exception { + List hitsWithTarget = scanHits(""" + { + "indicatorType": "RSI", + "conditionType": "CROSS_UP", + "leftField": "RSI_VALUE_9", + "rightField": "K_55", + "period": 9, + "valuePeriodOverride": true, + "thresholdOverride": true, + "targetValue": 55, + "candleRange": 1 + } + """, Map.of("RSI", Map.of("length", 9)), Map.of()); + List hitsFieldOnly = scanHits(""" + { + "indicatorType": "RSI", + "conditionType": "CROSS_UP", + "leftField": "RSI_VALUE_9", + "rightField": "K_55", + "period": 9, + "valuePeriodOverride": true, + "thresholdOverride": false, + "candleRange": 1 + } + """, Map.of("RSI", Map.of("length", 9)), Map.of()); + assertEquals(hitsWithTarget, hitsFieldOnly, + "K_55 field should resolve to 55 even without targetValue"); + } + @Test void rsiCrossUp_hlMid_equalsK50WithoutOverride() throws Exception { assertSignalHitsEqual( diff --git a/backend/src/test/java/com/goldenchart/service/StrategyConditionThresholdNormalizerTest.java b/backend/src/test/java/com/goldenchart/service/StrategyConditionThresholdNormalizerTest.java index 13ed5f1..65e8a6e 100644 --- a/backend/src/test/java/com/goldenchart/service/StrategyConditionThresholdNormalizerTest.java +++ b/backend/src/test/java/com/goldenchart/service/StrategyConditionThresholdNormalizerTest.java @@ -5,14 +5,14 @@ import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; class StrategyConditionThresholdNormalizerTest { private static final ObjectMapper MAPPER = new ObjectMapper(); @Test - void normalizesK50OverrideToHlMid() throws Exception { + void preservesK50DirectInput() throws Exception { JsonNode root = MAPPER.readTree(""" { "type": "CONDITION", @@ -29,11 +29,53 @@ class StrategyConditionThresholdNormalizerTest { } """); - JsonNode normalized = StrategyConditionThresholdNormalizer.normalizeTree(root); - JsonNode cond = normalized.path("condition"); + JsonNode cond = StrategyConditionThresholdNormalizer.normalizeTree(root).path("condition"); - assertEquals("HL_MID", cond.path("rightField").asText()); - assertFalse(cond.path("thresholdOverride").asBoolean(true)); - assertFalse(cond.has("targetValue")); + assertEquals("K_50", cond.path("rightField").asText()); + assertTrue(cond.path("thresholdOverride").asBoolean(false)); + assertEquals(50.0, cond.path("targetValue").asDouble(), 0.0001); + } + + @Test + void preservesK55DirectInput() throws Exception { + JsonNode root = MAPPER.readTree(""" + { + "type": "CONDITION", + "condition": { + "indicatorType": "RSI", + "conditionType": "CROSS_UP", + "leftField": "RSI_VALUE", + "rightField": "K_55", + "thresholdOverride": true, + "targetValue": 55 + } + } + """); + + JsonNode cond = StrategyConditionThresholdNormalizer.normalizeTree(root).path("condition"); + + assertEquals("K_55", cond.path("rightField").asText()); + assertTrue(cond.path("thresholdOverride").asBoolean(false)); + assertEquals(55.0, cond.path("targetValue").asDouble(), 0.0001); + } + + @Test + void fillsTargetValueFromKFieldWhenMissing() throws Exception { + JsonNode root = MAPPER.readTree(""" + { + "type": "CONDITION", + "condition": { + "indicatorType": "RSI", + "rightField": "K_55", + "thresholdOverride": false + } + } + """); + + JsonNode cond = StrategyConditionThresholdNormalizer.normalizeTree(root).path("condition"); + + assertEquals("K_55", cond.path("rightField").asText()); + assertTrue(cond.path("thresholdOverride").asBoolean(false)); + assertEquals(55.0, cond.path("targetValue").asDouble(), 0.0001); } } diff --git a/frontend/scripts/test-threshold-normalize.mjs b/frontend/scripts/test-threshold-normalize.mjs index 445a8ae..4c62b9d 100644 --- a/frontend/scripts/test-threshold-normalize.mjs +++ b/frontend/scripts/test-threshold-normalize.mjs @@ -1,94 +1,68 @@ /** 정규화 로직 회귀 검증 — node scripts/test-threshold-normalize.mjs */ -function isThresholdOverridden(cond) { - return cond.thresholdOverride === true; -} function parseThresholdField(field) { if (!field?.startsWith('K_')) return null; const n = parseFloat(field.slice(2)); return Number.isFinite(n) ? n : null; } -const DEFAULT = { RSI: { over: 70, mid: 50, under: 30 } }; -function inferThresholdRoleForValue(value, indicatorType) { - const key = indicatorType?.toUpperCase?.() ?? indicatorType; - const th = DEFAULT[key] ?? {}; - const eps = 0.0001; - if (th.over != null && Math.abs(th.over - value) < eps) return 'HL_OVER'; - if (th.mid != null && Math.abs(th.mid - value) < eps) return 'HL_MID'; - if (th.under != null && Math.abs(th.under - value) < eps) return 'HL_UNDER'; - return null; -} -function inferFromK(field, indicatorType) { - if (!field?.startsWith('K_')) return null; - const val = parseFloat(field.slice(2)); - return inferThresholdRoleForValue(val, indicatorType); -} -function canonicalizeCondition(cond) { - let next = { ...cond, indicatorType: cond.indicatorType?.toUpperCase?.() ?? cond.indicatorType }; - const targetNum = typeof next.targetValue === 'number' && Number.isFinite(next.targetValue) - ? next.targetValue - : null; - const fromField = parseThresholdField(next.rightField); - const val = targetNum ?? fromField; - if (val != null && Number.isFinite(val)) { - const role = inferThresholdRoleForValue(val, next.indicatorType); - if (role) { - const { targetValue, ...rest } = next; - return { ...rest, rightField: role, thresholdOverride: false }; + +function ensureDirectThresholdSnapshot(cond) { + const indicatorType = cond.indicatorType?.toUpperCase?.() ?? cond.indicatorType; + let next = { ...cond, indicatorType }; + for (const key of ['rightField', 'leftField']) { + const f = next[key]; + if (!f?.startsWith('K_')) continue; + const parsed = parseThresholdField(f); + if (parsed == null) continue; + if (next.targetValue == null || !Number.isFinite(next.targetValue)) { + next = { ...next, targetValue: parsed, thresholdOverride: true }; + } else if (next.thresholdOverride !== true) { + next = { ...next, thresholdOverride: true }; } } - if (next.rightField?.startsWith('K_')) { - const role = inferFromK(next.rightField, next.indicatorType); - if (role) { - const { targetValue, ...rest } = next; - return { ...rest, rightField: role, thresholdOverride: false }; - } - } - return next; -} -function isConditionCarrier(node) { - return !!node.condition && (node.type === 'CONDITION' || node.type == null); -} -function normalizeNode(node) { - let next = { ...node }; - if (isConditionCarrier(node)) { - next = { ...node, type: node.type ?? 'CONDITION', condition: canonicalizeCondition(node.condition) }; - } - if (next.children?.length) { - next.children = next.children.map(normalizeNode); - } return next; } -/** 차트 hline mid=48 이어도 직접입력 50 → HL_MID (Ta4j 기본값 기준) */ -function setConditionThreshold(cond, value) { - const role = inferThresholdRoleForValue(value, cond.indicatorType); - if (role) { - const { targetValue, ...rest } = cond; - return { ...rest, rightField: role, thresholdOverride: false }; - } - return { ...cond, rightField: `K_${value}`, targetValue: value, thresholdOverride: true }; +function setDirectThreshold(cond, side, value) { + const fieldKey = side === 'left' ? 'leftField' : 'rightField'; + return { + ...cond, + [fieldKey]: `K_${value}`, + targetValue: value, + thresholdOverride: true, + }; +} + +function setConditionThreshold(cond, value, directInput) { + if (directInput) return setDirectThreshold(cond, 'right', value); + return setDirectThreshold(cond, 'right', value); } let failed = 0; -const cases = [ - { type: 'CONDITION', condition: { indicatorType: 'RSI', rightField: 'K_50', thresholdOverride: true, targetValue: 50 } }, - { condition: { indicatorType: 'RSI', rightField: 'K_50', thresholdOverride: true, targetValue: 50 } }, - { type: 'TIMEFRAME', children: [{ condition: { indicatorType: 'RSI', rightField: 'K_50', thresholdOverride: true, targetValue: 50 } }] }, -]; -for (const c of cases) { - const r = normalizeNode(c); - const cond = r.condition ?? r.children?.[0]?.condition; - const ok = cond?.rightField === 'HL_MID' && cond?.thresholdOverride === false; +function assertOk(label, ok) { if (!ok) failed++; - console.log(ok ? 'PASS' : 'FAIL', JSON.stringify(c).slice(0, 60), '=>', cond?.rightField, cond?.thresholdOverride); + console.log(ok ? 'PASS' : 'FAIL', label); } -const direct = setConditionThreshold( - { indicatorType: 'RSI', rightField: 'HL_OVER', thresholdOverride: false }, - 50, -); -const directOk = direct.rightField === 'HL_MID' && direct.thresholdOverride === false; -if (!directOk) failed++; -console.log(directOk ? 'PASS' : 'FAIL', 'setConditionThreshold(50) with chart mid≠50 =>', direct.rightField); +const k55 = ensureDirectThresholdSnapshot({ + indicatorType: 'RSI', + rightField: 'K_55', + thresholdOverride: true, + targetValue: 55, +}); +assertOk('K_55 preserved', k55.rightField === 'K_55' && k55.targetValue === 55 && k55.thresholdOverride === true); + +const k50 = ensureDirectThresholdSnapshot({ + indicatorType: 'RSI', + rightField: 'K_50', + thresholdOverride: true, + targetValue: 50, +}); +assertOk('K_50 preserved (not HL_MID)', k50.rightField === 'K_50' && k50.thresholdOverride === true); + +const direct55 = setConditionThreshold({ indicatorType: 'RSI', rightField: 'HL_MID', thresholdOverride: false }, 55, true); +assertOk('directInput 55 => K_55', direct55.rightField === 'K_55' && direct55.targetValue === 55); + +const filled = ensureDirectThresholdSnapshot({ indicatorType: 'RSI', rightField: 'K_55', thresholdOverride: false }); +assertOk('K_55 fills targetValue', filled.targetValue === 55 && filled.thresholdOverride === true); process.exit(failed > 0 ? 1 : 0); diff --git a/frontend/src/components/strategyEditor/ConditionNodeSettings.tsx b/frontend/src/components/strategyEditor/ConditionNodeSettings.tsx index 3c1cb2f..da46a38 100644 --- a/frontend/src/components/strategyEditor/ConditionNodeSettings.tsx +++ b/frontend/src/components/strategyEditor/ConditionNodeSettings.tsx @@ -20,7 +20,7 @@ import { setConditionRightPeriod, setConditionThreshold, setConditionValuePeriod, - canonicalizeConditionThreshold, + ensureDirectThresholdSnapshot, usesValuePeriodField, } from '../../utils/conditionPeriods'; import { compositeDisplayName, compositeElementLabel, COMPOSITE_INDICATOR_ITEMS, switchCompositeIndicatorType } from '../../utils/compositeIndicators'; @@ -170,7 +170,7 @@ export default function ConditionNodeSettings({ max={thresholdBounds.max} allowDecimal onChange={v => onChange( - canonicalizeConditionThreshold( + ensureDirectThresholdSnapshot( setConditionThreshold(condition, 'right', v, def, { directInput: true }), ), )} diff --git a/frontend/src/utils/conditionPeriods.ts b/frontend/src/utils/conditionPeriods.ts index b416e2c..0dbc826 100644 --- a/frontend/src/utils/conditionPeriods.ts +++ b/frontend/src/utils/conditionPeriods.ts @@ -205,38 +205,47 @@ export function thresholdField(value: number): string { return `K_${value}`; } +/** 직접입력 — K_ 숫자·targetValue·thresholdOverride 스냅샷 */ +export function setDirectThreshold( + cond: ConditionDSL, + side: 'left' | 'right', + value: number, +): ConditionDSL { + const fieldKey = side === 'left' ? 'leftField' : 'rightField'; + return { + ...cond, + [fieldKey]: thresholdField(value), + targetValue: value, + thresholdOverride: true, + }; +} + /** - * 표준 hline 값(30/50/70 등)이면 K_*·targetValue 를 HL_* 로 통일. - * 차트 커스텀 hline(def.hlThresh)과 무관 — Ta4j 기본 역할값 기준. + * K_* 직접입력 스냅샷 보강 — targetValue 누락 시 필드명에서 복원. + * HL_* 로 치환하지 않음 (직접입력 55는 평가 시 55 그대로). */ -export function canonicalizeConditionThreshold(cond: ConditionDSL): ConditionDSL { +export function ensureDirectThresholdSnapshot(cond: ConditionDSL): ConditionDSL { const indicatorType = cond.indicatorType?.toUpperCase?.() ?? cond.indicatorType; let next: ConditionDSL = { ...cond, indicatorType }; - const rawTarget = next.targetValue; - const targetNum = typeof rawTarget === 'number' && Number.isFinite(rawTarget) ? rawTarget : null; - const fromField = parseThresholdField(next.rightField); - const val = Number.isFinite(targetNum) ? targetNum : fromField; - - if (val != null && Number.isFinite(val)) { - const role = inferThresholdRoleForValue(val, indicatorType, {}); - if (role != null) { - const { targetValue: _t, ...rest } = next; - return { ...rest, rightField: role, thresholdOverride: false }; - } - } - - if (next.rightField?.startsWith('K_')) { - const role = inferThresholdSymbolFromLegacyExact(next.rightField, indicatorType, {}); - if (role != null) { - const { targetValue: _t, ...rest } = next; - return { ...rest, rightField: role, thresholdOverride: false }; + for (const key of ['rightField', 'leftField'] as const) { + const f = next[key]; + if (!f?.startsWith('K_')) continue; + const parsed = parseThresholdField(f); + if (parsed == null) continue; + if (next.targetValue == null || !Number.isFinite(next.targetValue)) { + next = { ...next, targetValue: parsed, thresholdOverride: true }; + } else if (!isThresholdOverridden(next)) { + next = { ...next, thresholdOverride: true }; } } return next; } +/** @deprecated ensureDirectThresholdSnapshot 사용 — HL_* 치환 없음 */ +export const canonicalizeConditionThreshold = ensureDirectThresholdSnapshot; + export function hasEditableThreshold(cond: ConditionDSL): boolean { return isThresholdFieldOrSymbol(cond.rightField) || isThresholdFieldOrSymbol(cond.leftField) @@ -313,9 +322,7 @@ export function activateDirectThresholdInput( ?? getChartReferenceThreshold(cond, side, inheritedField, def) ?? parseThresholdField(side === 'right' ? cond.rightField : cond.leftField); if (value == null || !Number.isFinite(value)) return cond; - return canonicalizeConditionThreshold( - setConditionThreshold(cond, side, value, def), - ); + return setDirectThreshold(cond, side, value); } export function setConditionThreshold( @@ -326,20 +333,16 @@ export function setConditionThreshold( options?: SetConditionThresholdOptions, ): ConditionDSL { const fieldKey = side === 'left' ? 'leftField' : 'rightField'; + + // 직접입력 — K_ 스냅샷 유지 (55 등 비표준값도 평가에 그대로 반영) + if (options?.directInput) { + return setDirectThreshold(cond, side, value); + } + const current = cond[fieldKey]; if (!isThresholdFieldOrSymbol(current)) { if (side === 'right' && INDICATORS_WITH_RIGHT_THRESHOLD_INPUT.has(cond.indicatorType)) { - const matchedRole = inferThresholdRoleForValue(value, cond.indicatorType, {}); - if (matchedRole != null) { - const { targetValue: _t, ...rest } = cond; - return { ...rest, [fieldKey]: matchedRole, thresholdOverride: false }; - } - return { - ...cond, - [fieldKey]: thresholdField(value), - targetValue: value, - thresholdOverride: true, - }; + return setDirectThreshold(cond, side, value); } return cond; } @@ -351,33 +354,13 @@ export function setConditionThreshold( ? getChartReferenceThreshold(cond, side, inheritedField, def) : null; - // 30/50/70 등 표준 hline — 차트 커스텀값과 무관하게 HL_* 로 통일 (직접입력·프리셋·평가 일치) - const matchedRole = inferThresholdRoleForValue(value, cond.indicatorType, {}); - if (matchedRole != null) { + // 드롭다운 hline 프리셋(중앙선 등)과 차트 기준값이 일치할 때만 상속 + if (chartRef != null && Math.abs(chartRef - value) < 0.0001 && isThresholdSymbol(inheritedField)) { const { targetValue: _t, ...rest } = cond; - return { ...rest, [fieldKey]: matchedRole, thresholdOverride: false }; + return { ...rest, [fieldKey]: inheritedField, thresholdOverride: false }; } - if (!options?.directInput && 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 }; - } - - const fallbackRole = inferThresholdRoleForValue(value, cond.indicatorType, {}); - if (fallbackRole != null) { - const { targetValue: _t, ...rest } = cond; - return { ...rest, [fieldKey]: fallbackRole, thresholdOverride: false }; - } - - return { - ...cond, - [fieldKey]: thresholdField(value), - targetValue: value, - thresholdOverride: true, - }; + return setDirectThreshold(cond, side, value); } export function setConditionValuePeriod( diff --git a/frontend/src/utils/strategyConditionNormalize.ts b/frontend/src/utils/strategyConditionNormalize.ts index 0eb2bc7..6c18b53 100644 --- a/frontend/src/utils/strategyConditionNormalize.ts +++ b/frontend/src/utils/strategyConditionNormalize.ts @@ -3,35 +3,21 @@ import type { ConditionDSL, LogicNode } from './strategyTypes'; import type { DefType } from './strategyEditorShared'; import { getDefaultConditionFields } from './strategyEditorShared'; import { - canonicalizeConditionThreshold, + ensureDirectThresholdSnapshot, isThresholdOverridden, isValuePeriodOverridden, - parseThresholdField, usesValuePeriodField, VALUE_FIELD_PREFIX, } from './conditionPeriods'; import { defaultInheritedThresholdField, - inferThresholdRoleForValue, inferThresholdSymbolFromLegacy, inferThresholdSymbolFromLegacyExact, isThresholdFieldOrSymbol, isThresholdSymbol, - THRESHOLD_HL_MID, - THRESHOLD_HL_OVER, } from './thresholdSymbols'; import { repairPriceExtremePairForest } from './priceExtremeBreakoutPair'; -function coerceTargetValue(raw: unknown): number | null { - if (raw == null) return null; - if (typeof raw === 'number') return Number.isFinite(raw) ? raw : null; - if (typeof raw === 'string') { - const n = parseFloat(raw.trim()); - return Number.isFinite(n) ? n : null; - } - return null; -} - function isConditionCarrier(node: LogicNode): node is LogicNode & { condition: ConditionDSL } { return !!node.condition && (node.type === 'CONDITION' || node.type == null || node.type === undefined); } @@ -41,22 +27,8 @@ function migrateConditionThreshold( def: DefType, signalType: 'buy' | 'sell', ): ConditionDSL { + // 직접입력(K_55 등) — 숫자 스냅샷 유지, HL_* 로 접지 않음 if (isThresholdOverridden(cond)) { - const val = coerceTargetValue(cond.targetValue) ?? parseThresholdField(cond.rightField); - if (val != null) { - const role = inferThresholdRoleForValue(val, cond.indicatorType, {}); - if (role != null) { - const { targetValue: _t, ...rest } = cond; - return migrateConditionPeriod({ ...rest, rightField: role, thresholdOverride: false }); - } - } - if (cond.rightField?.startsWith('K_')) { - const role = inferThresholdSymbolFromLegacyExact(cond.rightField, cond.indicatorType, {}); - if (role != null) { - const { targetValue: _t, ...rest } = cond; - return migrateConditionPeriod({ ...rest, rightField: role, thresholdOverride: false }); - } - } return cond; } @@ -138,9 +110,9 @@ export function migrateLogicRootForEditor( return repairPriceExtremePairForest(migrated.root, migrated.orphans); } -/** 저장·표시·평가 공통 — K_50 등을 HL_MID 로 접기 */ +/** 저장·표시·평가 공통 — K_* 직접입력 스냅샷 보강 (HL_* 치환 없음) */ export function normalizeConditionForPersistence(cond: ConditionDSL): ConditionDSL { - let next = canonicalizeConditionThreshold(cond); + let next = ensureDirectThresholdSnapshot(cond); if (!isValuePeriodOverridden(next)) { const prefix = VALUE_FIELD_PREFIX[next.indicatorType]; diff --git a/frontend/src/utils/strategyDescriptionNarrative.ts b/frontend/src/utils/strategyDescriptionNarrative.ts index a3024bd..5a5b99a 100644 --- a/frontend/src/utils/strategyDescriptionNarrative.ts +++ b/frontend/src/utils/strategyDescriptionNarrative.ts @@ -23,7 +23,7 @@ import { getCompositeFieldOpts, getCompositeLeftCandleType, getCompositeRightCandleType, - canonicalizeConditionThreshold, + ensureDirectThresholdSnapshot, parseThresholdField, } from './conditionPeriods'; import { formatStrategyCandleLabel } from './strategyStartNodes'; @@ -154,7 +154,7 @@ function describeCondition( signalType: 'buy' | 'sell', def: DefType, ): string { - const cond = canonicalizeConditionThreshold(raw); + const cond = ensureDirectThresholdSnapshot(raw); const ind = cond.indicatorType; const ct = cond.conditionType; diff --git a/frontend/src/utils/strategyEditorShared.tsx b/frontend/src/utils/strategyEditorShared.tsx index c713739..2be3045 100644 --- a/frontend/src/utils/strategyEditorShared.tsx +++ b/frontend/src/utils/strategyEditorShared.tsx @@ -74,7 +74,7 @@ import { import ComboFieldSelect from '../components/strategyEditor/ComboFieldSelect'; import { activateDirectThresholdInput, - canonicalizeConditionThreshold, + ensureDirectThresholdSnapshot, getCompositeFieldOpts, getCompositePeriodPresetOptions, getConditionRightPeriod, @@ -924,7 +924,7 @@ export function resolveFieldOptionValue(indicatorType: string, field?: string): export const nodeToText = (node: LogicNode, DEF: DefType = DEF_DEFAULTS): string => { if (!node) return ''; if (node.type === 'CONDITION' && node.condition) { - const c = canonicalizeConditionThreshold(normalizeCompositeCondition(node.condition)); + const c = ensureDirectThresholdSnapshot(normalizeCompositeCondition(node.condition)); const indName = getStrategyIndicatorDisplayName(c.indicatorType); const opts = getFieldOpts(c.indicatorType, 'buy', DEF, c); const C = condLabel(c.conditionType); @@ -1155,7 +1155,7 @@ export const CondEditor: React.FC = ({ const handleRight = (v: string) => { if (isThresholdSymbol(v)) { const { targetValue: _t, ...rest } = normalized; - onChange(canonicalizeConditionThreshold({ ...rest, rightField: v, thresholdOverride: false })); + onChange(ensureDirectThresholdSnapshot({ ...rest, rightField: v, thresholdOverride: false })); return; } const thresholds: Record = { @@ -1177,7 +1177,7 @@ export const CondEditor: React.FC = ({ if (priorP != null && isPriceExtremeIndicator(normalized.indicatorType)) { upd = syncPriceExtremeSimpleFields({ ...upd, period: priorP }); } - onChange(canonicalizeConditionThreshold(upd)); + onChange(ensureDirectThresholdSnapshot(upd)); }; if (normalized.composite) { @@ -1443,7 +1443,7 @@ export const CondEditor: React.FC = ({ )); }} onCustomNumberChange={v => onChange( - canonicalizeConditionThreshold( + ensureDirectThresholdSnapshot( setConditionThreshold(normalized, 'right', v, def, { directInput: true }), ), )}