평가로직 오류 수정

This commit is contained in:
Macbook
2026-06-17 23:57:17 +09:00
parent d4b5814bbc
commit 9c832a3ab8
7 changed files with 156 additions and 27 deletions
@@ -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");
@@ -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) {
@@ -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);
}
@@ -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<Props> = ({
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],
);
@@ -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;
}
+6 -4
View File
@@ -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 {
+2 -1
View File
@@ -38,7 +38,8 @@ function mergedHlThresh(
indicatorType: string,
hlThresh: Record<string, HlThresh>,
): 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(