From 5bbc1053269e2a84a152d55add24f2649e6416f8 Mon Sep 17 00:00:00 2001 From: Macbook Date: Thu, 18 Jun 2026 17:47:49 +0900 Subject: [PATCH] =?UTF-8?q?=EC=A0=84=EB=9E=B5=ED=8E=B8=EC=A7=91=EA=B8=B0?= =?UTF-8?q?=20=EC=A1=B0=EA=B1=B4=20=EB=AA=A9=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/IndicatorSettingsListRow.tsx | 35 ++++- .../src/components/StrategyEvaluationPage.tsx | 23 +++ .../StrategyEvaluationChart.tsx | 94 ++++++++---- .../StrategyEvaluationChartSettingsTab.tsx | 19 ++- frontend/src/utils/conditionPeriods.ts | 106 +++++++++++++- .../src/utils/strategyDescriptionNarrative.ts | 22 ++- frontend/src/utils/strategyEditorShared.tsx | 135 ++++++++++-------- 7 files changed, 335 insertions(+), 99 deletions(-) diff --git a/frontend/src/components/IndicatorSettingsListRow.tsx b/frontend/src/components/IndicatorSettingsListRow.tsx index 92b978b..7a42e8c 100644 --- a/frontend/src/components/IndicatorSettingsListRow.tsx +++ b/frontend/src/components/IndicatorSettingsListRow.tsx @@ -26,6 +26,11 @@ export interface IndicatorSettingsListRowProps { hideChartToggle?: boolean; /** 출력·표시(가격축 라벨·타임프레임) 숨김 */ hideOutputFooter?: boolean; + /** 오버레이 지표(SMA·볼린저·일목) 전체 on/off — 오버레이 뱃지·기본값 버튼 대신 표시 */ + overlayMasterToggle?: { + visible: boolean; + onChange: (visible: boolean) => void; + }; } const IndicatorSettingsListRow: React.FC = ({ @@ -47,6 +52,7 @@ const IndicatorSettingsListRow: React.FC = ({ onDrop, hideChartToggle = false, hideOutputFooter = false, + overlayMasterToggle, }) => { const labels = getIndicatorListLabels(type); const def = getIndicatorDef(type); @@ -92,9 +98,9 @@ const IndicatorSettingsListRow: React.FC = ({ {labels.en}
- {def?.overlay && 오버레이} + {!overlayMasterToggle && def?.overlay && 오버레이} {def?.returnsMarkers && 마커} - {!hideChartToggle && ( + {!overlayMasterToggle && !hideChartToggle && ( enabled ? ( 차트 적용 ) : ( @@ -102,21 +108,36 @@ const IndicatorSettingsListRow: React.FC = ({ ) )}
- {!hideChartToggle && ( + {overlayMasterToggle ? ( + ) : ( + !hideChartToggle && ( + + ) )} - {variant === 'settings' && onRowDefaults && ( + {variant === 'settings' && onRowDefaults && !overlayMasterToggle && ( diff --git a/frontend/src/components/StrategyEvaluationPage.tsx b/frontend/src/components/StrategyEvaluationPage.tsx index 6fc76d7..2046bea 100644 --- a/frontend/src/components/StrategyEvaluationPage.tsx +++ b/frontend/src/components/StrategyEvaluationPage.tsx @@ -55,6 +55,11 @@ import StrategyEvaluationChartSettingsTab from './strategyEvaluation/StrategyEva import PaletteDragOverlay from './strategyEditor/PaletteDragOverlay'; import { needsPointerPaletteDrag } from '../utils/paletteDragSession'; import { useStrategyEvaluationEditor } from '../hooks/useStrategyEvaluationEditor'; +import { + DEFAULT_CHART_OVERLAY_VISIBILITY, + type ChartOverlayToggleKey, + type ChartOverlayVisibility, +} from '../utils/chartOverlayVisibility'; import { buildStrategyEvaluationAiVerifyContext, requestStrategyEvaluationAiVerify, @@ -112,6 +117,9 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) { const [reportModel, setReportModel] = useState(null); const [reportLoading, setReportLoading] = useState(false); const [rightTab, setRightTab] = useState('signal'); + const [overlayVisibility, setOverlayVisibility] = useState( + () => ({ ...DEFAULT_CHART_OVERLAY_VISIBILITY }), + ); const [aiVerifyUnread, setAiVerifyUnread] = useState(false); const [aiVerifyRunning, setAiVerifyRunning] = useState(false); const [aiVerifyError, setAiVerifyError] = useState(null); @@ -143,6 +151,10 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) { [baseGetParams, appliedIndicatorParams], ); + const handleSetOverlayVisibility = useCallback((key: ChartOverlayToggleKey, visible: boolean) => { + setOverlayVisibility(prev => ({ ...prev, [key]: visible })); + }, []); + const handleStrategyEditorSaved = useCallback((saved: StrategyDto, meta?: { created?: boolean }) => { const hydrated = hydrateStrategyDto(saved); setSelectedStrategy(hydrated); @@ -344,6 +356,13 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) { const selectedBarTimeSec = bars[selectedBarIndex]?.time ?? null; + useEffect(() => { + if (bars.length === 0) return; + if (selectedBarIndex >= bars.length) { + setSelectedBarIndex(bars.length - 1); + } + }, [bars.length, selectedBarIndex]); + const barSignalHighlight = useMemo( () => resolveBarSignalHighlight(backtestSignals, selectedBarTimeSec, selectedBarIndex), [backtestSignals, selectedBarTimeSec, selectedBarIndex], @@ -815,6 +834,8 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) { marketLoading={marketFeed.loading} refreshMarketTickers={marketFeed.refreshAllTickers} onMarketSearchOpenChange={setMarketSearchOpen} + overlayVisibility={overlayVisibility} + onSetOverlayVisibility={handleSetOverlayVisibility} /> )} diff --git a/frontend/src/components/strategyEvaluation/StrategyEvaluationChart.tsx b/frontend/src/components/strategyEvaluation/StrategyEvaluationChart.tsx index 3dcf78d..1d869de 100644 --- a/frontend/src/components/strategyEvaluation/StrategyEvaluationChart.tsx +++ b/frontend/src/components/strategyEvaluation/StrategyEvaluationChart.tsx @@ -36,6 +36,7 @@ import { DEFAULT_CHART_OVERLAY_VISIBILITY, effectiveConfigForOverlay, listChartOverlayToggleItems, + CHART_OVERLAY_TOGGLE_ORDER, type ChartOverlayToggleKey, type ChartOverlayVisibility, } from '../../utils/chartOverlayVisibility'; @@ -49,6 +50,7 @@ import { parseBacktestRunTimeframeChoice, type BacktestRunTimeframeChoice, } from '../../utils/backtestRunTimeframe'; +import { timeframeBarSeconds } from '../../utils/backtestUiUtils'; interface Props { market: string; @@ -100,6 +102,9 @@ interface Props { marketLoading?: boolean; refreshMarketTickers?: () => Promise; onMarketSearchOpenChange?: (open: boolean) => void; + /** 캔들 오버레이(SMA·볼린저·일목·캔들) 표시 — 미전달 시 내부 state */ + overlayVisibility?: ChartOverlayVisibility; + onSetOverlayVisibility?: (key: ChartOverlayToggleKey, visible: boolean) => void; } const isOverlayType = (type: string) => getIndicatorDef(type)?.overlay === true; @@ -144,6 +149,8 @@ const StrategyEvaluationChart: React.FC = ({ marketLoading, refreshMarketTickers, onMarketSearchOpenChange, + overlayVisibility: overlayVisibilityProp, + onSetOverlayVisibility, }) => { const { getParams: baseGetParams, getVisualConfig: baseGetVisual, settingsRevision } = useIndicatorSettings(); const getParams = getParamsOverride ?? baseGetParams; @@ -163,13 +170,15 @@ const StrategyEvaluationChart: React.FC = ({ const [magnifierActive, setMagnifierActive] = useState(false); const marketBtnRef = useRef(null); const managerRef = useRef(null); - const [overlayVisibility, setOverlayVisibility] = useState( + const [internalOverlayVisibility, setInternalOverlayVisibility] = useState( () => ({ ...DEFAULT_CHART_OVERLAY_VISIBILITY }), ); + const overlayVisibility = overlayVisibilityProp ?? internalOverlayVisibility; const overlayVisibilityRef = useRef(overlayVisibility); useEffect(() => { overlayVisibilityRef.current = overlayVisibility; }, [overlayVisibility]); + const backtestGenRef = useRef(0); const evaluationBarCountRef = useRef(BACKTEST_DISPLAY_BAR_COUNT); const signalsRef = useRef(signals); @@ -196,23 +205,48 @@ const StrategyEvaluationChart: React.FC = ({ [overlayVisibility], ); + const applyOverlayToManager = useCallback(( + key: ChartOverlayToggleKey, + fullVisibility: ChartOverlayVisibility, + ) => { + const mgr = managerRef.current; + if (!mgr) return; + mgr.setChartOverlayVisibility(fullVisibility); + for (const info of mgr.getIndicatorPaneInfo()) { + if (chartOverlayKeyForType(info.type) !== key) continue; + const reactInd = indicatorsRef.current.find(i => i.id === info.id); + mgr.applyIndicatorStyle(reactInd + ? { ...info.config, ...reactInd, id: info.id, type: info.type } + : info.config); + } + }, []); + const handleToggleCandleOverlay = useCallback((key: ChartOverlayToggleKey) => { - setOverlayVisibility(prev => { - const next = { ...prev, [key]: !prev[key] }; - const mgr = managerRef.current; - mgr?.setChartOverlayGroupVisible(key, next[key]); - if (mgr) { - for (const info of mgr.getIndicatorPaneInfo()) { - if (chartOverlayKeyForType(info.type) !== key) continue; - const reactInd = indicatorsRef.current.find(i => i.id === info.id); - mgr.applyIndicatorStyle(reactInd - ? { ...info.config, ...reactInd, id: info.id, type: info.type } - : info.config); + const nextVisible = !overlayVisibilityRef.current[key]; + const next = { ...overlayVisibilityRef.current, [key]: nextVisible }; + if (onSetOverlayVisibility) { + onSetOverlayVisibility(key, nextVisible); + } else { + setInternalOverlayVisibility(next); + applyOverlayToManager(key, next); + } + }, [onSetOverlayVisibility, applyOverlayToManager]); + + const prevOverlayVisibilityRef = useRef(overlayVisibility); + useEffect(() => { + const prev = prevOverlayVisibilityRef.current; + prevOverlayVisibilityRef.current = overlayVisibility; + const mgr = managerRef.current; + if (!mgr) return; + mgr.setChartOverlayVisibility(overlayVisibility); + if (onSetOverlayVisibility) { + for (const key of CHART_OVERLAY_TOGGLE_ORDER) { + if (prev[key] !== overlayVisibility[key]) { + applyOverlayToManager(key, overlayVisibility); } } - return next; - }); - }, []); + } + }, [overlayVisibility, onSetOverlayVisibility, applyOverlayToManager]); const [customOverlaySelection, setCustomOverlaySelection] = useState( () => createDefaultChartCustomOverlaySelection(), @@ -435,12 +469,25 @@ const StrategyEvaluationChart: React.FC = ({ }, [market, timeframe, strategy?.id, paramsRevision, onBarsLoaded, onSelectedBarIndexChange]); const scrollToBar = useCallback((barIdx: number) => { - const mgr = chartMgr; - if (!mgr || barIdx < 0 || barIdx >= bars.length) return; + const mgr = managerRef.current; + if (!mgr?.hasMainSeries() || barIdx < 0 || barIdx >= bars.length) return; const bar = bars[barIdx]; - const barSec = Math.max(60, 180); - mgr.applyVisibleTimeRange(bar.time - barSec * 24, bar.time + barSec * 8); - }, [chartMgr, bars]); + const barSec = timeframeBarSeconds(timeframe); + const pad = Math.max(barSec * 48, 3600); + mgr.applyVisibleTimeRange(bar.time - pad, bar.time + Math.max(barSec * 8, 600)); + }, [bars, timeframe]); + + const prevSelectedBarIndexRef = useRef(selectedBarIndex); + useEffect(() => { + prevSelectedBarIndexRef.current = -1; + }, [market, timeframe, strategy?.id, paramsRevision]); + + useEffect(() => { + if (bars.length === 0) return; + if (prevSelectedBarIndexRef.current === selectedBarIndex) return; + prevSelectedBarIndexRef.current = selectedBarIndex; + scrollToBar(selectedBarIndex); + }, [selectedBarIndex, bars.length, scrollToBar]); const onManagerReady = useCallback((mgr: ChartManager) => { managerRef.current = mgr; @@ -467,15 +514,14 @@ const StrategyEvaluationChart: React.FC = ({ const stepBar = useCallback((delta: number) => { if (bars.length === 0) return; const next = Math.min(bars.length - 1, Math.max(0, selectedBarIndex + delta)); + if (next === selectedBarIndex) return; onSelectedBarIndexChange(next); }, [bars.length, selectedBarIndex, onSelectedBarIndexChange]); const goLatest = useCallback(() => { if (bars.length === 0) return; - const lastIdx = bars.length - 1; - onSelectedBarIndexChange(lastIdx); - scrollToBar(lastIdx); - }, [bars.length, onSelectedBarIndexChange, scrollToBar]); + onSelectedBarIndexChange(bars.length - 1); + }, [bars.length, onSelectedBarIndexChange]); useEffect(() => { const mgr = chartMgr; diff --git a/frontend/src/components/strategyEvaluation/StrategyEvaluationChartSettingsTab.tsx b/frontend/src/components/strategyEvaluation/StrategyEvaluationChartSettingsTab.tsx index 542b367..b060f7c 100644 --- a/frontend/src/components/strategyEvaluation/StrategyEvaluationChartSettingsTab.tsx +++ b/frontend/src/components/strategyEvaluation/StrategyEvaluationChartSettingsTab.tsx @@ -21,9 +21,15 @@ import { import type { IchimokuCloudColors } from '../../utils/ichimokuConfig'; import ChartPriceAxisLabelSettings from './ChartPriceAxisLabelSettings'; import { useAppSettings, resolveAppDefaults } from '../../hooks/useAppSettings'; +import { + chartOverlayKeyForType, + type ChartOverlayToggleKey, + type ChartOverlayVisibility, +} from '../../utils/chartOverlayVisibility'; const EVAL_CHART_OVERLAY_TYPES = ['SMA', 'BollingerBands', 'IchimokuCloud'] as const; const EVAL_CHART_OVERLAY_SET = new Set(EVAL_CHART_OVERLAY_TYPES); +const EVAL_OVERLAY_MASTER_TYPES = new Set(EVAL_CHART_OVERLAY_TYPES); function buildEvalChartSettingsTypes(strategy: StrategyDto | null): string[] { const strategyTypes = strategy ? collectStrategyRegistryTypes(strategy) : []; @@ -63,6 +69,9 @@ interface Props { settingsRevision?: number; /** DB 저장 후 세션 오버라이드 제거·차트 갱신 */ onPersisted?: (type: string) => void; + /** 캔들 오버레이(SMA·볼린저·일목) 표시 on/off */ + overlayVisibility: ChartOverlayVisibility; + onSetOverlayVisibility: (key: ChartOverlayToggleKey, visible: boolean) => void; } const StrategyEvaluationChartSettingsTab: React.FC = ({ @@ -74,6 +83,8 @@ const StrategyEvaluationChartSettingsTab: React.FC = ({ saveVisual, settingsRevision = 0, onPersisted, + overlayVisibility, + onSetOverlayVisibility, }) => { const indicatorTypes = useMemo( () => buildEvalChartSettingsTypes(strategy), @@ -153,6 +164,8 @@ const StrategyEvaluationChartSettingsTab: React.FC = ({ const renderRows = (types: string[]) => types.map(type => { const cfg = configs[type]; if (!cfg) return null; + const overlayKey = chartOverlayKeyForType(type); + const isOverlayMaster = EVAL_OVERLAY_MASTER_TYPES.has(type) && overlayKey != null; return ( = ({ onToggleExpand={() => toggleExpand(type)} onToggleEnabled={() => {}} onChange={schedulePersist} - onRowDefaults={() => handleRowDefaults(type)} + onRowDefaults={isOverlayMaster ? undefined : () => handleRowDefaults(type)} + overlayMasterToggle={isOverlayMaster ? { + visible: overlayVisibility[overlayKey!], + onChange: visible => onSetOverlayVisibility(overlayKey!, visible), + } : undefined} /> ); }); diff --git a/frontend/src/utils/conditionPeriods.ts b/frontend/src/utils/conditionPeriods.ts index 0dbc826..b4e7b7a 100644 --- a/frontend/src/utils/conditionPeriods.ts +++ b/frontend/src/utils/conditionPeriods.ts @@ -12,6 +12,7 @@ import { syncCompositeFields, type CompositePeriodDef, } from './compositeIndicators'; +import { getStrategyIndicatorDisplayName } from './strategyPaletteStorage'; import { isPriceExtremeIndicator, migratePriceExtremeCondition, @@ -55,6 +56,7 @@ export const VALUE_FIELD_PREFIX: Record = { WILLIAMS_R: 'WILLIAMS_R_VALUE', TRIX: 'TRIX_VALUE', VR: 'VR_VALUE', + BWI: 'BWI_VALUE', PSYCHOLOGICAL: 'PSY_VALUE', NEW_PSYCHOLOGICAL: 'NEW_PSY_VALUE', INVEST_PSYCHOLOGICAL: 'INVEST_PSY_VALUE', @@ -447,14 +449,104 @@ export function hasNodeSettings(cond: ConditionDSL): boolean { || !!cond.composite; } -const COMMON_PERIODS = [5, 7, 9, 10, 12, 13, 14, 20, 21, 26, 28, 50, 60, 120]; +export const COMMON_PERIODS = [5, 7, 9, 10, 12, 13, 14, 20, 21, 26, 28, 50, 60, 120]; + +/** 지표별 추가 기간 프리셋 — HWP·UI 스펙 (예: CCI 100·200일) */ +const INDICATOR_EXTRA_PERIODS: Record = { + CCI: [100, 200], +}; + +function mergePeriodPresets(...groups: number[][]): number[] { + return [...new Set(groups.flat())].sort((a, b) => a - b); +} + +/** 단일·복합 공통 — 지표별 기간 프리셋 */ +export function getIndicatorPeriodPresets( + indicatorType: string, + def: IndicatorPeriodDef, + extra: number[] = [], +): number[] { + const base = getDefaultIndicatorPeriod(indicatorType, def); + const indicatorExtras = INDICATOR_EXTRA_PERIODS[indicatorType] ?? []; + if (isPriceExtremeIndicator(indicatorType)) { + return mergePeriodPresets([base, 5, 9, 10, 20, 55], COMMON_PERIODS, indicatorExtras, extra); + } + return mergePeriodPresets([base], COMMON_PERIODS, indicatorExtras, extra); +} + +/** 단일 보조지표 값 라인 라벨 — 예: CCI 1 라인(9일) */ +export function singleIndicatorLineLabel(indicatorType: string, period: number): string { + const name = getStrategyIndicatorDisplayName(indicatorType); + return `${name} 1 라인(${period}일)`; +} + +/** 단일 보조지표 조건대상1 값·기간 라벨 — 지표별 HWP 표기 */ +export function singleIndicatorValuePeriodLabel(indicatorType: string, period: number): string { + switch (indicatorType) { + case 'CCI': + return `CCI 기간 ${period}`; + case 'RSI': + return `RSI 기간 ${period}`; + case 'ADX': + return `ADX 기간 ${period}`; + case 'WILLIAMS_R': + return `williams%R 기간${period}`; + case 'TRIX': + return `TRIX ${period}`; + default: + return singleIndicatorLineLabel(indicatorType, period); + } +} + +/** HWP — 조건대상1 기본 값 필드 (차트 설정 기간 + 직접입력) */ +export function buildHwpValueFieldOpt( + indicatorType: string, + def: IndicatorPeriodDef, + cond?: ConditionDSL, +): { value: string; label: string } | null { + const prefix = VALUE_FIELD_PREFIX[indicatorType]; + if (!prefix) return null; + const mock = cond ? { ...cond, indicatorType } : { indicatorType } as ConditionDSL; + const period = getConditionValuePeriod(mock, def); + return { value: prefix, label: singleIndicatorValuePeriodLabel(indicatorType, period) }; +} + +/** 단일 보조지표 조건대상1 — 기간별 값 라인 드롭다운 (직접입력은 ComboFieldSelect) */ +export function buildSingleIndicatorPeriodOpts( + indicatorType: string, + def: IndicatorPeriodDef, + cond?: ConditionDSL, +): { value: string; label: string }[] { + const prefix = VALUE_FIELD_PREFIX[indicatorType]; + if (!prefix) return []; + const mock = cond ? { ...cond, indicatorType } : { indicatorType } as ConditionDSL; + const current = getConditionValuePeriod(mock, def); + const periods = [...new Set([...getPeriodPresetOptions(indicatorType, def), current])].sort((a, b) => a - b); + return periods.map(p => ({ + value: `${prefix}_${p}`, + label: singleIndicatorLineLabel(indicatorType, p), + })); +} + +/** RSI_VALUE → RSI_VALUE_9 — 옵션·라벨 조회용 */ +export function resolveStoredValueField( + indicatorType: string, + field: string | undefined, + def: IndicatorPeriodDef, + cond?: ConditionDSL, +): string | undefined { + if (!field) return field; + const prefix = VALUE_FIELD_PREFIX[indicatorType]; + if (prefix && field === prefix) { + const mock = cond ? { ...cond, indicatorType, leftField: field } : { indicatorType, leftField: field } as ConditionDSL; + const p = getConditionValuePeriod(mock, def); + return `${prefix}_${p}`; + } + return field; +} export function getPeriodPresetOptions(indicatorType: string, def: IndicatorPeriodDef): number[] { - const base = getDefaultIndicatorPeriod(indicatorType, def); - if (isPriceExtremeIndicator(indicatorType)) { - return [...new Set([base, 5, 9, 10, 20, 55, ...COMMON_PERIODS])].sort((a, b) => a - b); - } - return [...new Set([base, ...COMMON_PERIODS])].sort((a, b) => a - b); + return getIndicatorPeriodPresets(indicatorType, def); } export function getPeriodSettingsLabel(cond: ConditionDSL): string | null { @@ -472,7 +564,7 @@ export function getCompositePeriodPresetOptions( ): number[] { const { short, long } = getCompositeDefaultPeriods(indicatorType, def as CompositePeriodDef); const base = side === 'left' ? short : long; - return [...new Set([base, short, long, ...COMMON_PERIODS])].sort((a, b) => a - b); + return getIndicatorPeriodPresets(indicatorType, def, [base, short, long]); } /** 복합지표 조건대상1/2 드롭다운 — CCI 1 라인(9일) 형식 */ diff --git a/frontend/src/utils/strategyDescriptionNarrative.ts b/frontend/src/utils/strategyDescriptionNarrative.ts index 5a5b99a..b1ae836 100644 --- a/frontend/src/utils/strategyDescriptionNarrative.ts +++ b/frontend/src/utils/strategyDescriptionNarrative.ts @@ -24,7 +24,11 @@ import { getCompositeLeftCandleType, getCompositeRightCandleType, ensureDirectThresholdSnapshot, + getConditionValuePeriod, parseThresholdField, + resolveStoredValueField, + singleIndicatorLineLabel, + singleIndicatorValuePeriodLabel, } from './conditionPeriods'; import { formatStrategyCandleLabel } from './strategyStartNodes'; import { formatStartCandleLabel } from './strategyStartNodes'; @@ -83,9 +87,23 @@ function fieldLabel( if (hit) return hit.label; } const opts = getFieldOpts(indicatorType, signalType, def, cond); - const resolved = resolveFieldOptionValue(indicatorType, field); - const hit = opts.find(o => o.value === resolved); + const stored = resolveStoredValueField(indicatorType, field, def, cond); + const hit = opts.find(o => o.value === stored) ?? opts.find(o => o.value === field); if (hit && hit.label !== '선택안함') return hit.label; + const resolved = resolveFieldOptionValue(indicatorType, field); + const hitLegacy = opts.find(o => o.value === resolved); + if (hitLegacy && hitLegacy.label !== '선택안함') return hitLegacy.label; + const m = stored?.match(/_(\d+)$/); + if (m) return singleIndicatorValuePeriodLabel(indicatorType, parseInt(m[1], 10)); + if (cond) { + const base = { + RSI: 'RSI_VALUE', CCI: 'CCI_VALUE', ADX: 'ADX_VALUE', + WILLIAMS_R: 'WILLIAMS_R_VALUE', TRIX: 'TRIX_VALUE', + }[indicatorType]; + if (base && (stored === base || field === base)) { + return singleIndicatorValuePeriodLabel(indicatorType, getConditionValuePeriod(cond, def)); + } + } const thresh = field ? parseThresholdField(field) : null; if (thresh != null) return `임계값 ${thresh}`; return field ?? indicatorType; diff --git a/frontend/src/utils/strategyEditorShared.tsx b/frontend/src/utils/strategyEditorShared.tsx index 8b4dac4..4205bfc 100644 --- a/frontend/src/utils/strategyEditorShared.tsx +++ b/frontend/src/utils/strategyEditorShared.tsx @@ -74,7 +74,10 @@ import { import ComboFieldSelect from '../components/strategyEditor/ComboFieldSelect'; import { activateDirectThresholdInput, + buildHwpValueFieldOpt, + buildSingleIndicatorPeriodOpts, ensureDirectThresholdSnapshot, + VALUE_FIELD_PREFIX, getCompositeFieldOpts, getCompositePeriodPresetOptions, getConditionRightPeriod, @@ -91,6 +94,9 @@ import { parseThresholdField, resolveFieldCustomKind, resolveRightFieldCustomKind, + resolveStoredValueField, + singleIndicatorLineLabel, + singleIndicatorValuePeriodLabel, getCompositeLeftCandleType, getCompositeRightCandleType, setCompositeLeftCandleType, @@ -491,37 +497,35 @@ function buildFieldOptsForSlot( switch (ind) { case 'MACD': { const { mid } = th({ mid: 0 }); + const signalLabel = `신호선 ${DEF.macdSignal}`; if (slot === 'left') { return [ - { value: 'MACD_LINE', label: 'MACD 선' }, - { value: 'SIGNAL_LINE', label: `신호선 ${DEF.macdSignal}` }, - { value: THRESHOLD_HL_MID, label: `중앙선(${mid})` }, + { value: 'MACD_LINE', label: 'MACD선' }, + { value: 'SIGNAL_LINE', label: signalLabel }, ]; } return [ - { value: 'SIGNAL_LINE', label: `신호선 ${DEF.macdSignal}` }, + { value: 'SIGNAL_LINE', label: signalLabel }, NONE, { value: THRESHOLD_HL_MID, label: `중앙선(${mid})` }, ]; } case 'RSI': { - const rsiP = cond ? getConditionValuePeriod({ ...cond, indicatorType: 'RSI' }, DEF) : DEF.rsiPeriod; + const valueOpt = buildHwpValueFieldOpt('RSI', DEF, cond)!; + const signalLabel = `신호선 ${DEF.rsiSignal}`; const thresholds = buildThresholdOpts(th({ over: 70, mid: 50, under: 30 }), overTerm, underTerm); if (slot === 'left') { - return [ - { value: 'RSI_VALUE', label: `RSI 기간 ${rsiP}` }, - { value: 'RSI_SIGNAL', label: `신호선 ${DEF.rsiSignal}` }, - ]; + return [valueOpt, { value: 'RSI_SIGNAL', label: signalLabel }]; } return [ - { value: 'RSI_SIGNAL', label: `신호선 ${DEF.rsiSignal}` }, + { value: 'RSI_SIGNAL', label: signalLabel }, NONE, ...thresholds, ]; } case 'STOCHASTIC': { const thresholds = buildThresholdOpts(th({ over: 80, mid: 50, under: 20 }), overTerm, underTerm); - const kLabel = `Stochastic 기간${DEF.stochK}일 %K${DEF.stochSmooth}일`; + const kLabel = `Stochastic 기간${DEF.stochK}일 %K${DEF.stochSmooth}`; const dLabel = `Stochastic %D${DEF.stochD}`; if (slot === 'left') { return [ @@ -537,38 +541,40 @@ function buildFieldOptsForSlot( } case 'CCI': { const cciP = cond ? getConditionValuePeriod({ ...cond, indicatorType: 'CCI' }, DEF) : DEF.cciPeriod; + const signalLabel = `신호선 ${DEF.cciSignal}`; const thresholds = buildThresholdOpts(th({ over: 100, mid: 0, under: -100 }), overTerm, underTerm); if (slot === 'left') { return [ { value: 'CCI_VALUE', label: `CCI 기간 ${cciP}` }, - { value: 'CCI_SIGNAL', label: `신호선 ${DEF.cciSignal}` }, + { value: 'CCI_SIGNAL', label: signalLabel }, ]; } return [ - { value: 'CCI_SIGNAL', label: `신호선 ${DEF.cciSignal}` }, + { value: 'CCI_SIGNAL', label: signalLabel }, NONE, ...thresholds, ]; } case 'ADX': { - const adxP = cond ? getConditionValuePeriod({ ...cond, indicatorType: 'ADX' }, DEF) : DEF.adxPeriod; + const valueOpt = buildHwpValueFieldOpt('ADX', DEF, cond)!; const thresholds = buildThresholdOpts(th({ over: 40, mid: 25, under: 20 }), overTerm, underTerm); if (slot === 'left') { - return [{ value: 'ADX_VALUE', label: `ADX 기간 ${adxP}` }]; + return [valueOpt]; } return [NONE, ...thresholds]; } case 'TRIX': { - const trixP = cond ? getConditionValuePeriod({ ...cond, indicatorType: 'TRIX' }, DEF) : DEF.trixPeriod; + const valueOpt = buildHwpValueFieldOpt('TRIX', DEF, cond)!; + const trmaLabel = `TRMA ${DEF.trixSignal}`; const { mid } = th({ mid: 0 }); if (slot === 'left') { return [ - { value: 'TRIX_VALUE', label: `TRIX ${trixP}` }, - { value: 'TRIX_SIGNAL', label: `TRMA ${DEF.trixSignal}` }, + valueOpt, + { value: 'TRIX_SIGNAL', label: trmaLabel }, ]; } return [ - { value: 'TRIX_SIGNAL', label: `TRMA ${DEF.trixSignal}` }, + { value: 'TRIX_SIGNAL', label: trmaLabel }, NONE, { value: THRESHOLD_HL_MID, label: `중앙선(${mid})` }, ]; @@ -579,14 +585,14 @@ function buildFieldOptsForSlot( const minusLabel = `-DI 기간${DEF.dmiPeriod}일`; if (slot === 'left') { return [ - { value: 'PDI', label: plusLabel }, { value: 'MDI', label: minusLabel }, + { value: 'PDI', label: plusLabel }, ]; } return [ + NONE, { value: 'MDI', label: minusLabel }, { value: 'PDI', label: plusLabel }, - NONE, ...thresholds, ]; } @@ -602,10 +608,10 @@ function buildFieldOptsForSlot( NONE, ]; case 'WILLIAMS_R': { - const wrP = cond ? getConditionValuePeriod({ ...cond, indicatorType: 'WILLIAMS_R' }, DEF) : DEF.williamsR; + const valueOpt = buildHwpValueFieldOpt('WILLIAMS_R', DEF, cond)!; const thresholds = buildThresholdOpts(th({ over: -20, mid: -50, under: -80 }), overTerm, underTerm); if (slot === 'left') { - return [{ value: 'WILLIAMS_R_VALUE', label: `williams%R 기간${wrP}` }]; + return [valueOpt]; } return [NONE, ...thresholds]; } @@ -648,47 +654,42 @@ function buildFieldOptsForSlot( return slot === 'right' ? [NONE, ...lines] : lines; } case 'PSYCHOLOGICAL': { - const psyP = cond - ? getConditionValuePeriod({ ...cond, indicatorType: ind }, DEF) - : DEF.psyPeriod; + const periodOpts = buildSingleIndicatorPeriodOpts('PSYCHOLOGICAL', DEF, cond); const thresholds = buildThresholdOpts(th({ over: 75, mid: 50, under: 25 }), overTerm, underTerm); if (slot === 'left') { - return [{ value: 'PSY_VALUE', label: `심리도 기간 ${psyP}` }]; + return periodOpts; } return [NONE, ...thresholds]; } case 'NEW_PSYCHOLOGICAL': { - const nPsyP = cond - ? getConditionValuePeriod({ ...cond, indicatorType: ind }, DEF) - : DEF.newPsy; + const periodOpts = buildSingleIndicatorPeriodOpts('NEW_PSYCHOLOGICAL', DEF, cond); const thresholds = buildThresholdOpts(th({ over: 50, mid: 0, under: -50 }), overTerm, underTerm); if (slot === 'left') { - return [{ value: 'NEW_PSY_VALUE', label: `신심리도 기간 ${nPsyP}` }]; + return periodOpts; } return [NONE, ...thresholds]; } case 'INVEST_PSYCHOLOGICAL': { - const ipsyP = cond - ? getConditionValuePeriod({ ...cond, indicatorType: 'INVEST_PSYCHOLOGICAL' }, DEF) - : DEF.investPsy; + const periodOpts = buildSingleIndicatorPeriodOpts('INVEST_PSYCHOLOGICAL', DEF, cond); const thresholds = buildThresholdOpts(th({ over: 75, mid: 50, under: 25 }), overTerm, underTerm); if (slot === 'left') { - return [{ value: 'INVEST_PSY_VALUE', label: `투자심리도 기간 ${ipsyP}` }]; + return periodOpts; } return [NONE, ...thresholds]; } case 'BWI': { + const periodOpts = buildSingleIndicatorPeriodOpts('BWI', DEF, cond); const thresholds = buildThresholdOpts(th({ over: 80, mid: 50, under: 20 }), overTerm, underTerm); if (slot === 'left') { - return [{ value: 'BWI_VALUE', label: `BWI 기간 ${DEF.bwiPeriod}` }]; + return periodOpts; } return [NONE, ...thresholds]; } case 'VR': { - const vrP = cond ? getConditionValuePeriod({ ...cond, indicatorType: 'VR' }, DEF) : DEF.vrPeriod; + const periodOpts = buildSingleIndicatorPeriodOpts('VR', DEF, cond); const thresholds = buildThresholdOpts(th({ over: 200, mid: 100, under: 50 }), overTerm, underTerm); if (slot === 'left') { - return [{ value: 'VR_VALUE', label: `VR 기간 ${vrP}` }]; + return periodOpts; } return [NONE, ...thresholds]; } @@ -941,13 +942,25 @@ export const nodeToText = (node: LogicNode, DEF: DefType = DEF_DEFAULTS): string return `${indName} - ${L} ${C} ${R}${tf}`; } const defaults = getDefaultConditionFields(c.indicatorType, 'buy', DEF); - const lv = resolveFieldOptionValue(c.indicatorType, c.leftField); + const leftStored = resolveStoredValueField(c.indicatorType, c.leftField, DEF, c); + const lv = resolveFieldOptionValue(c.indicatorType, leftStored); const rvRaw = c.rightField; const rv = isThresholdOverridden(c) ? resolveFieldOptionValue(c.indicatorType, rvRaw) : (resolveInheritedThresholdField(rvRaw, c.indicatorType, DEF.hlThresh) ?? resolveFieldOptionValue(c.indicatorType, rvRaw)); - const L = opts.find(o => o.value === lv)?.label ?? c.leftField ?? indName; + const L = opts.find(o => o.value === leftStored)?.label + ?? opts.find(o => o.value === lv)?.label + ?? (() => { + const m = leftStored?.match(/_(\d+)$/); + if (m) return singleIndicatorValuePeriodLabel(c.indicatorType, parseInt(m[1], 10)); + const base = VALUE_FIELD_PREFIX[c.indicatorType]; + if (base && (leftStored === base || lv === base)) { + return singleIndicatorValuePeriodLabel(c.indicatorType, getConditionValuePeriod(c, DEF)); + } + return null; + })() + ?? c.leftField ?? indName; const chartRef = getChartReferenceThreshold(c, 'right', defaults.r, DEF); const R = opts.find(o => o.value === rv)?.label ?? (chartRef != null && !isThresholdOverridden(c) ? `기준(${chartRef})` : null) @@ -1130,7 +1143,11 @@ export const CondEditor: React.FC = ({ const inheritedThresholdField = defaults.r; const getLeftValue = () => { - const v = resolveFieldOptionValue(normalized.indicatorType, normalized.leftField); + const raw = normalized.leftField; + const resolved = resolveStoredValueField(normalized.indicatorType, raw, def, normalized); + if (resolved && leftFieldOpts.some(o => o.value === resolved)) return resolved; + if (raw && leftFieldOpts.some(o => o.value === raw)) return raw; + const v = resolveFieldOptionValue(normalized.indicatorType, raw); return isValid(v) ? v! : defaults.l; }; const getRightValue = () => { @@ -1378,11 +1395,12 @@ export const CondEditor: React.FC = ({ o.value === normalized.leftField) - || (leftFieldOpts.some(o => o.value === getLeftValue()) + || (leftFieldOpts.some(o => o.value === resolveFieldOptionValue(normalized.indicatorType, normalized.leftField)) && !isValuePeriodFieldStored(normalized.indicatorType, normalized.leftField)) + || leftFieldOpts.some(o => o.value === resolveStoredValueField(normalized.indicatorType, normalized.leftField, def, normalized)) )} customKind={resolveFieldCustomKind(normalized.indicatorType, normalized.leftField)} customNumber={getConditionValuePeriod(normalized, def)} @@ -1390,22 +1408,23 @@ export const CondEditor: React.FC = ({ min={1} max={500} disabled={isHoldType} + customOptionLabel={singleIndicatorValuePeriodLabel( + normalized.indicatorType, + getConditionValuePeriod(normalized, def), + )} onFieldChange={v => { - const bases: Record = { - RSI: 'RSI_VALUE', - CCI: 'CCI_VALUE', - ADX: 'ADX_VALUE', - WILLIAMS_R: 'WILLIAMS_R_VALUE', - TRIX: 'TRIX_VALUE', - VR: 'VR_VALUE', - PSYCHOLOGICAL: 'PSY_VALUE', - NEW_PSYCHOLOGICAL: 'NEW_PSY_VALUE', - INVEST_PSYCHOLOGICAL: 'INVEST_PSY_VALUE', - }; - const base = bases[normalized.indicatorType]; - if (base && v === base) { - const p = getConditionValuePeriod(normalized, def); - onChange({ ...normalized, leftField: `${base}_${p}`, period: p, valuePeriodOverride: true }); + const valuePrefix = VALUE_FIELD_PREFIX[normalized.indicatorType]; + if (valuePrefix && v === valuePrefix) { + onChange(initConditionPeriodsInherit( + normalized.indicatorType, + { ...normalized, leftField: v }, + def, + )); + return; + } + const pMatch = v.match(/_(\d+)$/); + if (valuePrefix && v.startsWith(`${valuePrefix}_`) && pMatch) { + onChange(setConditionValuePeriod(normalized, parseInt(pMatch[1], 10), def)); } else { onChange({ ...normalized, leftField: v }); }