diff --git a/backend/src/main/java/com/goldenchart/service/IndicatorHlineResolver.java b/backend/src/main/java/com/goldenchart/service/IndicatorHlineResolver.java index 32cafb2..5fc0e18 100644 --- a/backend/src/main/java/com/goldenchart/service/IndicatorHlineResolver.java +++ b/backend/src/main/java/com/goldenchart/service/IndicatorHlineResolver.java @@ -20,12 +20,18 @@ public final class IndicatorHlineResolver { if (field == null || field.isBlank()) return null; - // K_ 직접입력 — targetValue 가 있으면 thresholdOverride 플래그와 무관하게 우선 - if (field.startsWith("K_") && cond != null && cond.has("targetValue") && !cond.path("targetValue").isNull()) { - return cond.path("targetValue").asDouble(); + boolean override = cond != null && cond.path("thresholdOverride").asBoolean(false); + + // K_ 직접입력 — targetValue·override 시 필드명/스냅샷 숫자 우선 + if (field.startsWith("K_")) { + if (cond != null && cond.has("targetValue") && !cond.path("targetValue").isNull()) { + return cond.path("targetValue").asDouble(); + } + if (override) { + try { return Double.parseDouble(field.substring(2)); } catch (NumberFormatException ignored) {} + } } - boolean override = cond != null && cond.path("thresholdOverride").asBoolean(false); if (override) { if (cond.has("targetValue") && !cond.path("targetValue").isNull()) { return cond.path("targetValue").asDouble(); diff --git a/frontend/src/components/StrategyEvaluationPage.tsx b/frontend/src/components/StrategyEvaluationPage.tsx index e43a784..170a806 100644 --- a/frontend/src/components/StrategyEvaluationPage.tsx +++ b/frontend/src/components/StrategyEvaluationPage.tsx @@ -281,6 +281,19 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) { return () => { cancelled = true; }; }, [selectedStrategyId]); + /** 편집기 저장 후 동일 전략 평가 시 DSL·시그널 갱신 */ + useEffect(() => { + const onSaved = (e: Event) => { + const detail = (e as CustomEvent<{ strategyId?: number }>).detail; + if (detail?.strategyId == null || detail.strategyId !== selectedStrategyId) return; + void loadStrategy(detail.strategyId).then(s => { + setSelectedStrategy(s ? hydrateStrategyDto(s) : null); + }); + }; + window.addEventListener('gc:strategy-saved', onSaved); + return () => window.removeEventListener('gc:strategy-saved', onSaved); + }, [selectedStrategyId]); + const filteredStrategies = useMemo(() => { const q = strategySearch.trim().toLowerCase(); if (!q) return strategies; diff --git a/frontend/src/utils/conditionPeriods.ts b/frontend/src/utils/conditionPeriods.ts index c2c1f9a..7884b53 100644 --- a/frontend/src/utils/conditionPeriods.ts +++ b/frontend/src/utils/conditionPeriods.ts @@ -82,6 +82,15 @@ function parseFieldPeriod(field?: string): number | null { return Number.isFinite(n) && n > 0 ? n : null; } +/** 조건 DSL에서 Ta4j 평가용 기간 — period 필드·leftField 접미사 */ +export function resolveConditionDslPeriod(c: ConditionDSL): number | undefined { + if (c.period != null && c.period > 0) return c.period; + const fromLeft = parseFieldPeriod(c.leftField); + if (fromLeft) return fromLeft; + if (c.composite && c.leftPeriod != null && c.leftPeriod > 0) return c.leftPeriod; + return undefined; +} + /** 조건 노드의 RSI 등 값(좌측) 기간 — true=전략 전용, false/미설정=보조지표 설정(DEF) 상속 */ export function isValuePeriodOverridden(cond: ConditionDSL): boolean { return cond.valuePeriodOverride === true; @@ -271,7 +280,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 setConditionThreshold(cond, side, value, def, { directInput: true }); + return setConditionThreshold(cond, side, value, def); } export function setConditionThreshold( @@ -302,22 +311,21 @@ export function setConditionThreshold( ? getChartReferenceThreshold(cond, side, inheritedField, def) : null; - if (!options?.directInput) { - const matchedRole = def - ? inferThresholdRoleForValue(value, cond.indicatorType, def.hlThresh) - : null; - if (matchedRole != null) { - const { targetValue: _t, ...rest } = cond; - return { ...rest, [fieldKey]: matchedRole, thresholdOverride: false }; - } + // 30/50/70 등 hline 역할과 정확히 일치하면 직접입력·프리셋 모두 HL_* 로 통일 (평가 일치) + const matchedRole = def + ? inferThresholdRoleForValue(value, cond.indicatorType, def.hlThresh) + : null; + if (matchedRole != null) { + const { targetValue: _t, ...rest } = cond; + return { ...rest, [fieldKey]: matchedRole, thresholdOverride: false }; + } - if (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 }; - } + 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 }; } return { diff --git a/frontend/src/utils/strategyConditionNormalize.ts b/frontend/src/utils/strategyConditionNormalize.ts index ba9972f..146514d 100644 --- a/frontend/src/utils/strategyConditionNormalize.ts +++ b/frontend/src/utils/strategyConditionNormalize.ts @@ -11,6 +11,7 @@ import { } from './conditionPeriods'; import { defaultInheritedThresholdField, + inferThresholdRoleForValue, inferThresholdSymbolFromLegacy, inferThresholdSymbolFromLegacyExact, isThresholdFieldOrSymbol, @@ -97,10 +98,26 @@ export function migrateLogicRootForEditor( return repairPriceExtremePairForest(migrated.root, migrated.orphans); } -/** 저장 직전 — 상속 모드는 숫자 스냅샷·targetValue 제거 (직접입력 K_+targetValue 는 유지) */ +/** 저장 직전 — 상속 모드는 숫자 스냅샷·targetValue 제거; 직접입력도 hline 역할과 일치하면 HL_* 로 통일 */ export function normalizeConditionForPersistence(cond: ConditionDSL): ConditionDSL { let next: ConditionDSL = { ...cond }; + if (isThresholdOverridden(next)) { + if (next.targetValue != null && Number.isFinite(next.targetValue)) { + const role = inferThresholdRoleForValue(next.targetValue, 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( diff --git a/frontend/src/utils/strategyToChartIndicators.ts b/frontend/src/utils/strategyToChartIndicators.ts index 9671110..83e9a61 100644 --- a/frontend/src/utils/strategyToChartIndicators.ts +++ b/frontend/src/utils/strategyToChartIndicators.ts @@ -18,6 +18,7 @@ import { normalizeSmaConfig } from './smaConfig'; import { normalizeIchimokuConfig } from './ichimokuConfig'; import type { EditorConditionState } from './strategyConditionSerde'; import { collectUiEvaluationTimeframes } from './strategyTimeframeSync'; +import { resolveConditionDslPeriod } from './conditionPeriods'; import { applyStrategyRefToParams, resolveConditionThresholdForChart, @@ -139,13 +140,13 @@ function walkIndicatorRefs( const c = node.condition; const dslType = c.indicatorType; const registryType = DSL_TO_REGISTRY[dslType] ?? dslType; - const key = `${registryType}:${c.period ?? ''}:${c.leftPeriod ?? ''}:${c.rightPeriod ?? ''}:${resolveConditionThresholdForChart(c) ?? ''}`; + const key = `${registryType}:${resolveConditionDslPeriod(c) ?? ''}:${c.leftPeriod ?? ''}:${c.rightPeriod ?? ''}:${resolveConditionThresholdForChart(c) ?? ''}`; if (seen.has(key)) return; seen.add(key); out.push({ dslType, registryType, - period: c.period, + period: resolveConditionDslPeriod(c), leftPeriod: c.leftPeriod, rightPeriod: c.rightPeriod, targetValue: resolveConditionThresholdForChart(c),