전략편집기 템플릿 저장 기능 추가

This commit is contained in:
Macbook
2026-06-11 23:24:29 +09:00
parent 05c15ec92b
commit eb6cf894da
9 changed files with 558 additions and 81 deletions
+255 -29
View File
@@ -83,6 +83,12 @@ import {
simpleTemplateToNode,
type StrategyTemplateDef,
} from '../utils/strategyPresets';
import {
addUserStrategyTemplate,
deleteUserStrategyTemplate,
loadUserStrategyTemplates,
type UserStrategyTemplate,
} from '../utils/strategyTemplateStorage';
import {
buildStrategyExportPayload,
buildStrategyListExportPayload,
@@ -193,6 +199,12 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop
const [auxiliaryPalette, setAuxiliaryPalette] = useState<PaletteItem[]>(() => loadPaletteItems('auxiliary'));
const [compositePalette, setCompositePalette] = useState<PaletteItem[]>(() => loadPaletteItems('composite'));
const [paletteSearch, setPaletteSearch] = useState('');
const [templateSearch, setTemplateSearch] = useState('');
const [userTemplates, setUserTemplates] = useState<UserStrategyTemplate[]>(() => loadUserStrategyTemplates());
const [templateSaveOpen, setTemplateSaveOpen] = useState(false);
const [templateSaveLabel, setTemplateSaveLabel] = useState('');
const [templateSaveDesc, setTemplateSaveDesc] = useState('');
const [templateSaveLabelError, setTemplateSaveLabelError] = useState(false);
const [selectedPaletteKey, setSelectedPaletteKey] = useState<string | null>(null);
const [stratName, setStratName] = useState('');
const [stratDesc, setStratDesc] = useState('');
@@ -1027,6 +1039,40 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop
const templates = useMemo(() => getStrategyTemplates(DEF), [DEF]);
const templateRows = useMemo(() => {
const userRows = userTemplates.map(t => ({
key: t.id,
source: 'user' as const,
label: t.label,
description: t.description,
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,
}));
return [...userRows, ...builtinRows];
}, [userTemplates, templates]);
const filteredTemplateRows = useMemo(() => {
const q = templateSearch.trim().toLowerCase();
if (!q) return templateRows;
return templateRows.filter(row => {
const signalLabel = row.signal === 'buy' ? '매수' : '매도';
return (
row.label.toLowerCase().includes(q)
|| row.description?.toLowerCase().includes(q)
|| signalLabel.includes(q)
|| row.signal.includes(q)
);
});
}, [templateRows, templateSearch]);
const handleTemplate = useCallback((tmpl: StrategyTemplateDef) => {
if (editorMode === 'graph') layoutFlushRef.current?.();
@@ -1047,6 +1093,93 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop
showSnack(`"${tmpl.label}" 템플릿이 적용됐습니다`);
}, [editorMode, layoutStrategyKey, resetFlowLayout, signalTab, scheduleStrategyPersist]);
const applyUserTemplate = useCallback((tmpl: UserStrategyTemplate) => {
if (editorMode === 'graph') layoutFlushRef.current?.();
const targetTab = tmpl.signal;
const layoutFromState = {
startMeta: tmpl.editorState.startMeta,
extraStartIds: tmpl.editorState.extraStartIds,
extraRoots: tmpl.editorState.extraRoots ?? {},
startCombineOp: normalizeStartCombineOp(tmpl.editorState.startCombineOp),
positions: {},
edgeHandles: {},
};
setBuyCondition(null);
setSellCondition(null);
setBuyOrphans([]);
setSellOrphans([]);
setSelectedNodeId(null);
if (targetTab === 'buy') {
setBuyCondition(tmpl.editorState.root);
setBuyLayout(prev => ({ ...prev, ...layoutFromState }));
setBuyOrphans(tmpl.orphans);
setSellLayout(normalizeTabLayoutState(emptySignalFlowLayout()));
} else {
setSellCondition(tmpl.editorState.root);
setSellLayout(prev => ({ ...prev, ...layoutFromState }));
setSellOrphans(tmpl.orphans);
setBuyLayout(normalizeTabLayoutState(emptySignalFlowLayout()));
}
bumpLayoutSeed(layoutStrategyKey, targetTab);
if (targetTab !== signalTab) setSignalTab(targetTab);
scheduleStrategyPersist();
showSnack(`"${tmpl.label}" 템플릿이 적용됐습니다`);
}, [
editorMode,
layoutStrategyKey,
bumpLayoutSeed,
signalTab,
scheduleStrategyPersist,
]);
const handleDeleteUserTemplate = useCallback((id: string, label: string) => {
if (!window.confirm(`"${label}" 템플릿을 목록에서 삭제할까요?`)) return;
setUserTemplates(deleteUserStrategyTemplate(id));
showSnack('템플릿을 삭제했습니다');
}, []);
const openTemplateSaveModal = useCallback(() => {
const hasContent = encodeConditionForSave(currentEditorState) != null || currentOrphans.length > 0;
if (!hasContent) {
showSnack('저장할 조건이 없습니다', false);
return;
}
setTemplateSaveLabel(stratName.trim() || `${signalTab === 'buy' ? '매수' : '매도'} 템플릿`);
setTemplateSaveDesc('');
setTemplateSaveLabelError(false);
setTemplateSaveOpen(true);
}, [currentEditorState, currentOrphans.length, stratName, signalTab]);
const handleTemplateSaveConfirm = useCallback(() => {
const label = templateSaveLabel.trim();
if (!label) {
setTemplateSaveLabelError(true);
return;
}
const saved = addUserStrategyTemplate({
label,
description: templateSaveDesc.trim() || undefined,
signal: signalTab,
editorState: toEditorState(currentRoot, currentLayout),
orphans: currentOrphans,
});
setUserTemplates(prev => [...prev, saved]);
setTemplateSaveOpen(false);
setRightTab('templates');
showSnack(`"${saved.label}" 템플릿을 저장했습니다`);
}, [
templateSaveLabel,
templateSaveDesc,
signalTab,
currentRoot,
currentLayout,
currentOrphans,
]);
const handleQuickRun = useCallback(async () => {
if (!selectedId) {
window.alert('백테스팅할 전략을 먼저 선택해주세요.');
@@ -1216,23 +1349,6 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop
</div>
<div className="se-header-actions">
<div className="se-editor-mode" role="group" aria-label="편집 방식">
<button
type="button"
className={`se-editor-mode-btn${editorMode === 'graph' ? ' se-editor-mode-btn--on' : ''}`}
onClick={() => handleEditorModeChange('graph')}
>
</button>
<button
type="button"
className={`se-editor-mode-btn${editorMode === 'list' ? ' se-editor-mode-btn--on' : ''}`}
onClick={() => handleEditorModeChange('list')}
>
</button>
</div>
<button type="button" className="se-btn se-btn--ghost" onClick={handleNew}> </button>
<button
type="button"
className="se-btn se-btn--ghost se-btn--icon se-btn--desc"
@@ -1282,7 +1398,6 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop
>
</button>
<button type="button" className="se-btn se-btn--gold" onClick={() => { setSaveNameError(false); setSaveOpen(true); }}></button>
</div>
</header>
@@ -1383,6 +1498,40 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop
onChange={handleStartCombineOpChange}
/>
)}
<div className="se-center-head-actions">
<button type="button" className="se-btn se-btn--ghost se-btn--sm" onClick={handleNew}> </button>
<div className="se-editor-mode se-editor-mode--compact" role="group" aria-label="편집 방식">
<button
type="button"
className={`se-editor-mode-btn${editorMode === 'graph' ? ' se-editor-mode-btn--on' : ''}`}
onClick={() => handleEditorModeChange('graph')}
>
</button>
<button
type="button"
className={`se-editor-mode-btn${editorMode === 'list' ? ' se-editor-mode-btn--on' : ''}`}
onClick={() => handleEditorModeChange('list')}
>
</button>
</div>
<button
type="button"
className="se-btn se-btn--ghost se-btn--sm"
onClick={openTemplateSaveModal}
title="현재 탭(매수/매도) 조건을 템플릿 목록에 저장"
>
릿
</button>
<button
type="button"
className="se-btn se-btn--gold se-btn--sm"
onClick={() => { setSaveNameError(false); setSaveOpen(true); }}
>
</button>
</div>
</div>
{editorMode === 'graph' ? (
@@ -1616,17 +1765,62 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop
</>
)}
{rightTab === 'templates' && (
<div className="se-template-list">
{templates.map((t, i) => (
<button key={i} type="button" className="se-template-item" onClick={() => handleTemplate(t)}>
<span className="se-template-label">{t.label}</span>
{'description' in t && t.description && (
<span className="se-template-desc">{t.description}</span>
)}
<span className={`se-template-signal se-template-signal--${t.signal}`}>{t.signal === 'buy' ? '매수' : '매도'}</span>
</button>
))}
</div>
<>
<input
className="se-palette-search"
placeholder="템플릿 검색 (RSI, MA, 매수…)"
value={templateSearch}
onChange={e => setTemplateSearch(e.target.value)}
/>
<div className="se-template-list">
{filteredTemplateRows.length === 0 ? (
<div className="se-template-empty"> </div>
) : (
filteredTemplateRows.map(row => (
<div key={row.key} className="se-template-row">
<button
type="button"
className="se-template-item"
onClick={() => {
if (row.source === 'user') applyUserTemplate(row.user);
else handleTemplate(row.builtin);
}}
>
<span className="se-template-label">
{row.source === 'user' && <span className="se-template-user-badge"> 릿</span>}
{row.label}
</span>
{row.description && (
<span className="se-template-desc">{row.description}</span>
)}
<span className={`se-template-signal se-template-signal--${row.signal}`}>
{row.signal === 'buy' ? '매수' : '매도'}
</span>
</button>
{row.source === 'user' && (
<button
type="button"
className="se-template-delete"
title="템플릿 삭제"
aria-label={`${row.label} 템플릿 삭제`}
onClick={e => {
e.stopPropagation();
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>
)}
</div>
))
)}
</div>
</>
)}
</div>
</div>
@@ -1635,6 +1829,38 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop
</div>
</div>
{templateSaveOpen && (
<DraggableModalFrame onClose={() => { setTemplateSaveLabelError(false); setTemplateSaveOpen(false); }} title="템플릿 저장">
<div className="se-modal-body">
<p className="se-template-save-hint">
<strong>{signalTab === 'buy' ? '매수' : '매도'}</strong> 릿 .
</p>
<label className="se-field-lbl">릿 *</label>
<input
className={`se-field-inp${templateSaveLabelError ? ' se-field-inp--error' : ''}`}
value={templateSaveLabel}
onChange={e => { setTemplateSaveLabel(e.target.value); setTemplateSaveLabelError(false); }}
placeholder="예: RSI 과매도 + MA 골든"
/>
{templateSaveLabelError && (
<p className="se-field-error" role="alert">릿 </p>
)}
<label className="se-field-lbl"></label>
<textarea
className="se-field-ta"
value={templateSaveDesc}
onChange={e => setTemplateSaveDesc(e.target.value)}
rows={2}
placeholder="템플릿 설명 (선택)"
/>
<div className="se-modal-actions">
<button type="button" className="se-btn se-btn--ghost" onClick={() => setTemplateSaveOpen(false)}></button>
<button type="button" className="se-btn se-btn--gold" onClick={handleTemplateSaveConfirm}></button>
</div>
</div>
</DraggableModalFrame>
)}
{saveOpen && (
<DraggableModalFrame onClose={() => { setSaveNameError(false); setSaveOpen(false); }} title="전략 저장">
<div className="se-modal-body">