평가로직 오류 수정

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 com.fasterxml.jackson.databind.node.ObjectNode;
import java.util.Map; import java.util.Map;
import java.util.Locale;
/** /**
* 전략 조건 DSL — K_50 등 직접입력 스냅샷을 hline 역할(HL_MID 등)로 정규화. * 전략 조건 DSL — K_50 등 직접입력 스냅샷을 hline 역할(HL_MID 등)로 정규화.
@@ -37,11 +38,15 @@ public final class StrategyConditionThresholdNormalizer {
} }
private static void normalizeCondition(ObjectNode cond) { private static void normalizeCondition(ObjectNode cond) {
normalizeThresholdField(cond, "rightField"); String indType = cond.path("indicatorType").asText("").toUpperCase(Locale.ROOT);
normalizeThresholdField(cond, "leftField"); 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(""); String field = cond.path(fieldKey).asText("");
if (field.isBlank()) return; if (field.isBlank()) return;
@@ -49,7 +54,7 @@ public final class StrategyConditionThresholdNormalizer {
Double value = readThresholdValue(cond, field); Double value = readThresholdValue(cond, field);
if (override || field.startsWith("K_")) { if (override || field.startsWith("K_")) {
String role = inferRoleForValue(cond.path("indicatorType").asText(""), value, field); String role = inferRoleForValue(indType, value, field);
if (role != null) { if (role != null) {
cond.put(fieldKey, role); cond.put(fieldKey, role);
cond.remove("targetValue"); cond.remove("targetValue");
@@ -59,7 +64,7 @@ public final class StrategyConditionThresholdNormalizer {
} }
if (!override && field.startsWith("K_")) { if (!override && field.startsWith("K_")) {
String role = inferRoleForValue(cond.path("indicatorType").asText(""), value, field); String role = inferRoleForValue(indType, value, field);
if (role != null) { if (role != null) {
cond.put(fieldKey, role); cond.put(fieldKey, role);
cond.remove("targetValue"); cond.remove("targetValue");
@@ -152,10 +152,14 @@ public class StrategyService {
dto.setCreatedAt(e.getCreatedAt()); dto.setCreatedAt(e.getCreatedAt());
dto.setUpdatedAt(e.getUpdatedAt()); dto.setUpdatedAt(e.getUpdatedAt());
try { try {
if (e.getBuyConditionJson() != null) if (e.getBuyConditionJson() != null) {
dto.setBuyCondition(objectMapper.readTree(e.getBuyConditionJson())); JsonNode buy = objectMapper.readTree(e.getBuyConditionJson());
if (e.getSellConditionJson() != null) dto.setBuyCondition(StrategyConditionThresholdNormalizer.normalizeTree(buy));
dto.setSellCondition(objectMapper.readTree(e.getSellConditionJson())); }
if (e.getSellConditionJson() != null) {
JsonNode sell = objectMapper.readTree(e.getSellConditionJson());
dto.setSellCondition(StrategyConditionThresholdNormalizer.normalizeTree(sell));
}
if (e.getFlowLayoutJson() != null && !e.getFlowLayoutJson().isBlank()) if (e.getFlowLayoutJson() != null && !e.getFlowLayoutJson().isBlank())
dto.setFlowLayout(objectMapper.readTree(e.getFlowLayoutJson())); dto.setFlowLayout(objectMapper.readTree(e.getFlowLayoutJson()));
} catch (Exception ex) { } 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 type { StrategyDto } from '../../utils/backendApi';
import { asLogicNode } from '../../utils/strategyHydrate'; import { asLogicNode } from '../../utils/strategyHydrate';
import { decodeConditionForEditor } from '../../utils/strategyConditionSerde'; import { decodeConditionForEditor } from '../../utils/strategyConditionSerde';
import { normalizeLogicRootForPersistence } from '../../utils/strategyConditionNormalize';
import { buildDefFromGetParams } from '../../utils/strategyEditorShared'; import { buildDefFromGetParams } from '../../utils/strategyEditorShared';
import LogicExpressionPreview from '../strategyEditor/LogicExpressionPreview'; import LogicExpressionPreview from '../strategyEditor/LogicExpressionPreview';
import LogicExpressionNarrative from '../strategyEditor/LogicExpressionNarrative'; import LogicExpressionNarrative from '../strategyEditor/LogicExpressionNarrative';
@@ -34,11 +35,11 @@ const StrategyEvaluationConditionPanel: React.FC<Props> = ({
const def = useMemo(() => buildDefFromGetParams(resolveGetParams), [resolveGetParams]); const def = useMemo(() => buildDefFromGetParams(resolveGetParams), [resolveGetParams]);
const buyCondition = useMemo( const buyCondition = useMemo(
() => (strategy ? asLogicNode(strategy.buyCondition) : null), () => normalizeLogicRootForPersistence(strategy ? asLogicNode(strategy.buyCondition) : null),
[strategy?.buyCondition], [strategy?.buyCondition],
); );
const sellCondition = useMemo( const sellCondition = useMemo(
() => (strategy ? asLogicNode(strategy.sellCondition) : null), () => normalizeLogicRootForPersistence(strategy ? asLogicNode(strategy.sellCondition) : null),
[strategy?.sellCondition], [strategy?.sellCondition],
); );
@@ -21,14 +21,28 @@ import {
} from './thresholdSymbols'; } from './thresholdSymbols';
import { repairPriceExtremePairForest } from './priceExtremeBreakoutPair'; 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( function migrateConditionThreshold(
cond: ConditionDSL, cond: ConditionDSL,
def: DefType, def: DefType,
signalType: 'buy' | 'sell', signalType: 'buy' | 'sell',
): ConditionDSL { ): ConditionDSL {
if (isThresholdOverridden(cond)) { if (isThresholdOverridden(cond)) {
const val = cond.targetValue ?? parseThresholdField(cond.rightField); const val = coerceTargetValue(cond.targetValue) ?? parseThresholdField(cond.rightField);
if (val != null && Number.isFinite(val)) { if (val != null) {
const role = inferThresholdRoleForValue(val, cond.indicatorType, def.hlThresh); const role = inferThresholdRoleForValue(val, cond.indicatorType, def.hlThresh);
if (role != null) { if (role != null) {
const { targetValue: _t, ...rest } = cond; const { targetValue: _t, ...rest } = cond;
@@ -92,12 +106,20 @@ function migrateLogicNode(
signalType: 'buy' | 'sell', signalType: 'buy' | 'sell',
): LogicNode { ): LogicNode {
let next = node; let next = node;
if (node.type === 'CONDITION' && node.condition) { if (isConditionCarrier(node)) {
next = { ...node, condition: migrateConditionForEditor(node.condition, def, signalType) }; next = {
...node,
type: node.type ?? 'CONDITION',
condition: migrateConditionForEditor(node.condition, def, signalType),
};
} }
if (next.children?.length) { if (next.children?.length) {
next = { ...next, children: next.children.map(c => migrateLogicNode(c, def, signalType)) }; 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; return next;
} }
@@ -115,13 +137,17 @@ export function migrateLogicRootForEditor(
return repairPriceExtremePairForest(migrated.root, migrated.orphans); return repairPriceExtremePairForest(migrated.root, migrated.orphans);
} }
/** 저장 직전 — 상속 모드는 숫자 스냅샷·targetValue 제거; 직접입력도 hline 역할과 일치하면 HL_*통일 */ /** 저장·표시·평가 공통 — K_50 등을 HL_MID접기 */
export function normalizeConditionForPersistence(cond: ConditionDSL): ConditionDSL { 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 (isThresholdOverridden(next)) {
if (next.targetValue != null && Number.isFinite(next.targetValue)) { if (targetNum != null) {
const role = inferThresholdRoleForValue(next.targetValue, next.indicatorType, {}); const role = inferThresholdRoleForValue(targetNum, next.indicatorType, {});
if (role != null) { if (role != null) {
const { targetValue: _t, ...rest } = next; const { targetValue: _t, ...rest } = next;
next = { ...rest, rightField: role, thresholdOverride: false }; next = { ...rest, rightField: role, thresholdOverride: false };
@@ -168,13 +194,21 @@ export function normalizeConditionForPersistence(cond: ConditionDSL): ConditionD
} }
function normalizeLogicNode(node: LogicNode): LogicNode { function normalizeLogicNode(node: LogicNode): LogicNode {
let next = node; let next: LogicNode = node;
if (node.type === 'CONDITION' && node.condition) { if (isConditionCarrier(node)) {
next = { ...node, condition: normalizeConditionForPersistence(node.condition) }; next = {
...node,
type: node.type ?? 'CONDITION',
condition: normalizeConditionForPersistence(node.condition),
};
} }
if (next.children?.length) { if (next.children?.length) {
next = { ...next, children: next.children.map(normalizeLogicNode) }; 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; return next;
} }
+6 -4
View File
@@ -45,15 +45,17 @@ export function hydrateStrategyDto(strategy: StrategyDto): StrategyDto {
if (!layout) return next; if (!layout) return next;
try { try {
const buyState = decodeConditionForEditor(buy); const normalizedBuy = asLogicNode(next.buyCondition);
const sellState = decodeConditionForEditor(sell); const normalizedSell = asLogicNode(next.sellCondition);
const buyState = decodeConditionForEditor(normalizedBuy);
const sellState = decodeConditionForEditor(normalizedSell);
const encodedBuy = encodeConditionForSave(buyState); const encodedBuy = encodeConditionForSave(buyState);
const encodedSell = encodeConditionForSave(sellState); const encodedSell = encodeConditionForSave(sellState);
if (encodedBuy || encodedSell) { if (encodedBuy || encodedSell) {
next = { next = {
...next, ...next,
buyCondition: encodedBuy ?? buy ?? undefined, buyCondition: encodedBuy ?? normalizedBuy ?? undefined,
sellCondition: encodedSell ?? sell ?? undefined, sellCondition: encodedSell ?? normalizedSell ?? undefined,
}; };
} }
} catch { } catch {
+2 -1
View File
@@ -38,7 +38,8 @@ function mergedHlThresh(
indicatorType: string, indicatorType: string,
hlThresh: Record<string, HlThresh>, hlThresh: Record<string, HlThresh>,
): 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( export function defaultThresholdForRole(