직접입력값 평가오류 수정

This commit is contained in:
Macbook
2026-06-18 00:47:58 +09:00
parent bd6094f71f
commit e5b5af093e
11 changed files with 297 additions and 284 deletions
+49 -75
View File
@@ -1,94 +1,68 @@
/** 정규화 로직 회귀 검증 — 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 canonicalizeCondition(cond) {
let next = { ...cond, indicatorType: cond.indicatorType?.toUpperCase?.() ?? cond.indicatorType };
const targetNum = typeof next.targetValue === 'number' && Number.isFinite(next.targetValue)
? next.targetValue
: null;
const fromField = parseThresholdField(next.rightField);
const val = targetNum ?? fromField;
if (val != null && Number.isFinite(val)) {
const role = inferThresholdRoleForValue(val, next.indicatorType);
if (role) {
const { targetValue, ...rest } = next;
return { ...rest, rightField: role, thresholdOverride: false };
function ensureDirectThresholdSnapshot(cond) {
const indicatorType = cond.indicatorType?.toUpperCase?.() ?? cond.indicatorType;
let next = { ...cond, indicatorType };
for (const key of ['rightField', 'leftField']) {
const f = next[key];
if (!f?.startsWith('K_')) continue;
const parsed = parseThresholdField(f);
if (parsed == null) continue;
if (next.targetValue == null || !Number.isFinite(next.targetValue)) {
next = { ...next, targetValue: parsed, thresholdOverride: true };
} else if (next.thresholdOverride !== true) {
next = { ...next, thresholdOverride: true };
}
}
if (next.rightField?.startsWith('K_')) {
const role = inferFromK(next.rightField, next.indicatorType);
if (role) {
const { targetValue, ...rest } = next;
return { ...rest, 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: canonicalizeCondition(node.condition) };
}
if (next.children?.length) {
next.children = next.children.map(normalizeNode);
}
return next;
}
/** 차트 hline mid=48 이어도 직접입력 50 → HL_MID (Ta4j 기본값 기준) */
function setConditionThreshold(cond, value) {
const role = inferThresholdRoleForValue(value, cond.indicatorType);
if (role) {
const { targetValue, ...rest } = cond;
return { ...rest, rightField: role, thresholdOverride: false };
}
return { ...cond, rightField: `K_${value}`, targetValue: value, thresholdOverride: true };
function setDirectThreshold(cond, side, value) {
const fieldKey = side === 'left' ? 'leftField' : 'rightField';
return {
...cond,
[fieldKey]: `K_${value}`,
targetValue: value,
thresholdOverride: true,
};
}
function setConditionThreshold(cond, value, directInput) {
if (directInput) return setDirectThreshold(cond, 'right', value);
return setDirectThreshold(cond, 'right', value);
}
let failed = 0;
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;
function assertOk(label, ok) {
if (!ok) failed++;
console.log(ok ? 'PASS' : 'FAIL', JSON.stringify(c).slice(0, 60), '=>', cond?.rightField, cond?.thresholdOverride);
console.log(ok ? 'PASS' : 'FAIL', label);
}
const direct = setConditionThreshold(
{ indicatorType: 'RSI', rightField: 'HL_OVER', thresholdOverride: false },
50,
);
const directOk = direct.rightField === 'HL_MID' && direct.thresholdOverride === false;
if (!directOk) failed++;
console.log(directOk ? 'PASS' : 'FAIL', 'setConditionThreshold(50) with chart mid≠50 =>', direct.rightField);
const k55 = ensureDirectThresholdSnapshot({
indicatorType: 'RSI',
rightField: 'K_55',
thresholdOverride: true,
targetValue: 55,
});
assertOk('K_55 preserved', k55.rightField === 'K_55' && k55.targetValue === 55 && k55.thresholdOverride === true);
const k50 = ensureDirectThresholdSnapshot({
indicatorType: 'RSI',
rightField: 'K_50',
thresholdOverride: true,
targetValue: 50,
});
assertOk('K_50 preserved (not HL_MID)', k50.rightField === 'K_50' && k50.thresholdOverride === true);
const direct55 = setConditionThreshold({ indicatorType: 'RSI', rightField: 'HL_MID', thresholdOverride: false }, 55, true);
assertOk('directInput 55 => K_55', direct55.rightField === 'K_55' && direct55.targetValue === 55);
const filled = ensureDirectThresholdSnapshot({ indicatorType: 'RSI', rightField: 'K_55', thresholdOverride: false });
assertOk('K_55 fills targetValue', filled.targetValue === 55 && filled.thresholdOverride === true);
process.exit(failed > 0 ? 1 : 0);
@@ -20,7 +20,7 @@ import {
setConditionRightPeriod,
setConditionThreshold,
setConditionValuePeriod,
canonicalizeConditionThreshold,
ensureDirectThresholdSnapshot,
usesValuePeriodField,
} from '../../utils/conditionPeriods';
import { compositeDisplayName, compositeElementLabel, COMPOSITE_INDICATOR_ITEMS, switchCompositeIndicatorType } from '../../utils/compositeIndicators';
@@ -170,7 +170,7 @@ export default function ConditionNodeSettings({
max={thresholdBounds.max}
allowDecimal
onChange={v => onChange(
canonicalizeConditionThreshold(
ensureDirectThresholdSnapshot(
setConditionThreshold(condition, 'right', v, def, { directInput: true }),
),
)}
+42 -59
View File
@@ -205,38 +205,47 @@ export function thresholdField(value: number): string {
return `K_${value}`;
}
/** 직접입력 — K_ 숫자·targetValue·thresholdOverride 스냅샷 */
export function setDirectThreshold(
cond: ConditionDSL,
side: 'left' | 'right',
value: number,
): ConditionDSL {
const fieldKey = side === 'left' ? 'leftField' : 'rightField';
return {
...cond,
[fieldKey]: thresholdField(value),
targetValue: value,
thresholdOverride: true,
};
}
/**
* 표준 hline 값(30/50/70 등)이면 K_*·targetValue 를 HL_* 로 통일.
* 차트 커스텀 hline(def.hlThresh)과 무관 — Ta4j 기본 역할값 기준.
* K_* 직접입력 스냅샷 보강 — targetValue 누락 시 필드명에서 복원.
* HL_* 로 치환하지 않음 (직접입력 55는 평가 시 55 그대로).
*/
export function canonicalizeConditionThreshold(cond: ConditionDSL): ConditionDSL {
export function ensureDirectThresholdSnapshot(cond: ConditionDSL): ConditionDSL {
const indicatorType = cond.indicatorType?.toUpperCase?.() ?? cond.indicatorType;
let next: ConditionDSL = { ...cond, indicatorType };
const rawTarget = next.targetValue;
const targetNum = typeof rawTarget === 'number' && Number.isFinite(rawTarget) ? rawTarget : null;
const fromField = parseThresholdField(next.rightField);
const val = Number.isFinite(targetNum) ? targetNum : fromField;
if (val != null && Number.isFinite(val)) {
const role = inferThresholdRoleForValue(val, indicatorType, {});
if (role != null) {
const { targetValue: _t, ...rest } = next;
return { ...rest, rightField: role, thresholdOverride: false };
}
}
if (next.rightField?.startsWith('K_')) {
const role = inferThresholdSymbolFromLegacyExact(next.rightField, indicatorType, {});
if (role != null) {
const { targetValue: _t, ...rest } = next;
return { ...rest, rightField: role, thresholdOverride: false };
for (const key of ['rightField', 'leftField'] as const) {
const f = next[key];
if (!f?.startsWith('K_')) continue;
const parsed = parseThresholdField(f);
if (parsed == null) continue;
if (next.targetValue == null || !Number.isFinite(next.targetValue)) {
next = { ...next, targetValue: parsed, thresholdOverride: true };
} else if (!isThresholdOverridden(next)) {
next = { ...next, thresholdOverride: true };
}
}
return next;
}
/** @deprecated ensureDirectThresholdSnapshot 사용 — HL_* 치환 없음 */
export const canonicalizeConditionThreshold = ensureDirectThresholdSnapshot;
export function hasEditableThreshold(cond: ConditionDSL): boolean {
return isThresholdFieldOrSymbol(cond.rightField)
|| isThresholdFieldOrSymbol(cond.leftField)
@@ -313,9 +322,7 @@ export function activateDirectThresholdInput(
?? getChartReferenceThreshold(cond, side, inheritedField, def)
?? parseThresholdField(side === 'right' ? cond.rightField : cond.leftField);
if (value == null || !Number.isFinite(value)) return cond;
return canonicalizeConditionThreshold(
setConditionThreshold(cond, side, value, def),
);
return setDirectThreshold(cond, side, value);
}
export function setConditionThreshold(
@@ -326,20 +333,16 @@ export function setConditionThreshold(
options?: SetConditionThresholdOptions,
): ConditionDSL {
const fieldKey = side === 'left' ? 'leftField' : 'rightField';
// 직접입력 — K_ 스냅샷 유지 (55 등 비표준값도 평가에 그대로 반영)
if (options?.directInput) {
return setDirectThreshold(cond, side, value);
}
const current = cond[fieldKey];
if (!isThresholdFieldOrSymbol(current)) {
if (side === 'right' && INDICATORS_WITH_RIGHT_THRESHOLD_INPUT.has(cond.indicatorType)) {
const matchedRole = inferThresholdRoleForValue(value, cond.indicatorType, {});
if (matchedRole != null) {
const { targetValue: _t, ...rest } = cond;
return { ...rest, [fieldKey]: matchedRole, thresholdOverride: false };
}
return {
...cond,
[fieldKey]: thresholdField(value),
targetValue: value,
thresholdOverride: true,
};
return setDirectThreshold(cond, side, value);
}
return cond;
}
@@ -351,33 +354,13 @@ export function setConditionThreshold(
? getChartReferenceThreshold(cond, side, inheritedField, def)
: null;
// 30/50/70 등 표준 hline — 차트 커스텀값과 무관하게 HL_* 로 통일 (직접입력·프리셋·평가 일치)
const matchedRole = inferThresholdRoleForValue(value, cond.indicatorType, {});
if (matchedRole != null) {
// 드롭다운 hline 프리셋(중앙선 등)과 차트 기준값이 일치할 때만 상속
if (chartRef != null && Math.abs(chartRef - value) < 0.0001 && isThresholdSymbol(inheritedField)) {
const { targetValue: _t, ...rest } = cond;
return { ...rest, [fieldKey]: matchedRole, thresholdOverride: false };
return { ...rest, [fieldKey]: inheritedField, thresholdOverride: false };
}
if (!options?.directInput && chartRef != null && Math.abs(chartRef - value) < 0.0001) {
const role = isThresholdSymbol(inheritedField)
? inheritedField
: (side === 'right' ? THRESHOLD_HL_OVER : inheritedField);
const { targetValue: _t, ...rest } = cond;
return { ...rest, [fieldKey]: role, thresholdOverride: false };
}
const fallbackRole = inferThresholdRoleForValue(value, cond.indicatorType, {});
if (fallbackRole != null) {
const { targetValue: _t, ...rest } = cond;
return { ...rest, [fieldKey]: fallbackRole, thresholdOverride: false };
}
return {
...cond,
[fieldKey]: thresholdField(value),
targetValue: value,
thresholdOverride: true,
};
return setDirectThreshold(cond, side, value);
}
export function setConditionValuePeriod(
@@ -3,35 +3,21 @@ import type { ConditionDSL, LogicNode } from './strategyTypes';
import type { DefType } from './strategyEditorShared';
import { getDefaultConditionFields } from './strategyEditorShared';
import {
canonicalizeConditionThreshold,
ensureDirectThresholdSnapshot,
isThresholdOverridden,
isValuePeriodOverridden,
parseThresholdField,
usesValuePeriodField,
VALUE_FIELD_PREFIX,
} from './conditionPeriods';
import {
defaultInheritedThresholdField,
inferThresholdRoleForValue,
inferThresholdSymbolFromLegacy,
inferThresholdSymbolFromLegacyExact,
isThresholdFieldOrSymbol,
isThresholdSymbol,
THRESHOLD_HL_MID,
THRESHOLD_HL_OVER,
} 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);
}
@@ -41,22 +27,8 @@ function migrateConditionThreshold(
def: DefType,
signalType: 'buy' | 'sell',
): ConditionDSL {
// 직접입력(K_55 등) — 숫자 스냅샷 유지, HL_* 로 접지 않음
if (isThresholdOverridden(cond)) {
const val = coerceTargetValue(cond.targetValue) ?? parseThresholdField(cond.rightField);
if (val != null) {
const role = inferThresholdRoleForValue(val, cond.indicatorType, {});
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, {});
if (role != null) {
const { targetValue: _t, ...rest } = cond;
return migrateConditionPeriod({ ...rest, rightField: role, thresholdOverride: false });
}
}
return cond;
}
@@ -138,9 +110,9 @@ export function migrateLogicRootForEditor(
return repairPriceExtremePairForest(migrated.root, migrated.orphans);
}
/** 저장·표시·평가 공통 — K_50 등을 HL_MID 로 접기 */
/** 저장·표시·평가 공통 — K_* 직접입력 스냅샷 보강 (HL_* 치환 없음) */
export function normalizeConditionForPersistence(cond: ConditionDSL): ConditionDSL {
let next = canonicalizeConditionThreshold(cond);
let next = ensureDirectThresholdSnapshot(cond);
if (!isValuePeriodOverridden(next)) {
const prefix = VALUE_FIELD_PREFIX[next.indicatorType];
@@ -23,7 +23,7 @@ import {
getCompositeFieldOpts,
getCompositeLeftCandleType,
getCompositeRightCandleType,
canonicalizeConditionThreshold,
ensureDirectThresholdSnapshot,
parseThresholdField,
} from './conditionPeriods';
import { formatStrategyCandleLabel } from './strategyStartNodes';
@@ -154,7 +154,7 @@ function describeCondition(
signalType: 'buy' | 'sell',
def: DefType,
): string {
const cond = canonicalizeConditionThreshold(raw);
const cond = ensureDirectThresholdSnapshot(raw);
const ind = cond.indicatorType;
const ct = cond.conditionType;
+5 -5
View File
@@ -74,7 +74,7 @@ import {
import ComboFieldSelect from '../components/strategyEditor/ComboFieldSelect';
import {
activateDirectThresholdInput,
canonicalizeConditionThreshold,
ensureDirectThresholdSnapshot,
getCompositeFieldOpts,
getCompositePeriodPresetOptions,
getConditionRightPeriod,
@@ -924,7 +924,7 @@ export function resolveFieldOptionValue(indicatorType: string, field?: string):
export const nodeToText = (node: LogicNode, DEF: DefType = DEF_DEFAULTS): string => {
if (!node) return '';
if (node.type === 'CONDITION' && node.condition) {
const c = canonicalizeConditionThreshold(normalizeCompositeCondition(node.condition));
const c = ensureDirectThresholdSnapshot(normalizeCompositeCondition(node.condition));
const indName = getStrategyIndicatorDisplayName(c.indicatorType);
const opts = getFieldOpts(c.indicatorType, 'buy', DEF, c);
const C = condLabel(c.conditionType);
@@ -1155,7 +1155,7 @@ export const CondEditor: React.FC<CondEditorProps> = ({
const handleRight = (v: string) => {
if (isThresholdSymbol(v)) {
const { targetValue: _t, ...rest } = normalized;
onChange(canonicalizeConditionThreshold({ ...rest, rightField: v, thresholdOverride: false }));
onChange(ensureDirectThresholdSnapshot({ ...rest, rightField: v, thresholdOverride: false }));
return;
}
const thresholds: Record<string,number> = {
@@ -1177,7 +1177,7 @@ export const CondEditor: React.FC<CondEditorProps> = ({
if (priorP != null && isPriceExtremeIndicator(normalized.indicatorType)) {
upd = syncPriceExtremeSimpleFields({ ...upd, period: priorP });
}
onChange(canonicalizeConditionThreshold(upd));
onChange(ensureDirectThresholdSnapshot(upd));
};
if (normalized.composite) {
@@ -1443,7 +1443,7 @@ export const CondEditor: React.FC<CondEditorProps> = ({
));
}}
onCustomNumberChange={v => onChange(
canonicalizeConditionThreshold(
ensureDirectThresholdSnapshot(
setConditionThreshold(normalized, 'right', v, def, { directInput: true }),
),
)}