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

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';
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<UserStrategyTemplate[]>(() => loadUserStrategyTemplates());
const [hiddenBuiltinTemplateIds, setHiddenBuiltinTemplateIds] = useState<string[]>(
() => 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<SaveDialogMode>('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<number | null>(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
<button
type="button"
className="se-btn se-btn--gold se-btn--sm"
onClick={() => { setSaveNameError(false); setSaveOpen(true); }}
onClick={() => openSaveDialog(selectedId ? 'update' : 'create')}
>
</button>
<button
type="button"
className="se-btn se-btn--ghost se-btn--sm"
onClick={() => openSaveDialog('saveAs')}
title="현재 조건을 다른 이름으로 새 전략으로 저장 (기존 전략은 유지)"
>
</button>
</div>
</div>
@@ -1887,25 +2000,27 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop
{row.signal === 'buy' ? '매수' : '매도'}
</span>
</button>
{row.source === 'user' && (
<button
type="button"
className="se-template-delete"
title="템플릿 삭제"
aria-label={`${row.label} 템플릿 삭제`}
onClick={e => {
e.stopPropagation();
<button
type="button"
className="se-template-delete"
title="템플릿 삭제"
aria-label={`${row.label} 템플릿 삭제`}
onClick={e => {
e.stopPropagation();
if (row.source === 'user') {
handleDeleteUserTemplate(row.user.id, 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>
</button>
)}
} 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>
</button>
</div>
))
)}
@@ -1953,23 +2068,51 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop
)}
{saveOpen && (
<DraggableModalFrame onClose={() => { setSaveNameError(false); setSaveOpen(false); }} title="전략 저장">
<DraggableModalFrame
onClose={() => { setSaveNameError(false); setSaveDuplicateError(false); setSaveOpen(false); }}
title={saveMode === 'saveAs' ? '새 이름으로 저장' : '전략 저장'}
>
<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>
<input
className={`se-field-inp${saveNameError ? ' se-field-inp--error' : ''}`}
value={stratName}
onChange={e => { 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 && (
<p className="se-field-error" role="alert"> </p>
)}
{saveDuplicateError && (
<p className="se-field-error" role="alert"> </p>
)}
<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">
<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>
</DraggableModalFrame>
+2
View File
@@ -14,6 +14,8 @@ export interface UiPreferences {
paletteAuxiliary?: PaletteItem[];
paletteComposite?: PaletteItem[];
userTemplates?: UserStrategyTemplate[];
/** 내장 템플릿 목록에서 숨긴 ID (getBuiltinTemplateId) */
hiddenBuiltinTemplateIds?: string[];
};
indicator?: {
customTabs?: IndicatorCustomTab[];
@@ -189,6 +189,7 @@ export function listImportItemToStrategyDto(item: StrategyListExportItem, id: nu
description: item.description,
buyCondition: item.buyCondition ?? null,
sellCondition: item.sellCondition ?? null,
flowLayout: item.flowLayout,
enabled: item.enabled ?? true,
createdAt: now,
updatedAt: now,
+6
View File
@@ -24,6 +24,12 @@ export type 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(
indicatorType: string,
conditionType: string,
@@ -64,3 +64,17 @@ export function deleteUserStrategyTemplate(id: string): UserStrategyTemplate[] {
saveUserStrategyTemplates(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;
}