전략편집기 템플릿 저장 기능 추가
This commit is contained in:
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user