diff --git a/backend/src/main/java/com/goldenchart/service/IndicatorHlineResolver.java b/backend/src/main/java/com/goldenchart/service/IndicatorHlineResolver.java index 78362a4..32cafb2 100644 --- a/backend/src/main/java/com/goldenchart/service/IndicatorHlineResolver.java +++ b/backend/src/main/java/com/goldenchart/service/IndicatorHlineResolver.java @@ -20,6 +20,11 @@ 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); if (override) { if (cond.has("targetValue") && !cond.path("targetValue").isNull()) { diff --git a/frontend/src/components/strategyEditor/ComboFieldSelect.tsx b/frontend/src/components/strategyEditor/ComboFieldSelect.tsx index ff3bd92..d980ea4 100644 --- a/frontend/src/components/strategyEditor/ComboFieldSelect.tsx +++ b/frontend/src/components/strategyEditor/ComboFieldSelect.tsx @@ -18,6 +18,8 @@ interface Props { allowDecimal?: boolean; onFieldChange: (field: string) => void; onCustomNumberChange: (n: number) => void; + /** 직접입력 모드 진입 — 현재 임계값을 K_ 스냅샷으로 즉시 반영 */ + onDirectInputActivate?: () => void; customOptionLabel?: string; disabled?: boolean; } @@ -66,6 +68,7 @@ export default function ComboFieldSelect({ allowDecimal = false, onFieldChange, onCustomNumberChange, + onDirectInputActivate, customOptionLabel, disabled = false, }: Props) { @@ -94,6 +97,7 @@ export default function ComboFieldSelect({ if (raw === COMBO_FIELD_CUSTOM) { if (canCustom) { setMode('custom'); + onDirectInputActivate?.(); setDraft(String(customNumber)); } return; diff --git a/frontend/src/components/strategyEditor/ConditionNodeSettings.tsx b/frontend/src/components/strategyEditor/ConditionNodeSettings.tsx index 4819b30..d2c5e71 100644 --- a/frontend/src/components/strategyEditor/ConditionNodeSettings.tsx +++ b/frontend/src/components/strategyEditor/ConditionNodeSettings.tsx @@ -168,7 +168,7 @@ export default function ConditionNodeSettings({ min={thresholdBounds.min} max={thresholdBounds.max} allowDecimal - onChange={v => onChange(setConditionThreshold(condition, 'right', v, def))} + onChange={v => onChange(setConditionThreshold(condition, 'right', v, def, { directInput: true }))} /> diff --git a/frontend/src/utils/conditionPeriods.ts b/frontend/src/utils/conditionPeriods.ts index 1f410f2..c2c1f9a 100644 --- a/frontend/src/utils/conditionPeriods.ts +++ b/frontend/src/utils/conditionPeriods.ts @@ -256,11 +256,30 @@ export function getConditionThreshold( return parseThresholdField(field); } +export type SetConditionThresholdOptions = { + /** true: 직접입력 — K_+targetValue+thresholdOverride 유지 (프리셋 HL_* 로 접지 않음) */ + directInput?: boolean; +}; + +export function activateDirectThresholdInput( + cond: ConditionDSL, + side: 'left' | 'right', + inheritedField: string | undefined, + def: { hlThresh: Record }, +): ConditionDSL { + const value = getConditionThreshold(cond, side, inheritedField, def) + ?? 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 }); +} + export function setConditionThreshold( cond: ConditionDSL, side: 'left' | 'right', value: number, def?: { hlThresh: Record }, + options?: SetConditionThresholdOptions, ): ConditionDSL { const fieldKey = side === 'left' ? 'leftField' : 'rightField'; const current = cond[fieldKey]; @@ -283,20 +302,22 @@ export function setConditionThreshold( ? getChartReferenceThreshold(cond, side, inheritedField, def) : null; - 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 (!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 }; + } - 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 (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 f413e22..ba9972f 100644 --- a/frontend/src/utils/strategyConditionNormalize.ts +++ b/frontend/src/utils/strategyConditionNormalize.ts @@ -11,8 +11,8 @@ import { } from './conditionPeriods'; import { defaultInheritedThresholdField, - inferThresholdRoleForValue, inferThresholdSymbolFromLegacy, + inferThresholdSymbolFromLegacyExact, isThresholdFieldOrSymbol, isThresholdSymbol, THRESHOLD_HL_MID, @@ -29,7 +29,8 @@ function migrateConditionThreshold( let rightField = cond.rightField; if (rightField?.startsWith('K_')) { - rightField = inferThresholdSymbolFromLegacy(rightField, cond.indicatorType, def.hlThresh); + rightField = inferThresholdSymbolFromLegacyExact(rightField, cond.indicatorType, def.hlThresh) + ?? inferThresholdSymbolFromLegacy(rightField, cond.indicatorType, def.hlThresh); } if (!rightField || (!isThresholdSymbol(rightField) && !rightField.startsWith('K_'))) { const defaults = getDefaultConditionFields(cond.indicatorType, signalType, def); @@ -96,29 +97,13 @@ export function migrateLogicRootForEditor( return repairPriceExtremePairForest(migrated.root, migrated.orphans); } -/** 저장 직전 — 상속 모드는 숫자 스냅샷·targetValue 제거, 직접입력은 hline 역할로 등가 정규화 */ +/** 저장 직전 — 상속 모드는 숫자 스냅샷·targetValue 제거 (직접입력 K_+targetValue 는 유지) */ 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 = inferThresholdSymbolFromLegacy(next.rightField, next.indicatorType, {}); - if (role && isThresholdSymbol(role)) { - const { targetValue: _t, ...rest } = next; - next = { ...rest, rightField: role, thresholdOverride: false }; - } - } - } - if (!isThresholdOverridden(next)) { if (next.rightField?.startsWith('K_')) { - next.rightField = inferThresholdSymbolFromLegacy( + next.rightField = inferThresholdSymbolFromLegacyExact( next.rightField, next.indicatorType, {}, diff --git a/frontend/src/utils/strategyDefSync.ts b/frontend/src/utils/strategyDefSync.ts index ec33c8a..12e509a 100644 --- a/frontend/src/utils/strategyDefSync.ts +++ b/frontend/src/utils/strategyDefSync.ts @@ -21,6 +21,7 @@ import { import { defaultInheritedThresholdField, inferThresholdSymbolFromLegacy, + inferThresholdSymbolFromLegacyExact, } from './thresholdSymbols'; import type { DefType } from './strategyEditorShared'; import { isPriceExtremeBreakoutPairRoot } from './priceExtremeBreakoutPair'; @@ -69,7 +70,11 @@ function applyGlobalDefToCondition( } if (!isThresholdOverridden(cond) && next.rightField?.startsWith('K_')) { - next.rightField = inferThresholdSymbolFromLegacy( + next.rightField = inferThresholdSymbolFromLegacyExact( + next.rightField, + cond.indicatorType, + def.hlThresh, + ) ?? inferThresholdSymbolFromLegacy( next.rightField, cond.indicatorType, def.hlThresh, diff --git a/frontend/src/utils/strategyEditorShared.tsx b/frontend/src/utils/strategyEditorShared.tsx index 4d9bde2..3e1b282 100644 --- a/frontend/src/utils/strategyEditorShared.tsx +++ b/frontend/src/utils/strategyEditorShared.tsx @@ -73,6 +73,7 @@ import { } from '../utils/stochOverboughtPair'; import ComboFieldSelect from '../components/strategyEditor/ComboFieldSelect'; import { + activateDirectThresholdInput, getCompositeFieldOpts, getCompositePeriodPresetOptions, getConditionRightPeriod, @@ -1432,7 +1433,15 @@ export const CondEditor: React.FC = ({ allowDecimal={normalized.indicatorType === 'CCI'} disabled={isSlopeType || isHoldType} onFieldChange={handleRight} - onCustomNumberChange={v => onChange(setConditionThreshold(normalized, 'right', v, def))} + onDirectInputActivate={() => { + onChange(activateDirectThresholdInput( + normalized, + 'right', + inheritedThresholdField, + def, + )); + }} + onCustomNumberChange={v => onChange(setConditionThreshold(normalized, 'right', v, def, { directInput: true }))} /> {/* 조건 */} diff --git a/frontend/src/utils/strategyIndicatorSync.ts b/frontend/src/utils/strategyIndicatorSync.ts index 701bf6c..d46ebb7 100644 --- a/frontend/src/utils/strategyIndicatorSync.ts +++ b/frontend/src/utils/strategyIndicatorSync.ts @@ -16,11 +16,12 @@ export interface StrategyIndicatorRef { /** 조건 rightField·targetValue → 차트에 추가할 숫자 임계 hline (HL_* 는 DB visual 사용) */ export function resolveConditionThresholdForChart(c: ConditionDSL): number | null { - if (c.thresholdOverride === false) return null; + const rf = c.rightField ?? ''; + const isDirectK = rf.startsWith('K_'); + if (c.thresholdOverride === false && !isDirectK) return null; if (c.targetValue != null && Number.isFinite(c.targetValue)) return c.targetValue; if (c.compareValue != null && Number.isFinite(c.compareValue)) return c.compareValue; - const rf = c.rightField ?? ''; - if (rf.startsWith('K_')) { + if (isDirectK) { const n = parseFloat(rf.slice(2)); return Number.isFinite(n) ? n : null; } diff --git a/frontend/src/utils/thresholdSymbols.ts b/frontend/src/utils/thresholdSymbols.ts index c9234d2..3c929e2 100644 --- a/frontend/src/utils/thresholdSymbols.ts +++ b/frontend/src/utils/thresholdSymbols.ts @@ -69,6 +69,18 @@ export function resolveThresholdFromDef( return null; } +/** 레거시 K_ 값 → hline 역할 심볼 (정확 일치만 — 저장·마이그레이션용) */ +export function inferThresholdSymbolFromLegacyExact( + field: string | undefined, + indicatorType: string, + hlThresh: Record, +): ThresholdHlineRole | undefined { + if (!field?.startsWith('K_')) return undefined; + const val = parseFloat(field.slice(2)); + if (!Number.isFinite(val)) return undefined; + return inferThresholdRoleForValue(val, indicatorType, hlThresh) ?? undefined; +} + /** 레거시 K_ 값 → hline 역할 심볼 (정확 일치 우선, 없으면 가장 가까운 역할) */ export function inferThresholdSymbolFromLegacy( field: string | undefined, diff --git a/frontend/src/utils/virtualStrategyConditions.ts b/frontend/src/utils/virtualStrategyConditions.ts index 7565ca2..da8f27c 100644 --- a/frontend/src/utils/virtualStrategyConditions.ts +++ b/frontend/src/utils/virtualStrategyConditions.ts @@ -8,6 +8,7 @@ import { formatIndicatorDisplayLabel } from './indicatorRegistry'; import { coerceFiniteNumber, safeToFixed } from './safeFormat'; import { asLogicNode } from './strategyHydrate'; import { normalizeStartCandleType } from './strategyStartNodes'; +import { parseThresholdField } from './conditionPeriods'; export { coerceFiniteNumber } from './safeFormat'; @@ -65,7 +66,12 @@ function walk( seen.add(rowId); const condLabel = CONDITION_LABEL[c.conditionType] ?? c.conditionType; - const target = coerceFiniteNumber(c.targetValue ?? c.compareValue ?? null); + const target = coerceFiniteNumber( + c.targetValue + ?? c.compareValue + ?? parseThresholdField(c.rightField) + ?? null, + ); const plotKey = c.leftField && c.leftField !== 'none' ? c.leftField : c.indicatorType; out.push({