From 981f09cf29a527081f621594e74dcad514ac7bda Mon Sep 17 00:00:00 2001 From: Macbook Date: Fri, 12 Jun 2026 10:29:42 +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=ED=85=9C=ED=94=8C=EB=A6=BF=20=EC=82=AD=EC=A0=9C=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/StrategyEditorPage.tsx | 291 +++++++++++++----- frontend/src/types/uiPreferences.ts | 2 + frontend/src/utils/strategyImportExport.ts | 1 + frontend/src/utils/strategyPresets.ts | 6 + frontend/src/utils/strategyTemplateStorage.ts | 14 + 5 files changed, 240 insertions(+), 74 deletions(-) diff --git a/frontend/src/components/StrategyEditorPage.tsx b/frontend/src/components/StrategyEditorPage.tsx index 4b0dd0e..91e2d49 100644 --- a/frontend/src/components/StrategyEditorPage.tsx +++ b/frontend/src/components/StrategyEditorPage.tsx @@ -82,12 +82,15 @@ import { } from '../utils/strategyEditorModeStorage'; import { getStrategyTemplates, + getBuiltinTemplateId, simpleTemplateToNode, type StrategyTemplateDef, } from '../utils/strategyPresets'; import { addUserStrategyTemplate, deleteUserStrategyTemplate, + hideBuiltinTemplate, + loadHiddenBuiltinTemplateIds, loadUserStrategyTemplates, type UserStrategyTemplate, } from '../utils/strategyTemplateStorage'; @@ -197,6 +200,29 @@ function applyFlowLayoutFromStore( } } +type SaveDialogMode = 'update' | 'create' | 'saveAs'; + +function suggestSaveAsName(baseName: string, existing: StrategyDto[]): string { + const trimmed = baseName.trim() || '새 전략'; + const names = new Set(existing.map(s => s.name.trim())); + let candidate = `${trimmed} (복사)`; + let n = 2; + while (names.has(candidate)) { + candidate = `${trimmed} (복사 ${n})`; + n += 1; + } + return candidate; +} + +function isDuplicateStrategyName( + name: string, + strategies: StrategyDto[], + excludeId?: number | null, +): boolean { + const trimmed = name.trim(); + return strategies.some(s => s.name.trim() === trimmed && s.id !== excludeId); +} + export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Props) { const { getParams, getVisualConfig, settingsRevision, settingsLoaded } = useIndicatorSettings(); const DEF = useMemo( @@ -217,6 +243,9 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop const [paletteSearch, setPaletteSearch] = useState(''); const [templateSearch, setTemplateSearch] = useState(''); const [userTemplates, setUserTemplates] = useState(() => loadUserStrategyTemplates()); + const [hiddenBuiltinTemplateIds, setHiddenBuiltinTemplateIds] = useState( + () => loadHiddenBuiltinTemplateIds(), + ); const [templateSaveOpen, setTemplateSaveOpen] = useState(false); const [templateSaveLabel, setTemplateSaveLabel] = useState(''); const [templateSaveDesc, setTemplateSaveDesc] = useState(''); @@ -226,7 +255,11 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop const [stratDesc, setStratDesc] = useState(''); const [isSaving, setIsSaving] = useState(false); const [saveOpen, setSaveOpen] = useState(false); + const [saveMode, setSaveMode] = useState('create'); + const [saveDraftName, setSaveDraftName] = useState(''); + const [saveDraftDesc, setSaveDraftDesc] = useState(''); const [saveNameError, setSaveNameError] = useState(false); + const [saveDuplicateError, setSaveDuplicateError] = useState(false); const [deleteOpen, setDeleteOpen] = useState(false); const [descOpen, setDescOpen] = useState(false); const [deleteId, setDeleteId] = useState(null); @@ -824,13 +857,31 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop resetFlowLayout('draft', signalTab); }; + const openSaveDialog = useCallback((mode: SaveDialogMode) => { + setSaveMode(mode); + setSaveDraftName(mode === 'saveAs' ? suggestSaveAsName(stratName, strategies) : stratName); + setSaveDraftDesc(stratDesc); + setSaveNameError(false); + setSaveDuplicateError(false); + setSaveOpen(true); + }, [stratName, stratDesc, strategies]); + const handleSave = async () => { - if (!stratName.trim()) { + const name = saveDraftName.trim(); + if (!name) { setSaveNameError(true); showSnack('전략 이름을 입력하세요', false); return; } + const asNew = saveMode === 'saveAs' || saveMode === 'create'; + const excludeId = asNew ? null : selectedId; + if (isDuplicateStrategyName(name, strategies, excludeId)) { + setSaveDuplicateError(true); + showSnack('같은 이름의 전략이 이미 있습니다', false); + return; + } setSaveNameError(false); + setSaveDuplicateError(false); if (editorMode === 'graph') layoutFlushRef.current?.(); if (strategyAutosaveTimerRef.current != null) { window.clearTimeout(strategyAutosaveTimerRef.current); @@ -844,9 +895,9 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop try { const flowLayout = buildCurrentFlowLayout(); const payload: ApiStrategyDto = { - id: selectedId ?? undefined, - name: stratName, - description: stratDesc, + id: asNew ? undefined : (selectedId ?? undefined), + name, + description: saveDraftDesc, buyCondition: encodedBuy, sellCondition: encodedSell, flowLayout, @@ -854,42 +905,40 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop }; const saved = await saveStrategy(payload); const now = new Date().toISOString(); - const dbId = saved?.id ?? selectedId ?? Date.now(); - setStrategies(prev => { - const existing = prev.find(s => s.id === selectedId); - if (existing && selectedId) { - return prev.map(s => s.id === selectedId - ? { - ...s, - id: dbId, - name: stratName, - description: stratDesc, - buyCondition: encodedBuy, - sellCondition: encodedSell, - flowLayout, - updatedAt: saved?.updatedAt ?? now, - } - : s); - } + const dbId = saved?.id ?? (asNew ? Date.now() : selectedId ?? Date.now()); + const entry: StrategyDto = { + id: dbId, + name, + description: saveDraftDesc, + buyCondition: encodedBuy, + sellCondition: encodedSell, + flowLayout, + enabled: true, + createdAt: saved?.createdAt ?? now, + updatedAt: saved?.updatedAt ?? now, + }; + if (asNew) { + setStrategies(prev => [...prev, entry]); setSelectedId(dbId); - return [...prev, { - id: dbId, - name: stratName, - description: stratDesc, - buyCondition: encodedBuy, - sellCondition: encodedSell, - flowLayout, - enabled: true, - createdAt: saved?.createdAt ?? now, - updatedAt: saved?.updatedAt ?? now, - }]; - }); - if (!selectedId) setSelectedId(dbId); + } else if (selectedId) { + setStrategies(prev => prev.map(s => (s.id === selectedId ? { ...s, ...entry, id: dbId } : s))); + } else { + setStrategies(prev => [...prev, entry]); + setSelectedId(dbId); + } + setStratName(name); + setStratDesc(saveDraftDesc); bumpLayoutSeed(String(dbId), signalTab); setSaveOpen(false); setSaveToast(true); setTimeout(() => setSaveToast(false), 2500); - showSnack(selectedId ? '전략이 수정되었습니다' : '전략이 저장되었습니다'); + if (saveMode === 'saveAs') { + showSnack('새 전략으로 저장되었습니다'); + } else if (saveMode === 'update') { + showSnack('전략이 수정되었습니다'); + } else { + showSnack('전략이 저장되었습니다'); + } } catch (e) { showSnack(e instanceof Error ? e.message : '저장 실패', false); } finally { @@ -1022,13 +1071,56 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop showSnack('전략 목록 형식 파일이 필요합니다', false); return; } + const items = result.data.strategies; + if (hasRegisteredUser()) { + const persisted: StrategyDto[] = []; + let failed = 0; + for (const item of items) { + try { + const saved = await saveStrategy({ + name: item.name, + description: item.description, + buyCondition: item.buyCondition, + sellCondition: item.sellCondition, + flowLayout: item.flowLayout, + enabled: item.enabled ?? true, + }); + if (saved?.id) { + persisted.push({ + id: saved.id, + name: saved.name, + description: saved.description, + buyCondition: saved.buyCondition as LogicNode | null ?? null, + sellCondition: saved.sellCondition as LogicNode | null ?? null, + flowLayout: normalizeStrategyFlowLayout(saved.flowLayout) ?? item.flowLayout, + enabled: saved.enabled ?? true, + createdAt: saved.createdAt, + updatedAt: saved.updatedAt, + }); + } else { + failed += 1; + } + } catch { + failed += 1; + } + } + if (persisted.length === 0) { + showSnack('전략 저장에 실패했습니다', false); + return; + } + setStrategies(prev => [...prev, ...persisted]); + showSnack( + failed > 0 + ? `${persisted.length}개 저장, ${failed}개 실패` + : `${persisted.length}개 전략을 가져와 저장했습니다`, + failed === 0, + ); + return; + } const baseId = Date.now(); - const imported = result.data.strategies.map((item, index) => { - const id = baseId + index; - return listImportItemToStrategyDto(item, id); - }); + const imported = items.map((item, index) => listImportItemToStrategyDto(item, baseId + index)); setStrategies(prev => [...prev, ...imported]); - showSnack(`${imported.length}개 전략을 가져왔습니다`); + showSnack(`${imported.length}개 전략을 가져왔습니다 (로그인 시 DB에 저장됩니다)`, false); } catch (e) { showSnack(e instanceof Error ? e.message : '파일 읽기 실패', false); } @@ -1084,16 +1176,23 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop signal: t.signal, user: t, })); - const builtinRows = templates.map((t, i) => ({ - key: `builtin-${i}`, - source: 'builtin' as const, - label: t.label, - description: 'description' in t ? t.description : undefined, - signal: t.signal, - builtin: t, - })); + const hidden = new Set(hiddenBuiltinTemplateIds); + const builtinRows = templates + .filter(t => !hidden.has(getBuiltinTemplateId(t))) + .map(t => { + const builtinId = getBuiltinTemplateId(t); + return { + key: builtinId, + source: 'builtin' as const, + label: t.label, + description: 'description' in t ? t.description : undefined, + signal: t.signal, + builtinId, + builtin: t, + }; + }); return [...userRows, ...builtinRows]; - }, [userTemplates, templates]); + }, [userTemplates, templates, hiddenBuiltinTemplateIds]); const filteredTemplateRows = useMemo(() => { const q = templateSearch.trim().toLowerCase(); @@ -1178,6 +1277,12 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop showSnack('템플릿을 삭제했습니다'); }, []); + const handleDeleteBuiltinTemplate = useCallback((id: string, label: string) => { + if (!window.confirm(`"${label}" 템플릿을 목록에서 삭제할까요?`)) return; + setHiddenBuiltinTemplateIds(hideBuiltinTemplate(id)); + showSnack('템플릿을 목록에서 제거했습니다'); + }, []); + const openTemplateSaveModal = useCallback(() => { const hasContent = encodeConditionForSave(currentEditorState) != null || currentOrphans.length > 0; if (!hasContent) { @@ -1575,10 +1680,18 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop + @@ -1887,25 +2000,27 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop {row.signal === 'buy' ? '매수' : '매도'} - {row.source === 'user' && ( - - )} + } else { + handleDeleteBuiltinTemplate(row.builtinId, row.label); + } + }} + > + + + + )) )} @@ -1953,23 +2068,51 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop )} {saveOpen && ( - { setSaveNameError(false); setSaveOpen(false); }} title="전략 저장"> + { setSaveNameError(false); setSaveDuplicateError(false); setSaveOpen(false); }} + title={saveMode === 'saveAs' ? '새 이름으로 저장' : '전략 저장'} + >
+ {saveMode === 'saveAs' && selectedId && ( +

+ 현재 편집 중인 조건을 새 전략으로 저장합니다. + 기존 전략「{strategies.find(s => s.id === selectedId)?.name ?? stratName}」은 변경되지 않습니다. +

+ )} + {saveMode === 'saveAs' && !selectedId && ( +

+ 현재 조건을 새 전략으로 저장합니다. +

+ )} { setStratName(e.target.value); setSaveNameError(false); }} + className={`se-field-inp${saveNameError || saveDuplicateError ? ' se-field-inp--error' : ''}`} + value={saveDraftName} + onChange={e => { + setSaveDraftName(e.target.value); + setSaveNameError(false); + setSaveDuplicateError(false); + }} placeholder="예: Golden_RSI_V1" /> {saveNameError && (

전략 이름을 입력하세요

)} + {saveDuplicateError && ( +

같은 이름의 전략이 이미 있습니다

+ )} -