diff --git a/backend/src/main/java/com/goldenchart/service/StrategyConditionThresholdNormalizer.java b/backend/src/main/java/com/goldenchart/service/StrategyConditionThresholdNormalizer.java index a25cc4e..4754ad9 100644 --- a/backend/src/main/java/com/goldenchart/service/StrategyConditionThresholdNormalizer.java +++ b/backend/src/main/java/com/goldenchart/service/StrategyConditionThresholdNormalizer.java @@ -4,6 +4,7 @@ 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 등)로 정규화. @@ -37,11 +38,15 @@ public final class StrategyConditionThresholdNormalizer { } private static void normalizeCondition(ObjectNode cond) { - normalizeThresholdField(cond, "rightField"); - normalizeThresholdField(cond, "leftField"); + String indType = cond.path("indicatorType").asText("").toUpperCase(Locale.ROOT); + if (!indType.isBlank()) { + cond.put("indicatorType", indType); + } + normalizeThresholdField(cond, "rightField", indType); + normalizeThresholdField(cond, "leftField", indType); } - private static void normalizeThresholdField(ObjectNode cond, String fieldKey) { + private static void normalizeThresholdField(ObjectNode cond, String fieldKey, String indType) { String field = cond.path(fieldKey).asText(""); if (field.isBlank()) return; @@ -49,7 +54,7 @@ public final class StrategyConditionThresholdNormalizer { Double value = readThresholdValue(cond, field); if (override || field.startsWith("K_")) { - String role = inferRoleForValue(cond.path("indicatorType").asText(""), value, field); + String role = inferRoleForValue(indType, value, field); if (role != null) { cond.put(fieldKey, role); cond.remove("targetValue"); @@ -59,7 +64,7 @@ public final class StrategyConditionThresholdNormalizer { } if (!override && field.startsWith("K_")) { - String role = inferRoleForValue(cond.path("indicatorType").asText(""), value, field); + String role = inferRoleForValue(indType, value, field); if (role != null) { cond.put(fieldKey, role); cond.remove("targetValue"); diff --git a/backend/src/main/java/com/goldenchart/service/StrategyService.java b/backend/src/main/java/com/goldenchart/service/StrategyService.java index 5b39635..9b26382 100644 --- a/backend/src/main/java/com/goldenchart/service/StrategyService.java +++ b/backend/src/main/java/com/goldenchart/service/StrategyService.java @@ -152,10 +152,14 @@ public class StrategyService { dto.setCreatedAt(e.getCreatedAt()); dto.setUpdatedAt(e.getUpdatedAt()); try { - if (e.getBuyConditionJson() != null) - dto.setBuyCondition(objectMapper.readTree(e.getBuyConditionJson())); - if (e.getSellConditionJson() != null) - dto.setSellCondition(objectMapper.readTree(e.getSellConditionJson())); + if (e.getBuyConditionJson() != null) { + JsonNode buy = objectMapper.readTree(e.getBuyConditionJson()); + dto.setBuyCondition(StrategyConditionThresholdNormalizer.normalizeTree(buy)); + } + if (e.getSellConditionJson() != null) { + JsonNode sell = objectMapper.readTree(e.getSellConditionJson()); + dto.setSellCondition(StrategyConditionThresholdNormalizer.normalizeTree(sell)); + } if (e.getFlowLayoutJson() != null && !e.getFlowLayoutJson().isBlank()) dto.setFlowLayout(objectMapper.readTree(e.getFlowLayoutJson())); } catch (Exception ex) { diff --git a/frontend/scripts/test-threshold-normalize.mjs b/frontend/scripts/test-threshold-normalize.mjs new file mode 100644 index 0000000..69847f8 --- /dev/null +++ b/frontend/scripts/test-threshold-normalize.mjs @@ -0,0 +1,82 @@ +/** 정규화 로직 회귀 검증 — 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 coerceTargetValue(raw) { + 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 normalizeCondition(cond) { + let next = { ...cond, indicatorType: cond.indicatorType?.toUpperCase?.() ?? cond.indicatorType }; + const targetNum = coerceTargetValue(next.targetValue); + if (isThresholdOverridden(next)) { + if (targetNum != null) { + const role = inferThresholdRoleForValue(targetNum, next.indicatorType); + if (role) { + const { targetValue, ...rest } = next; + next = { ...rest, rightField: role, thresholdOverride: false }; + } + } else if (next.rightField?.startsWith('K_')) { + const role = inferFromK(next.rightField, next.indicatorType); + if (role) { + const { targetValue, ...rest } = next; + next = { ...rest, rightField: role, thresholdOverride: false }; + } + } + } + if (!isThresholdOverridden(next) && next.rightField?.startsWith('K_')) { + const role = inferFromK(next.rightField, next.indicatorType); + if (role) next = { ...next, 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: normalizeCondition(node.condition) }; + } + if (next.children?.length) { + next.children = next.children.map(normalizeNode); + } + return next; +} + +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; + console.log(ok ? 'PASS' : 'FAIL', JSON.stringify(c).slice(0, 60), '=>', cond?.rightField, cond?.thresholdOverride); +} diff --git a/frontend/src/components/strategyEvaluation/StrategyEvaluationConditionPanel.tsx b/frontend/src/components/strategyEvaluation/StrategyEvaluationConditionPanel.tsx index 4846be5..27066c9 100644 --- a/frontend/src/components/strategyEvaluation/StrategyEvaluationConditionPanel.tsx +++ b/frontend/src/components/strategyEvaluation/StrategyEvaluationConditionPanel.tsx @@ -5,6 +5,7 @@ import React, { useCallback, useMemo } from 'react'; import type { StrategyDto } from '../../utils/backendApi'; import { asLogicNode } from '../../utils/strategyHydrate'; import { decodeConditionForEditor } from '../../utils/strategyConditionSerde'; +import { normalizeLogicRootForPersistence } from '../../utils/strategyConditionNormalize'; import { buildDefFromGetParams } from '../../utils/strategyEditorShared'; import LogicExpressionPreview from '../strategyEditor/LogicExpressionPreview'; import LogicExpressionNarrative from '../strategyEditor/LogicExpressionNarrative'; @@ -34,11 +35,11 @@ const StrategyEvaluationConditionPanel: React.FC = ({ const def = useMemo(() => buildDefFromGetParams(resolveGetParams), [resolveGetParams]); const buyCondition = useMemo( - () => (strategy ? asLogicNode(strategy.buyCondition) : null), + () => normalizeLogicRootForPersistence(strategy ? asLogicNode(strategy.buyCondition) : null), [strategy?.buyCondition], ); const sellCondition = useMemo( - () => (strategy ? asLogicNode(strategy.sellCondition) : null), + () => normalizeLogicRootForPersistence(strategy ? asLogicNode(strategy.sellCondition) : null), [strategy?.sellCondition], ); diff --git a/frontend/src/utils/strategyConditionNormalize.ts b/frontend/src/utils/strategyConditionNormalize.ts index 9dac30e..0bd27ab 100644 --- a/frontend/src/utils/strategyConditionNormalize.ts +++ b/frontend/src/utils/strategyConditionNormalize.ts @@ -21,14 +21,28 @@ import { } 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); +} + function migrateConditionThreshold( cond: ConditionDSL, def: DefType, signalType: 'buy' | 'sell', ): ConditionDSL { if (isThresholdOverridden(cond)) { - const val = cond.targetValue ?? parseThresholdField(cond.rightField); - if (val != null && Number.isFinite(val)) { + const val = coerceTargetValue(cond.targetValue) ?? parseThresholdField(cond.rightField); + if (val != null) { const role = inferThresholdRoleForValue(val, cond.indicatorType, def.hlThresh); if (role != null) { const { targetValue: _t, ...rest } = cond; @@ -92,12 +106,20 @@ function migrateLogicNode( signalType: 'buy' | 'sell', ): LogicNode { let next = node; - if (node.type === 'CONDITION' && node.condition) { - next = { ...node, condition: migrateConditionForEditor(node.condition, def, signalType) }; + if (isConditionCarrier(node)) { + next = { + ...node, + type: node.type ?? 'CONDITION', + condition: migrateConditionForEditor(node.condition, def, signalType), + }; } if (next.children?.length) { next = { ...next, children: next.children.map(c => migrateLogicNode(c, def, signalType)) }; } + const legacyChild = (next as LogicNode & { child?: LogicNode }).child; + if (legacyChild) { + next = { ...next, child: migrateLogicNode(legacyChild, def, signalType) } as LogicNode; + } return next; } @@ -115,13 +137,17 @@ export function migrateLogicRootForEditor( return repairPriceExtremePairForest(migrated.root, migrated.orphans); } -/** 저장 직전 — 상속 모드는 숫자 스냅샷·targetValue 제거; 직접입력도 hline 역할과 일치하면 HL_* 로 통일 */ +/** 저장·표시·평가 공통 — K_50 등을 HL_MID 로 접기 */ export function normalizeConditionForPersistence(cond: ConditionDSL): ConditionDSL { - let next: ConditionDSL = { ...cond }; + let next: ConditionDSL = { + ...cond, + indicatorType: cond.indicatorType?.toUpperCase?.() ?? cond.indicatorType, + }; + const targetNum = coerceTargetValue(next.targetValue); if (isThresholdOverridden(next)) { - if (next.targetValue != null && Number.isFinite(next.targetValue)) { - const role = inferThresholdRoleForValue(next.targetValue, next.indicatorType, {}); + if (targetNum != null) { + const role = inferThresholdRoleForValue(targetNum, next.indicatorType, {}); if (role != null) { const { targetValue: _t, ...rest } = next; next = { ...rest, rightField: role, thresholdOverride: false }; @@ -168,13 +194,21 @@ export function normalizeConditionForPersistence(cond: ConditionDSL): ConditionD } function normalizeLogicNode(node: LogicNode): LogicNode { - let next = node; - if (node.type === 'CONDITION' && node.condition) { - next = { ...node, condition: normalizeConditionForPersistence(node.condition) }; + let next: LogicNode = node; + if (isConditionCarrier(node)) { + next = { + ...node, + type: node.type ?? 'CONDITION', + condition: normalizeConditionForPersistence(node.condition), + }; } if (next.children?.length) { next = { ...next, children: next.children.map(normalizeLogicNode) }; } + const legacyChild = (next as LogicNode & { child?: LogicNode }).child; + if (legacyChild) { + next = { ...next, child: normalizeLogicNode(legacyChild) } as LogicNode; + } return next; } diff --git a/frontend/src/utils/strategyHydrate.ts b/frontend/src/utils/strategyHydrate.ts index 0117d32..8b0f703 100644 --- a/frontend/src/utils/strategyHydrate.ts +++ b/frontend/src/utils/strategyHydrate.ts @@ -45,15 +45,17 @@ export function hydrateStrategyDto(strategy: StrategyDto): StrategyDto { if (!layout) return next; try { - const buyState = decodeConditionForEditor(buy); - const sellState = decodeConditionForEditor(sell); + const normalizedBuy = asLogicNode(next.buyCondition); + const normalizedSell = asLogicNode(next.sellCondition); + const buyState = decodeConditionForEditor(normalizedBuy); + const sellState = decodeConditionForEditor(normalizedSell); const encodedBuy = encodeConditionForSave(buyState); const encodedSell = encodeConditionForSave(sellState); if (encodedBuy || encodedSell) { next = { ...next, - buyCondition: encodedBuy ?? buy ?? undefined, - sellCondition: encodedSell ?? sell ?? undefined, + buyCondition: encodedBuy ?? normalizedBuy ?? undefined, + sellCondition: encodedSell ?? normalizedSell ?? undefined, }; } } catch { diff --git a/frontend/src/utils/thresholdSymbols.ts b/frontend/src/utils/thresholdSymbols.ts index 3c929e2..8e087c5 100644 --- a/frontend/src/utils/thresholdSymbols.ts +++ b/frontend/src/utils/thresholdSymbols.ts @@ -38,7 +38,8 @@ function mergedHlThresh( indicatorType: string, hlThresh: Record, ): HlThresh { - return { ...DEFAULT_HL_THRESH[indicatorType], ...hlThresh[indicatorType] }; + const key = indicatorType?.toUpperCase?.() ?? indicatorType; + return { ...DEFAULT_HL_THRESH[key], ...hlThresh[key], ...hlThresh[indicatorType] }; } export function defaultThresholdForRole(