평가로직 오류 수정
This commit is contained in:
+10
-5
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
Reference in New Issue
Block a user