diff --git a/backend/src/main/java/com/goldenchart/service/StrategyConditionThresholdNormalizer.java b/backend/src/main/java/com/goldenchart/service/StrategyConditionThresholdNormalizer.java index 4754ad9..c1197f9 100644 --- a/backend/src/main/java/com/goldenchart/service/StrategyConditionThresholdNormalizer.java +++ b/backend/src/main/java/com/goldenchart/service/StrategyConditionThresholdNormalizer.java @@ -27,6 +27,8 @@ public final class StrategyConditionThresholdNormalizer { JsonNode cond = node.get("condition"); if (cond != null && cond.isObject()) { normalizeCondition((ObjectNode) cond); + } else if (node.has("indicatorType") && node.has("conditionType")) { + normalizeCondition((ObjectNode) node); } JsonNode children = node.get("children"); diff --git a/backend/src/main/java/com/goldenchart/service/StrategyDslToTa4jAdapter.java b/backend/src/main/java/com/goldenchart/service/StrategyDslToTa4jAdapter.java index f3a0d3e..7701dbe 100644 --- a/backend/src/main/java/com/goldenchart/service/StrategyDslToTa4jAdapter.java +++ b/backend/src/main/java/com/goldenchart/service/StrategyDslToTa4jAdapter.java @@ -216,10 +216,22 @@ public class StrategyDslToTa4jAdapter { case "OR" -> buildOrRule(node, ctx); case "NOT" -> buildNotRule(node, ctx); case "TIMEFRAME" -> buildTimeframeRule(node, ctx); - default -> buildConditionRule(node.path("condition"), ctx); + default -> buildConditionRuleNode(node, ctx); }; } + /** CONDITION 래퍼 — nested condition 또는 인라인 조건 필드 모두 지원 */ + private Rule buildConditionRuleNode(JsonNode node, RuleBuildContext ctx) { + JsonNode nested = node.path("condition"); + if (nested != null && !nested.isNull() && nested.has("indicatorType")) { + return buildConditionRule(nested, ctx); + } + if (node.has("indicatorType")) { + return buildConditionRule(node, ctx); + } + return new BooleanRule(false); + } + /** 조건 DSL left/right 필드의 현재 봉 수치 (가상투자 UI 표시용) */ public Double readConditionFieldValue(JsonNode cond, BarSeries series, Map> params, diff --git a/frontend/scripts/test-threshold-normalize.mjs b/frontend/scripts/test-threshold-normalize.mjs index 69847f8..445a8ae 100644 --- a/frontend/scripts/test-threshold-normalize.mjs +++ b/frontend/scripts/test-threshold-normalize.mjs @@ -22,36 +22,26 @@ function inferFromK(field, indicatorType) { 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) { +function canonicalizeCondition(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 }; - } + 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 }; } } - if (!isThresholdOverridden(next) && next.rightField?.startsWith('K_')) { + if (next.rightField?.startsWith('K_')) { const role = inferFromK(next.rightField, next.indicatorType); - if (role) next = { ...next, rightField: role, thresholdOverride: false }; + if (role) { + const { targetValue, ...rest } = next; + return { ...rest, rightField: role, thresholdOverride: false }; + } } return next; } @@ -61,7 +51,7 @@ function isConditionCarrier(node) { function normalizeNode(node) { let next = { ...node }; if (isConditionCarrier(node)) { - next = { ...node, type: node.type ?? 'CONDITION', condition: normalizeCondition(node.condition) }; + next = { ...node, type: node.type ?? 'CONDITION', condition: canonicalizeCondition(node.condition) }; } if (next.children?.length) { next.children = next.children.map(normalizeNode); @@ -69,6 +59,17 @@ function normalizeNode(node) { 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 }; +} + +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 } }, @@ -78,5 +79,16 @@ 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; + if (!ok) failed++; console.log(ok ? 'PASS' : 'FAIL', JSON.stringify(c).slice(0, 60), '=>', cond?.rightField, cond?.thresholdOverride); } + +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); + +process.exit(failed > 0 ? 1 : 0); diff --git a/frontend/src/components/strategyEditor/ConditionNodeSettings.tsx b/frontend/src/components/strategyEditor/ConditionNodeSettings.tsx index d2c5e71..3c1cb2f 100644 --- a/frontend/src/components/strategyEditor/ConditionNodeSettings.tsx +++ b/frontend/src/components/strategyEditor/ConditionNodeSettings.tsx @@ -20,6 +20,7 @@ import { setConditionRightPeriod, setConditionThreshold, setConditionValuePeriod, + canonicalizeConditionThreshold, usesValuePeriodField, } from '../../utils/conditionPeriods'; import { compositeDisplayName, compositeElementLabel, COMPOSITE_INDICATOR_ITEMS, switchCompositeIndicatorType } from '../../utils/compositeIndicators'; @@ -168,7 +169,11 @@ export default function ConditionNodeSettings({ min={thresholdBounds.min} max={thresholdBounds.max} allowDecimal - onChange={v => onChange(setConditionThreshold(condition, 'right', v, def, { directInput: true }))} + onChange={v => onChange( + canonicalizeConditionThreshold( + setConditionThreshold(condition, 'right', v, def, { directInput: true }), + ), + )} /> diff --git a/frontend/src/components/strategyEditor/StrategyEditorCanvas.tsx b/frontend/src/components/strategyEditor/StrategyEditorCanvas.tsx index 83a2140..6ccf878 100644 --- a/frontend/src/components/strategyEditor/StrategyEditorCanvas.tsx +++ b/frontend/src/components/strategyEditor/StrategyEditorCanvas.tsx @@ -20,6 +20,7 @@ import { import '@xyflow/react/dist/style.css'; import type { Theme } from '../../types/index'; import type { LogicNode, ConditionDSL } from '../../utils/strategyTypes'; +import { normalizeConditionForPersistence } from '../../utils/strategyConditionNormalize'; import { addChild, deleteNode, @@ -716,21 +717,22 @@ function StrategyEditorCanvasInner({ }, [clearDropPreview, applyDropWithAttach, addOrphanAt, root, orphans]); const handleUpdateCondition = useCallback((id: string, condition: ConditionDSL) => { + const next = normalizeConditionForPersistence(condition); if (isOrphanNode(orphans, id)) { onOrphansChange(orphans.map(o => ( - o.id === id ? { ...o, condition } : o + o.id === id ? { ...o, condition: next } : o ))); return; } if (root && findNodeInTree(root, id)) { - onChange(updateNode(root, id, n => ({ ...n, condition }))); + onChange(updateNode(root, id, n => ({ ...n, condition: next }))); return; } for (const [sid, branch] of Object.entries(extraRoots)) { if (branch && findNodeInTree(branch, id)) { onExtraRootsChange?.({ ...extraRoots, - [sid]: updateNode(branch, id, n => ({ ...n, condition })), + [sid]: updateNode(branch, id, n => ({ ...n, condition: next })), }); return; } diff --git a/frontend/src/utils/conditionPeriods.ts b/frontend/src/utils/conditionPeriods.ts index 1a7c416..b416e2c 100644 --- a/frontend/src/utils/conditionPeriods.ts +++ b/frontend/src/utils/conditionPeriods.ts @@ -22,6 +22,7 @@ import { isThresholdFieldOrSymbol, isThresholdSymbol, inferThresholdRoleForValue, + inferThresholdSymbolFromLegacyExact, resolveInheritedThresholdField, resolveThresholdFromDef, THRESHOLD_HL_MID, @@ -204,6 +205,38 @@ export function thresholdField(value: number): string { return `K_${value}`; } +/** + * 표준 hline 값(30/50/70 등)이면 K_*·targetValue 를 HL_* 로 통일. + * 차트 커스텀 hline(def.hlThresh)과 무관 — Ta4j 기본 역할값 기준. + */ +export function canonicalizeConditionThreshold(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 }; + } + } + + return next; +} + export function hasEditableThreshold(cond: ConditionDSL): boolean { return isThresholdFieldOrSymbol(cond.rightField) || isThresholdFieldOrSymbol(cond.leftField) @@ -280,7 +313,9 @@ export function activateDirectThresholdInput( ?? getChartReferenceThreshold(cond, side, inheritedField, def) ?? parseThresholdField(side === 'right' ? cond.rightField : cond.leftField); if (value == null || !Number.isFinite(value)) return cond; - return setConditionThreshold(cond, side, value, def); + return canonicalizeConditionThreshold( + setConditionThreshold(cond, side, value, def), + ); } export function setConditionThreshold( @@ -294,9 +329,7 @@ export function setConditionThreshold( const current = cond[fieldKey]; if (!isThresholdFieldOrSymbol(current)) { if (side === 'right' && INDICATORS_WITH_RIGHT_THRESHOLD_INPUT.has(cond.indicatorType)) { - const matchedRole = def - ? inferThresholdRoleForValue(value, cond.indicatorType, def.hlThresh) - : null; + const matchedRole = inferThresholdRoleForValue(value, cond.indicatorType, {}); if (matchedRole != null) { const { targetValue: _t, ...rest } = cond; return { ...rest, [fieldKey]: matchedRole, thresholdOverride: false }; @@ -318,10 +351,8 @@ export function setConditionThreshold( ? getChartReferenceThreshold(cond, side, inheritedField, def) : null; - // 30/50/70 등 hline 역할과 정확히 일치하면 직접입력·프리셋 모두 HL_* 로 통일 (평가 일치) - const matchedRole = def - ? inferThresholdRoleForValue(value, cond.indicatorType, def.hlThresh) - : null; + // 30/50/70 등 표준 hline — 차트 커스텀값과 무관하게 HL_* 로 통일 (직접입력·프리셋·평가 일치) + const matchedRole = inferThresholdRoleForValue(value, cond.indicatorType, {}); if (matchedRole != null) { const { targetValue: _t, ...rest } = cond; return { ...rest, [fieldKey]: matchedRole, thresholdOverride: false }; @@ -335,6 +366,12 @@ export function setConditionThreshold( 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), diff --git a/frontend/src/utils/strategyConditionNormalize.ts b/frontend/src/utils/strategyConditionNormalize.ts index 0bd27ab..0eb2bc7 100644 --- a/frontend/src/utils/strategyConditionNormalize.ts +++ b/frontend/src/utils/strategyConditionNormalize.ts @@ -3,6 +3,7 @@ import type { ConditionDSL, LogicNode } from './strategyTypes'; import type { DefType } from './strategyEditorShared'; import { getDefaultConditionFields } from './strategyEditorShared'; import { + canonicalizeConditionThreshold, isThresholdOverridden, isValuePeriodOverridden, parseThresholdField, @@ -43,14 +44,14 @@ function migrateConditionThreshold( if (isThresholdOverridden(cond)) { const val = coerceTargetValue(cond.targetValue) ?? parseThresholdField(cond.rightField); if (val != null) { - const role = inferThresholdRoleForValue(val, cond.indicatorType, def.hlThresh); + 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, def.hlThresh); + const role = inferThresholdSymbolFromLegacyExact(cond.rightField, cond.indicatorType, {}); if (role != null) { const { targetValue: _t, ...rest } = cond; return migrateConditionPeriod({ ...rest, rightField: role, thresholdOverride: false }); @@ -61,8 +62,8 @@ function migrateConditionThreshold( let rightField = cond.rightField; if (rightField?.startsWith('K_')) { - rightField = inferThresholdSymbolFromLegacyExact(rightField, cond.indicatorType, def.hlThresh) - ?? inferThresholdSymbolFromLegacy(rightField, cond.indicatorType, def.hlThresh); + rightField = inferThresholdSymbolFromLegacyExact(rightField, cond.indicatorType, {}) + ?? inferThresholdSymbolFromLegacy(rightField, cond.indicatorType, {}); } if (!rightField || (!isThresholdSymbol(rightField) && !rightField.startsWith('K_'))) { const defaults = getDefaultConditionFields(cond.indicatorType, signalType, def); @@ -139,47 +140,7 @@ export function migrateLogicRootForEditor( /** 저장·표시·평가 공통 — K_50 등을 HL_MID 로 접기 */ export function normalizeConditionForPersistence(cond: ConditionDSL): ConditionDSL { - let next: ConditionDSL = { - ...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 != null) { - const { targetValue: _t, ...rest } = next; - next = { ...rest, rightField: role, thresholdOverride: false }; - } - } else if (next.rightField?.startsWith('K_')) { - const role = inferThresholdSymbolFromLegacyExact(next.rightField, next.indicatorType, {}); - if (role != null) { - const { targetValue: _t, ...rest } = next; - next = { ...rest, rightField: role, thresholdOverride: false }; - } - } - } - - if (!isThresholdOverridden(next)) { - if (next.rightField?.startsWith('K_')) { - next.rightField = inferThresholdSymbolFromLegacyExact( - next.rightField, - next.indicatorType, - {}, - ) ?? defaultInheritedThresholdField(next.indicatorType); - } - if (!isThresholdSymbol(next.rightField)) { - const role = next.indicatorType === 'DISPARITY' || next.indicatorType === 'VOLUME_OSC' - || next.indicatorType === 'TRIX' - ? THRESHOLD_HL_MID : THRESHOLD_HL_OVER; - if (parseThresholdField(next.rightField) != null || !next.rightField) { - next.rightField = role; - } - } - const { targetValue: _t, ...rest } = next; - next = { ...rest, thresholdOverride: false }; - } + let next = canonicalizeConditionThreshold(cond); if (!isValuePeriodOverridden(next)) { const prefix = VALUE_FIELD_PREFIX[next.indicatorType]; diff --git a/frontend/src/utils/strategyDescriptionNarrative.ts b/frontend/src/utils/strategyDescriptionNarrative.ts index 1f83fd5..a3024bd 100644 --- a/frontend/src/utils/strategyDescriptionNarrative.ts +++ b/frontend/src/utils/strategyDescriptionNarrative.ts @@ -23,6 +23,7 @@ import { getCompositeFieldOpts, getCompositeLeftCandleType, getCompositeRightCandleType, + canonicalizeConditionThreshold, parseThresholdField, } from './conditionPeriods'; import { formatStrategyCandleLabel } from './strategyStartNodes'; @@ -149,10 +150,11 @@ function describeConditionType( } function describeCondition( - cond: ReturnType, + raw: ReturnType, signalType: 'buy' | 'sell', def: DefType, ): string { + const cond = canonicalizeConditionThreshold(raw); const ind = cond.indicatorType; const ct = cond.conditionType; diff --git a/frontend/src/utils/strategyEditorShared.tsx b/frontend/src/utils/strategyEditorShared.tsx index 3e1b282..c713739 100644 --- a/frontend/src/utils/strategyEditorShared.tsx +++ b/frontend/src/utils/strategyEditorShared.tsx @@ -74,6 +74,7 @@ import { import ComboFieldSelect from '../components/strategyEditor/ComboFieldSelect'; import { activateDirectThresholdInput, + canonicalizeConditionThreshold, getCompositeFieldOpts, getCompositePeriodPresetOptions, getConditionRightPeriod, @@ -923,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 = normalizeCompositeCondition(node.condition); + const c = canonicalizeConditionThreshold(normalizeCompositeCondition(node.condition)); const indName = getStrategyIndicatorDisplayName(c.indicatorType); const opts = getFieldOpts(c.indicatorType, 'buy', DEF, c); const C = condLabel(c.conditionType); @@ -1154,7 +1155,7 @@ export const CondEditor: React.FC = ({ const handleRight = (v: string) => { if (isThresholdSymbol(v)) { const { targetValue: _t, ...rest } = normalized; - onChange({ ...rest, rightField: v, thresholdOverride: false }); + onChange(canonicalizeConditionThreshold({ ...rest, rightField: v, thresholdOverride: false })); return; } const thresholds: Record = { @@ -1176,7 +1177,7 @@ export const CondEditor: React.FC = ({ if (priorP != null && isPriceExtremeIndicator(normalized.indicatorType)) { upd = syncPriceExtremeSimpleFields({ ...upd, period: priorP }); } - onChange(upd); + onChange(canonicalizeConditionThreshold(upd)); }; if (normalized.composite) { @@ -1441,7 +1442,11 @@ export const CondEditor: React.FC = ({ def, )); }} - onCustomNumberChange={v => onChange(setConditionThreshold(normalized, 'right', v, def, { directInput: true }))} + onCustomNumberChange={v => onChange( + canonicalizeConditionThreshold( + setConditionThreshold(normalized, 'right', v, def, { directInput: true }), + ), + )} /> {/* 조건 */}