전략편집기 템플릿 삭제 기능 추가

This commit is contained in:
Macbook
2026-06-12 10:29:42 +09:00
parent 429a80523e
commit 981f09cf29
5 changed files with 240 additions and 74 deletions
+217 -74
View File
@@ -82,12 +82,15 @@ import {
} from '../utils/strategyEditorModeStorage'; } from '../utils/strategyEditorModeStorage';
import { import {
getStrategyTemplates, getStrategyTemplates,
getBuiltinTemplateId,
simpleTemplateToNode, simpleTemplateToNode,
type StrategyTemplateDef, type StrategyTemplateDef,
} from '../utils/strategyPresets'; } from '../utils/strategyPresets';
import { import {
addUserStrategyTemplate, addUserStrategyTemplate,
deleteUserStrategyTemplate, deleteUserStrategyTemplate,
hideBuiltinTemplate,
loadHiddenBuiltinTemplateIds,
loadUserStrategyTemplates, loadUserStrategyTemplates,
type UserStrategyTemplate, type UserStrategyTemplate,
} from '../utils/strategyTemplateStorage'; } 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) { export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Props) {
const { getParams, getVisualConfig, settingsRevision, settingsLoaded } = useIndicatorSettings(); const { getParams, getVisualConfig, settingsRevision, settingsLoaded } = useIndicatorSettings();
const DEF = useMemo( const DEF = useMemo(
@@ -217,6 +243,9 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop
const [paletteSearch, setPaletteSearch] = useState(''); const [paletteSearch, setPaletteSearch] = useState('');
const [templateSearch, setTemplateSearch] = useState(''); const [templateSearch, setTemplateSearch] = useState('');
const [userTemplates, setUserTemplates] = useState<UserStrategyTemplate[]>(() => loadUserStrategyTemplates()); const [userTemplates, setUserTemplates] = useState<UserStrategyTemplate[]>(() => loadUserStrategyTemplates());
const [hiddenBuiltinTemplateIds, setHiddenBuiltinTemplateIds] = useState<string[]>(
() => loadHiddenBuiltinTemplateIds(),
);
const [templateSaveOpen, setTemplateSaveOpen] = useState(false); const [templateSaveOpen, setTemplateSaveOpen] = useState(false);
const [templateSaveLabel, setTemplateSaveLabel] = useState(''); const [templateSaveLabel, setTemplateSaveLabel] = useState('');
const [templateSaveDesc, setTemplateSaveDesc] = useState(''); const [templateSaveDesc, setTemplateSaveDesc] = useState('');
@@ -226,7 +255,11 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop
const [stratDesc, setStratDesc] = useState(''); const [stratDesc, setStratDesc] = useState('');
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const [saveOpen, setSaveOpen] = useState(false); const [saveOpen, setSaveOpen] = useState(false);
const [saveMode, setSaveMode] = useState<SaveDialogMode>('create');
const [saveDraftName, setSaveDraftName] = useState('');
const [saveDraftDesc, setSaveDraftDesc] = useState('');
const [saveNameError, setSaveNameError] = useState(false); const [saveNameError, setSaveNameError] = useState(false);
const [saveDuplicateError, setSaveDuplicateError] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false); const [deleteOpen, setDeleteOpen] = useState(false);
const [descOpen, setDescOpen] = useState(false); const [descOpen, setDescOpen] = useState(false);
const [deleteId, setDeleteId] = useState<number | null>(null); const [deleteId, setDeleteId] = useState<number | null>(null);
@@ -824,13 +857,31 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop
resetFlowLayout('draft', signalTab); 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 () => { const handleSave = async () => {
if (!stratName.trim()) { const name = saveDraftName.trim();
if (!name) {
setSaveNameError(true); setSaveNameError(true);
showSnack('전략 이름을 입력하세요', false); showSnack('전략 이름을 입력하세요', false);
return; return;
} }
const asNew = saveMode === 'saveAs' || saveMode === 'create';
const excludeId = asNew ? null : selectedId;
if (isDuplicateStrategyName(name, strategies, excludeId)) {
setSaveDuplicateError(true);
showSnack('같은 이름의 전략이 이미 있습니다', false);
return;
}
setSaveNameError(false); setSaveNameError(false);
setSaveDuplicateError(false);
if (editorMode === 'graph') layoutFlushRef.current?.(); if (editorMode === 'graph') layoutFlushRef.current?.();
if (strategyAutosaveTimerRef.current != null) { if (strategyAutosaveTimerRef.current != null) {
window.clearTimeout(strategyAutosaveTimerRef.current); window.clearTimeout(strategyAutosaveTimerRef.current);
@@ -844,9 +895,9 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop
try { try {
const flowLayout = buildCurrentFlowLayout(); const flowLayout = buildCurrentFlowLayout();
const payload: ApiStrategyDto = { const payload: ApiStrategyDto = {
id: selectedId ?? undefined, id: asNew ? undefined : (selectedId ?? undefined),
name: stratName, name,
description: stratDesc, description: saveDraftDesc,
buyCondition: encodedBuy, buyCondition: encodedBuy,
sellCondition: encodedSell, sellCondition: encodedSell,
flowLayout, flowLayout,
@@ -854,42 +905,40 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop
}; };
const saved = await saveStrategy(payload); const saved = await saveStrategy(payload);
const now = new Date().toISOString(); const now = new Date().toISOString();
const dbId = saved?.id ?? selectedId ?? Date.now(); const dbId = saved?.id ?? (asNew ? Date.now() : selectedId ?? Date.now());
setStrategies(prev => { const entry: StrategyDto = {
const existing = prev.find(s => s.id === selectedId); id: dbId,
if (existing && selectedId) { name,
return prev.map(s => s.id === selectedId description: saveDraftDesc,
? { buyCondition: encodedBuy,
...s, sellCondition: encodedSell,
id: dbId, flowLayout,
name: stratName, enabled: true,
description: stratDesc, createdAt: saved?.createdAt ?? now,
buyCondition: encodedBuy, updatedAt: saved?.updatedAt ?? now,
sellCondition: encodedSell, };
flowLayout, if (asNew) {
updatedAt: saved?.updatedAt ?? now, setStrategies(prev => [...prev, entry]);
}
: s);
}
setSelectedId(dbId); setSelectedId(dbId);
return [...prev, { } else if (selectedId) {
id: dbId, setStrategies(prev => prev.map(s => (s.id === selectedId ? { ...s, ...entry, id: dbId } : s)));
name: stratName, } else {
description: stratDesc, setStrategies(prev => [...prev, entry]);
buyCondition: encodedBuy, setSelectedId(dbId);
sellCondition: encodedSell, }
flowLayout, setStratName(name);
enabled: true, setStratDesc(saveDraftDesc);
createdAt: saved?.createdAt ?? now,
updatedAt: saved?.updatedAt ?? now,
}];
});
if (!selectedId) setSelectedId(dbId);
bumpLayoutSeed(String(dbId), signalTab); bumpLayoutSeed(String(dbId), signalTab);
setSaveOpen(false); setSaveOpen(false);
setSaveToast(true); setSaveToast(true);
setTimeout(() => setSaveToast(false), 2500); setTimeout(() => setSaveToast(false), 2500);
showSnack(selectedId ? '전략이 수정되었습니다' : '전략이 저장되었습니다'); if (saveMode === 'saveAs') {
showSnack('새 전략으로 저장되었습니다');
} else if (saveMode === 'update') {
showSnack('전략이 수정되었습니다');
} else {
showSnack('전략이 저장되었습니다');
}
} catch (e) { } catch (e) {
showSnack(e instanceof Error ? e.message : '저장 실패', false); showSnack(e instanceof Error ? e.message : '저장 실패', false);
} finally { } finally {
@@ -1022,13 +1071,56 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop
showSnack('전략 목록 형식 파일이 필요합니다', false); showSnack('전략 목록 형식 파일이 필요합니다', false);
return; 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 baseId = Date.now();
const imported = result.data.strategies.map((item, index) => { const imported = items.map((item, index) => listImportItemToStrategyDto(item, baseId + index));
const id = baseId + index;
return listImportItemToStrategyDto(item, id);
});
setStrategies(prev => [...prev, ...imported]); setStrategies(prev => [...prev, ...imported]);
showSnack(`${imported.length}개 전략을 가져왔습니다`); showSnack(`${imported.length}개 전략을 가져왔습니다 (로그인 시 DB에 저장됩니다)`, false);
} catch (e) { } catch (e) {
showSnack(e instanceof Error ? e.message : '파일 읽기 실패', false); showSnack(e instanceof Error ? e.message : '파일 읽기 실패', false);
} }
@@ -1084,16 +1176,23 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop
signal: t.signal, signal: t.signal,
user: t, user: t,
})); }));
const builtinRows = templates.map((t, i) => ({ const hidden = new Set(hiddenBuiltinTemplateIds);
key: `builtin-${i}`, const builtinRows = templates
source: 'builtin' as const, .filter(t => !hidden.has(getBuiltinTemplateId(t)))
label: t.label, .map(t => {
description: 'description' in t ? t.description : undefined, const builtinId = getBuiltinTemplateId(t);
signal: t.signal, return {
builtin: t, 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]; return [...userRows, ...builtinRows];
}, [userTemplates, templates]); }, [userTemplates, templates, hiddenBuiltinTemplateIds]);
const filteredTemplateRows = useMemo(() => { const filteredTemplateRows = useMemo(() => {
const q = templateSearch.trim().toLowerCase(); const q = templateSearch.trim().toLowerCase();
@@ -1178,6 +1277,12 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop
showSnack('템플릿을 삭제했습니다'); showSnack('템플릿을 삭제했습니다');
}, []); }, []);
const handleDeleteBuiltinTemplate = useCallback((id: string, label: string) => {
if (!window.confirm(`"${label}" 템플릿을 목록에서 삭제할까요?`)) return;
setHiddenBuiltinTemplateIds(hideBuiltinTemplate(id));
showSnack('템플릿을 목록에서 제거했습니다');
}, []);
const openTemplateSaveModal = useCallback(() => { const openTemplateSaveModal = useCallback(() => {
const hasContent = encodeConditionForSave(currentEditorState) != null || currentOrphans.length > 0; const hasContent = encodeConditionForSave(currentEditorState) != null || currentOrphans.length > 0;
if (!hasContent) { if (!hasContent) {
@@ -1575,10 +1680,18 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop
<button <button
type="button" type="button"
className="se-btn se-btn--gold se-btn--sm" className="se-btn se-btn--gold se-btn--sm"
onClick={() => { setSaveNameError(false); setSaveOpen(true); }} onClick={() => openSaveDialog(selectedId ? 'update' : 'create')}
> >
</button> </button>
<button
type="button"
className="se-btn se-btn--ghost se-btn--sm"
onClick={() => openSaveDialog('saveAs')}
title="현재 조건을 다른 이름으로 새 전략으로 저장 (기존 전략은 유지)"
>
</button>
</div> </div>
</div> </div>
@@ -1887,25 +2000,27 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop
{row.signal === 'buy' ? '매수' : '매도'} {row.signal === 'buy' ? '매수' : '매도'}
</span> </span>
</button> </button>
{row.source === 'user' && ( <button
<button type="button"
type="button" className="se-template-delete"
className="se-template-delete" title="템플릿 삭제"
title="템플릿 삭제" aria-label={`${row.label} 템플릿 삭제`}
aria-label={`${row.label} 템플릿 삭제`} onClick={e => {
onClick={e => { e.stopPropagation();
e.stopPropagation(); if (row.source === 'user') {
handleDeleteUserTemplate(row.user.id, row.label); handleDeleteUserTemplate(row.user.id, row.label);
}} } else {
> handleDeleteBuiltinTemplate(row.builtinId, row.label);
<svg viewBox="0 0 16 16" width="15" height="15" aria-hidden> }
<path }}
fill="currentColor" >
d="M5.5 2a1 1 0 0 1 1-1h3a1 1 0 0 1 1 1v.5H12a.5.5 0 0 1 0 1h-.55l-.62 8.07A1.5 1.5 0 0 1 9.83 13H6.17a1.5 1.5 0 0 1-1.49-1.43L4.05 3.5H4a.5.5 0 0 1 0-1h1.5V2zm1.5 0v.5h2V2H7zm-2.38 1.5l.58 7.53a.5.5 0 0 0 .5.47h3.66a.5.5 0 0 0 .5-.47l.58-7.53H4.62z" <svg viewBox="0 0 16 16" width="15" height="15" aria-hidden>
/> <path
</svg> fill="currentColor"
</button> d="M5.5 2a1 1 0 0 1 1-1h3a1 1 0 0 1 1 1v.5H12a.5.5 0 0 1 0 1h-.55l-.62 8.07A1.5 1.5 0 0 1 9.83 13H6.17a1.5 1.5 0 0 1-1.49-1.43L4.05 3.5H4a.5.5 0 0 1 0-1h1.5V2zm1.5 0v.5h2V2H7zm-2.38 1.5l.58 7.53a.5.5 0 0 0 .5.47h3.66a.5.5 0 0 0 .5-.47l.58-7.53H4.62z"
)} />
</svg>
</button>
</div> </div>
)) ))
)} )}
@@ -1953,23 +2068,51 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop
)} )}
{saveOpen && ( {saveOpen && (
<DraggableModalFrame onClose={() => { setSaveNameError(false); setSaveOpen(false); }} title="전략 저장"> <DraggableModalFrame
onClose={() => { setSaveNameError(false); setSaveDuplicateError(false); setSaveOpen(false); }}
title={saveMode === 'saveAs' ? '새 이름으로 저장' : '전략 저장'}
>
<div className="se-modal-body"> <div className="se-modal-body">
{saveMode === 'saveAs' && selectedId && (
<p className="se-template-save-hint">
<strong> </strong> .
{strategies.find(s => s.id === selectedId)?.name ?? stratName} .
</p>
)}
{saveMode === 'saveAs' && !selectedId && (
<p className="se-template-save-hint">
<strong> </strong> .
</p>
)}
<label className="se-field-lbl"> *</label> <label className="se-field-lbl"> *</label>
<input <input
className={`se-field-inp${saveNameError ? ' se-field-inp--error' : ''}`} className={`se-field-inp${saveNameError || saveDuplicateError ? ' se-field-inp--error' : ''}`}
value={stratName} value={saveDraftName}
onChange={e => { setStratName(e.target.value); setSaveNameError(false); }} onChange={e => {
setSaveDraftName(e.target.value);
setSaveNameError(false);
setSaveDuplicateError(false);
}}
placeholder="예: Golden_RSI_V1" placeholder="예: Golden_RSI_V1"
/> />
{saveNameError && ( {saveNameError && (
<p className="se-field-error" role="alert"> </p> <p className="se-field-error" role="alert"> </p>
)} )}
{saveDuplicateError && (
<p className="se-field-error" role="alert"> </p>
)}
<label className="se-field-lbl"></label> <label className="se-field-lbl"></label>
<textarea className="se-field-ta" value={stratDesc} onChange={e => setStratDesc(e.target.value)} rows={2} /> <textarea
className="se-field-ta"
value={saveDraftDesc}
onChange={e => setSaveDraftDesc(e.target.value)}
rows={2}
/>
<div className="se-modal-actions"> <div className="se-modal-actions">
<button type="button" className="se-btn se-btn--ghost" onClick={() => setSaveOpen(false)}></button> <button type="button" className="se-btn se-btn--ghost" onClick={() => setSaveOpen(false)}></button>
<button type="button" className="se-btn se-btn--gold" disabled={isSaving} onClick={handleSave}>{isSaving ? '저장 중…' : '저장'}</button> <button type="button" className="se-btn se-btn--gold" disabled={isSaving} onClick={handleSave}>
{isSaving ? '저장 중…' : (saveMode === 'saveAs' ? '새로저장' : '저장')}
</button>
</div> </div>
</div> </div>
</DraggableModalFrame> </DraggableModalFrame>
+2
View File
@@ -14,6 +14,8 @@ export interface UiPreferences {
paletteAuxiliary?: PaletteItem[]; paletteAuxiliary?: PaletteItem[];
paletteComposite?: PaletteItem[]; paletteComposite?: PaletteItem[];
userTemplates?: UserStrategyTemplate[]; userTemplates?: UserStrategyTemplate[];
/** 내장 템플릿 목록에서 숨긴 ID (getBuiltinTemplateId) */
hiddenBuiltinTemplateIds?: string[];
}; };
indicator?: { indicator?: {
customTabs?: IndicatorCustomTab[]; customTabs?: IndicatorCustomTab[];
@@ -189,6 +189,7 @@ export function listImportItemToStrategyDto(item: StrategyListExportItem, id: nu
description: item.description, description: item.description,
buyCondition: item.buyCondition ?? null, buyCondition: item.buyCondition ?? null,
sellCondition: item.sellCondition ?? null, sellCondition: item.sellCondition ?? null,
flowLayout: item.flowLayout,
enabled: item.enabled ?? true, enabled: item.enabled ?? true,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
+6
View File
@@ -24,6 +24,12 @@ export type CompositeStrategyTemplate = {
export type StrategyTemplateDef = SimpleStrategyTemplate | CompositeStrategyTemplate; export type StrategyTemplateDef = SimpleStrategyTemplate | CompositeStrategyTemplate;
/** 내장 템플릿 숨김·복원용 안정 ID */
export function getBuiltinTemplateId(tmpl: StrategyTemplateDef): string {
const slug = tmpl.label.trim().replace(/\s+/g, '_');
return `builtin:${tmpl.signal}:${slug}`;
}
function condition( function condition(
indicatorType: string, indicatorType: string,
conditionType: string, conditionType: string,
@@ -64,3 +64,17 @@ export function deleteUserStrategyTemplate(id: string): UserStrategyTemplate[] {
saveUserStrategyTemplates(templates); saveUserStrategyTemplates(templates);
return templates; return templates;
} }
export function loadHiddenBuiltinTemplateIds(): string[] {
const raw = getUiPreferences().strategyEditor?.hiddenBuiltinTemplateIds;
if (!Array.isArray(raw)) return [];
return raw.filter((id): id is string => typeof id === 'string' && id.length > 0);
}
export function hideBuiltinTemplate(id: string): string[] {
const hidden = loadHiddenBuiltinTemplateIds();
if (hidden.includes(id)) return hidden;
const next = [...hidden, id];
patchUiPreferences({ strategyEditor: { hiddenBuiltinTemplateIds: next } });
return next;
}