From d4b5814bbc907c1853bb3c8ab13f11eb5193c5cc Mon Sep 17 00:00:00 2001 From: Macbook Date: Wed, 17 Jun 2026 23:45:39 +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?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/LiveConditionStatusService.java | 35 +++-- .../StrategyConditionThresholdNormalizer.java | 129 ++++++++++++++++++ .../goldenchart/service/StrategyService.java | 8 +- ...ategyConditionThresholdNormalizerTest.java | 39 ++++++ frontend/src/utils/conditionPeriods.ts | 9 +- .../src/utils/strategyConditionNormalize.ts | 19 ++- frontend/src/utils/strategyHydrate.ts | 5 +- 7 files changed, 220 insertions(+), 24 deletions(-) create mode 100644 backend/src/main/java/com/goldenchart/service/StrategyConditionThresholdNormalizer.java create mode 100644 backend/src/test/java/com/goldenchart/service/StrategyConditionThresholdNormalizerTest.java diff --git a/backend/src/main/java/com/goldenchart/service/LiveConditionStatusService.java b/backend/src/main/java/com/goldenchart/service/LiveConditionStatusService.java index 2db82ea..aacdc2b 100644 --- a/backend/src/main/java/com/goldenchart/service/LiveConditionStatusService.java +++ b/backend/src/main/java/com/goldenchart/service/LiveConditionStatusService.java @@ -222,14 +222,8 @@ public class LiveConditionStatusService { Map> params, Map> visual) { try { - JsonNode buyDslRaw = timeframeNormalizer.normalize( - objectMapper.readTree( - strategy.getBuyConditionJson() != null ? strategy.getBuyConditionJson() : "null"), - strategy.getName()); - JsonNode sellDslRaw = timeframeNormalizer.normalize( - objectMapper.readTree( - strategy.getSellConditionJson() != null ? strategy.getSellConditionJson() : "null"), - strategy.getName()); + JsonNode buyDslRaw = loadStrategyDsl(strategy.getBuyConditionJson(), strategy.getName()); + JsonNode sellDslRaw = loadStrategyDsl(strategy.getSellConditionJson(), strategy.getName()); String primaryTf = OhlcvBarSeriesSupport.normalizeTf(chartTimeframe); JsonNode buyDsl = timeframeNormalizer.remapForChartTimeframe(buyDslRaw, primaryTf); @@ -396,8 +390,9 @@ public class LiveConditionStatusService { Long barTimeSec) { if (conditionJson == null || conditionJson.isBlank()) return null; try { + String normalized = StrategyConditionThresholdNormalizer.normalizeJson(conditionJson, objectMapper); com.fasterxml.jackson.databind.JsonNode dsl = - objectMapper.readTree(conditionJson); + objectMapper.readTree(normalized); if (dsl == null || dsl.isNull()) return null; // DSL 루트의 주 시간봉 추출 (TIMEFRAME 노드가 있으면 해당 봉 사용) @@ -458,12 +453,22 @@ public class LiveConditionStatusService { List out) { if (json == null || json.isBlank()) return; try { - walk(objectMapper.readTree(json), timeframe, side, out, new HashSet<>()); + String normalized = StrategyConditionThresholdNormalizer.normalizeJson(json, objectMapper); + walk(objectMapper.readTree(normalized), timeframe, side, out, new HashSet<>()); } catch (Exception e) { log.warn("[LiveCondition] JSON walk fail: {}", e.getMessage()); } } + private JsonNode loadStrategyDsl(String json, String strategyName) throws Exception { + JsonNode root = (json == null || json.isBlank()) + ? objectMapper.readTree("null") + : objectMapper.readTree(json); + return timeframeNormalizer.normalize( + StrategyConditionThresholdNormalizer.normalizeTree(root), + strategyName); + } + private void walk(JsonNode node, String timeframe, String side, List out, Set seen) { if (node == null || node.isNull()) return; @@ -718,14 +723,8 @@ public class LiveConditionStatusService { try { String strategyName = strategy.getName(); - JsonNode buyDslRaw = timeframeNormalizer.normalize( - objectMapper.readTree( - strategy.getBuyConditionJson() != null ? strategy.getBuyConditionJson() : "null"), - strategyName); - JsonNode sellDslRaw = timeframeNormalizer.normalize( - objectMapper.readTree( - strategy.getSellConditionJson() != null ? strategy.getSellConditionJson() : "null"), - strategyName); + JsonNode buyDslRaw = loadStrategyDsl(strategy.getBuyConditionJson(), strategyName); + JsonNode sellDslRaw = loadStrategyDsl(strategy.getSellConditionJson(), strategyName); String primaryTf = OhlcvBarSeriesSupport.normalizeTf(chartTimeframe); JsonNode buyDsl = timeframeNormalizer.remapForChartTimeframe(buyDslRaw, primaryTf); diff --git a/backend/src/main/java/com/goldenchart/service/StrategyConditionThresholdNormalizer.java b/backend/src/main/java/com/goldenchart/service/StrategyConditionThresholdNormalizer.java new file mode 100644 index 0000000..a25cc4e --- /dev/null +++ b/backend/src/main/java/com/goldenchart/service/StrategyConditionThresholdNormalizer.java @@ -0,0 +1,129 @@ +package com.goldenchart.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; + +import java.util.Map; + +/** + * 전략 조건 DSL — K_50 등 직접입력 스냅샷을 hline 역할(HL_MID 등)로 정규화. + * 프론트 normalizeConditionForPersistence 와 동일 의도. + */ +public final class StrategyConditionThresholdNormalizer { + + private StrategyConditionThresholdNormalizer() {} + + public static JsonNode normalizeTree(JsonNode root) { + if (root == null || root.isNull() || !root.isObject()) return root; + ObjectNode copy = root.deepCopy(); + walk(copy); + return copy; + } + + private static void walk(JsonNode node) { + if (node == null || !node.isObject()) return; + + JsonNode cond = node.get("condition"); + if (cond != null && cond.isObject()) { + normalizeCondition((ObjectNode) cond); + } + + JsonNode children = node.get("children"); + if (children != null && children.isArray()) { + for (JsonNode c : children) walk(c); + } + JsonNode child = node.get("child"); + if (child != null) walk(child); + } + + private static void normalizeCondition(ObjectNode cond) { + normalizeThresholdField(cond, "rightField"); + normalizeThresholdField(cond, "leftField"); + } + + private static void normalizeThresholdField(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(cond.path("indicatorType").asText(""), value, field); + if (role != null) { + cond.put(fieldKey, role); + cond.remove("targetValue"); + cond.put("thresholdOverride", false); + return; + } + } + + if (!override && field.startsWith("K_")) { + String role = inferRoleForValue(cond.path("indicatorType").asText(""), 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 문자열 보정 — 파싱 실패 시 원본 반환 */ + public static String normalizeJson(String json, com.fasterxml.jackson.databind.ObjectMapper mapper) { + if (json == null || json.isBlank()) return json; + try { + JsonNode root = mapper.readTree(json); + JsonNode normalized = normalizeTree(root); + return mapper.writeValueAsString(normalized); + } catch (Exception ignored) { + return json; + } + } +} diff --git a/backend/src/main/java/com/goldenchart/service/StrategyService.java b/backend/src/main/java/com/goldenchart/service/StrategyService.java index e75b5b5..5b39635 100644 --- a/backend/src/main/java/com/goldenchart/service/StrategyService.java +++ b/backend/src/main/java/com/goldenchart/service/StrategyService.java @@ -66,11 +66,15 @@ public class StrategyService { } entity.setBuyConditionJson( buyJson != null - ? dslTimeframeNormalizer.normalizeJson(buyJson, strategyName) + ? dslTimeframeNormalizer.normalizeJson( + StrategyConditionThresholdNormalizer.normalizeJson(buyJson, objectMapper), + strategyName) : null); entity.setSellConditionJson( sellJson != null - ? dslTimeframeNormalizer.normalizeJson(sellJson, strategyName) + ? dslTimeframeNormalizer.normalizeJson( + StrategyConditionThresholdNormalizer.normalizeJson(sellJson, objectMapper), + strategyName) : null); if (dto.getFlowLayout() != null && !dto.getFlowLayout().isNull()) { entity.setFlowLayoutJson(objectMapper.writeValueAsString(dto.getFlowLayout())); diff --git a/backend/src/test/java/com/goldenchart/service/StrategyConditionThresholdNormalizerTest.java b/backend/src/test/java/com/goldenchart/service/StrategyConditionThresholdNormalizerTest.java new file mode 100644 index 0000000..13ed5f1 --- /dev/null +++ b/backend/src/test/java/com/goldenchart/service/StrategyConditionThresholdNormalizerTest.java @@ -0,0 +1,39 @@ +package com.goldenchart.service; + +import com.fasterxml.jackson.databind.JsonNode; +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; + +class StrategyConditionThresholdNormalizerTest { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + @Test + void normalizesK50OverrideToHlMid() throws Exception { + JsonNode root = MAPPER.readTree(""" + { + "type": "CONDITION", + "condition": { + "indicatorType": "RSI", + "conditionType": "CROSS_UP", + "leftField": "RSI_VALUE_9", + "rightField": "K_50", + "period": 9, + "valuePeriodOverride": true, + "thresholdOverride": true, + "targetValue": 50 + } + } + """); + + JsonNode normalized = StrategyConditionThresholdNormalizer.normalizeTree(root); + JsonNode cond = normalized.path("condition"); + + assertEquals("HL_MID", cond.path("rightField").asText()); + assertFalse(cond.path("thresholdOverride").asBoolean(true)); + assertFalse(cond.has("targetValue")); + } +} diff --git a/frontend/src/utils/conditionPeriods.ts b/frontend/src/utils/conditionPeriods.ts index 7884b53..1a7c416 100644 --- a/frontend/src/utils/conditionPeriods.ts +++ b/frontend/src/utils/conditionPeriods.ts @@ -266,7 +266,7 @@ export function getConditionThreshold( } export type SetConditionThresholdOptions = { - /** true: 직접입력 — K_+targetValue+thresholdOverride 유지 (프리셋 HL_* 로 접지 않음) */ + /** true: 직접입력 모드 — hline 역할과 일치하지 않는 값만 K_ 스냅샷 유지 */ directInput?: boolean; }; @@ -294,6 +294,13 @@ export function setConditionThreshold( const current = cond[fieldKey]; if (!isThresholdFieldOrSymbol(current)) { if (side === 'right' && INDICATORS_WITH_RIGHT_THRESHOLD_INPUT.has(cond.indicatorType)) { + const matchedRole = def + ? inferThresholdRoleForValue(value, cond.indicatorType, def.hlThresh) + : null; + if (matchedRole != null) { + const { targetValue: _t, ...rest } = cond; + return { ...rest, [fieldKey]: matchedRole, thresholdOverride: false }; + } return { ...cond, [fieldKey]: thresholdField(value), diff --git a/frontend/src/utils/strategyConditionNormalize.ts b/frontend/src/utils/strategyConditionNormalize.ts index 146514d..9dac30e 100644 --- a/frontend/src/utils/strategyConditionNormalize.ts +++ b/frontend/src/utils/strategyConditionNormalize.ts @@ -26,7 +26,24 @@ function migrateConditionThreshold( def: DefType, signalType: 'buy' | 'sell', ): ConditionDSL { - if (isThresholdOverridden(cond)) return cond; + if (isThresholdOverridden(cond)) { + const val = cond.targetValue ?? parseThresholdField(cond.rightField); + if (val != null && Number.isFinite(val)) { + const role = inferThresholdRoleForValue(val, cond.indicatorType, def.hlThresh); + 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, def.hlThresh); + if (role != null) { + const { targetValue: _t, ...rest } = cond; + return migrateConditionPeriod({ ...rest, rightField: role, thresholdOverride: false }); + } + } + return cond; + } let rightField = cond.rightField; if (rightField?.startsWith('K_')) { diff --git a/frontend/src/utils/strategyHydrate.ts b/frontend/src/utils/strategyHydrate.ts index e337405..0117d32 100644 --- a/frontend/src/utils/strategyHydrate.ts +++ b/frontend/src/utils/strategyHydrate.ts @@ -5,6 +5,7 @@ import type { LogicNode } from './strategyTypes'; import type { StrategyDto } from './backendApi'; import { extractVirtualConditions } from './virtualStrategyConditions'; import { decodeConditionForEditor, encodeConditionForSave } from './strategyConditionSerde'; +import { normalizeLogicRootForPersistence } from './strategyConditionNormalize'; import type { StrategyFlowLayoutStore } from './strategyEditorLayoutStorage'; export function asLogicNode(raw: unknown): LogicNode | null { @@ -34,8 +35,8 @@ export function hydrateStrategyDto(strategy: StrategyDto): StrategyDto { const sell = asLogicNode(strategy.sellCondition); let next: StrategyDto = { ...strategy, - buyCondition: buy ?? undefined, - sellCondition: sell ?? undefined, + buyCondition: normalizeLogicRootForPersistence(buy) ?? buy ?? undefined, + sellCondition: normalizeLogicRootForPersistence(sell) ?? sell ?? undefined, }; if (extractVirtualConditions(next).length > 0) return next;