전략편집기 템플릿 탭 추가,수정, 삭제 기능 적용

This commit is contained in:
Macbook
2026-06-12 13:30:40 +09:00
parent 52137cf1db
commit cb1bde2563
4 changed files with 278 additions and 81 deletions
+106 -62
View File
@@ -45,6 +45,7 @@ import {
type StartCombineOp, type StartCombineOp,
} from '../utils/strategyStartNodes'; } from '../utils/strategyStartNodes';
import IndicatorPaletteTab from './strategyEditor/IndicatorPaletteTab'; import IndicatorPaletteTab from './strategyEditor/IndicatorPaletteTab';
import TemplatePaletteTab, { type TemplatePaletteRow } from './strategyEditor/TemplatePaletteTab';
import SidewaysFilterPaletteTab from './strategyEditor/SidewaysFilterPaletteTab'; import SidewaysFilterPaletteTab from './strategyEditor/SidewaysFilterPaletteTab';
import { buildSidewaysFilterNode, loadSidewaysPaletteItems } from '../utils/sidewaysFilterPaletteStorage'; import { buildSidewaysFilterNode, loadSidewaysPaletteItems } from '../utils/sidewaysFilterPaletteStorage';
import { import {
@@ -121,6 +122,7 @@ import {
import { import {
addUserStrategyTemplate, addUserStrategyTemplate,
deleteUserStrategyTemplate, deleteUserStrategyTemplate,
updateUserStrategyTemplate,
hideBuiltinTemplate, hideBuiltinTemplate,
loadHiddenBuiltinTemplateIds, loadHiddenBuiltinTemplateIds,
loadUserStrategyTemplates, loadUserStrategyTemplates,
@@ -280,9 +282,13 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop
() => loadHiddenBuiltinTemplateIds(), () => loadHiddenBuiltinTemplateIds(),
); );
const [templateSaveOpen, setTemplateSaveOpen] = useState(false); const [templateSaveOpen, setTemplateSaveOpen] = useState(false);
const [templateModalMode, setTemplateModalMode] = useState<'add' | 'edit'>('add');
const [templateEditId, setTemplateEditId] = useState<string | null>(null);
const [templateSaveUpdateContent, setTemplateSaveUpdateContent] = useState(false);
const [templateSaveLabel, setTemplateSaveLabel] = useState(''); const [templateSaveLabel, setTemplateSaveLabel] = useState('');
const [templateSaveDesc, setTemplateSaveDesc] = useState(''); const [templateSaveDesc, setTemplateSaveDesc] = useState('');
const [templateSaveLabelError, setTemplateSaveLabelError] = useState(false); const [templateSaveLabelError, setTemplateSaveLabelError] = useState(false);
const [selectedTemplateKey, setSelectedTemplateKey] = useState<string | null>(null);
const [selectedPaletteKey, setSelectedPaletteKey] = useState<string | null>(null); const [selectedPaletteKey, setSelectedPaletteKey] = useState<string | null>(null);
const [stratName, setStratName] = useState(''); const [stratName, setStratName] = useState('');
const [stratDesc, setStratDesc] = useState(''); const [stratDesc, setStratDesc] = useState('');
@@ -1416,24 +1422,87 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop
showSnack('템플릿을 목록에서 제거했습니다'); showSnack('템플릿을 목록에서 제거했습니다');
}, []); }, []);
const openTemplateSaveModal = useCallback(() => { const openTemplateAddModal = useCallback(() => {
const hasContent = encodeConditionForSave(currentEditorState) != null || currentOrphans.length > 0; const hasContent = encodeConditionForSave(currentEditorState) != null || currentOrphans.length > 0;
if (!hasContent) { if (!hasContent) {
showSnack('저장할 조건이 없습니다', false); showSnack('저장할 조건이 없습니다', false);
return; return;
} }
setTemplateModalMode('add');
setTemplateEditId(null);
setTemplateSaveUpdateContent(false);
setTemplateSaveLabel(stratName.trim() || `${signalTab === 'buy' ? '매수' : '매도'} 템플릿`); setTemplateSaveLabel(stratName.trim() || `${signalTab === 'buy' ? '매수' : '매도'} 템플릿`);
setTemplateSaveDesc(''); setTemplateSaveDesc('');
setTemplateSaveLabelError(false); setTemplateSaveLabelError(false);
setTemplateSaveOpen(true); setTemplateSaveOpen(true);
}, [currentEditorState, currentOrphans.length, stratName, signalTab]); }, [currentEditorState, currentOrphans.length, stratName, signalTab]);
const openTemplateEditModal = useCallback(() => {
if (!selectedTemplateKey) {
window.alert('수정할 템플릿을 먼저 선택해 주세요.');
return;
}
const row = templateRows.find(r => r.key === selectedTemplateKey);
if (!row) return;
if (row.source !== 'user' || !row.user) {
window.alert('내 템플릿만 수정할 수 있습니다.');
return;
}
setTemplateModalMode('edit');
setTemplateEditId(row.user.id);
setTemplateSaveUpdateContent(false);
setTemplateSaveLabel(row.user.label);
setTemplateSaveDesc(row.user.description ?? '');
setTemplateSaveLabelError(false);
setTemplateSaveOpen(true);
}, [selectedTemplateKey, templateRows]);
const handleTemplateToolbarDelete = useCallback(() => {
if (!selectedTemplateKey) {
window.alert('삭제할 템플릿을 먼저 선택해 주세요.');
return;
}
const row = templateRows.find(r => r.key === selectedTemplateKey);
if (!row) return;
if (row.source === 'user' && row.user) {
handleDeleteUserTemplate(row.user.id, row.label);
} else if (row.source === 'builtin' && row.builtinId) {
handleDeleteBuiltinTemplate(row.builtinId, row.label);
}
setSelectedTemplateKey(null);
}, [selectedTemplateKey, templateRows, handleDeleteUserTemplate, handleDeleteBuiltinTemplate]);
const handleTemplateApply = useCallback((row: TemplatePaletteRow) => {
if (row.source === 'user' && row.user) applyUserTemplate(row.user);
else if (row.builtin) handleTemplate(row.builtin);
}, [applyUserTemplate, handleTemplate]);
const handleTemplateSaveConfirm = useCallback(() => { const handleTemplateSaveConfirm = useCallback(() => {
const label = templateSaveLabel.trim(); const label = templateSaveLabel.trim();
if (!label) { if (!label) {
setTemplateSaveLabelError(true); setTemplateSaveLabelError(true);
return; return;
} }
if (templateModalMode === 'edit' && templateEditId) {
const patch: Parameters<typeof updateUserStrategyTemplate>[1] = {
label,
description: templateSaveDesc.trim() || undefined,
};
if (templateSaveUpdateContent) {
const hasContent = encodeConditionForSave(currentEditorState) != null || currentOrphans.length > 0;
if (!hasContent) {
showSnack('갱신할 조건이 없습니다', false);
return;
}
patch.signal = signalTab;
patch.editorState = toEditorState(currentRoot, currentLayout);
patch.orphans = currentOrphans;
}
setUserTemplates(updateUserStrategyTemplate(templateEditId, patch));
setTemplateSaveOpen(false);
showSnack(`"${label}" 템플릿을 수정했습니다`);
return;
}
const saved = addUserStrategyTemplate({ const saved = addUserStrategyTemplate({
label, label,
description: templateSaveDesc.trim() || undefined, description: templateSaveDesc.trim() || undefined,
@@ -1442,16 +1511,21 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop
orphans: currentOrphans, orphans: currentOrphans,
}); });
setUserTemplates(prev => [...prev, saved]); setUserTemplates(prev => [...prev, saved]);
setSelectedTemplateKey(saved.id);
setTemplateSaveOpen(false); setTemplateSaveOpen(false);
setRightTab('templates'); setRightTab('templates');
showSnack(`"${saved.label}" 템플릿을 저장했습니다`); showSnack(`"${saved.label}" 템플릿을 저장했습니다`);
}, [ }, [
templateSaveLabel, templateSaveLabel,
templateSaveDesc, templateSaveDesc,
templateModalMode,
templateEditId,
templateSaveUpdateContent,
signalTab, signalTab,
currentRoot, currentRoot,
currentLayout, currentLayout,
currentOrphans, currentOrphans,
currentEditorState,
]); ]);
const handleQuickRun = useCallback(async () => { const handleQuickRun = useCallback(async () => {
@@ -1805,7 +1879,7 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop
<button <button
type="button" type="button"
className="se-btn se-btn--ghost se-btn--sm" className="se-btn se-btn--ghost se-btn--sm"
onClick={openTemplateSaveModal} onClick={openTemplateAddModal}
title="현재 탭(매수/매도) 조건을 템플릿 목록에 저장" title="현재 탭(매수/매도) 조건을 템플릿 목록에 저장"
> >
릿 릿
@@ -2145,64 +2219,17 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop
</> </>
)} )}
{rightTab === 'templates' && ( {rightTab === 'templates' && (
<> <TemplatePaletteTab
<input rows={filteredTemplateRows}
className="se-palette-search" searchQuery={templateSearch}
placeholder="템플릿 검색 (RSI, MA, 매수…)" onSearchChange={setTemplateSearch}
value={templateSearch} selectedKey={selectedTemplateKey}
onChange={e => setTemplateSearch(e.target.value)} onSelectKey={setSelectedTemplateKey}
onApply={handleTemplateApply}
onAdd={openTemplateAddModal}
onEdit={openTemplateEditModal}
onDelete={handleTemplateToolbarDelete}
/> />
<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>
<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);
} 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>
))
)}
</div>
</>
)} )}
</div> </div>
</div> </div>
@@ -2213,10 +2240,15 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop
</div> </div>
{templateSaveOpen && ( {templateSaveOpen && (
<DraggableModalFrame onClose={() => { setTemplateSaveLabelError(false); setTemplateSaveOpen(false); }} title="템플릿 저장"> <DraggableModalFrame
onClose={() => { setTemplateSaveLabelError(false); setTemplateSaveOpen(false); }}
title={templateModalMode === 'edit' ? '템플릿 수정' : '템플릿 저장'}
>
<div className="se-modal-body"> <div className="se-modal-body">
<p className="se-template-save-hint"> <p className="se-template-save-hint">
<strong>{signalTab === 'buy' ? '매수' : '매도'}</strong> 릿 . {templateModalMode === 'edit'
? <> <strong> 릿</strong> · .</>
: <> <strong>{signalTab === 'buy' ? '매수' : '매도'}</strong> 릿 .</>}
</p> </p>
<label className="se-field-lbl">릿 *</label> <label className="se-field-lbl">릿 *</label>
<input <input
@@ -2236,9 +2268,21 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop
rows={2} rows={2}
placeholder="템플릿 설명 (선택)" placeholder="템플릿 설명 (선택)"
/> />
{templateModalMode === 'edit' && (
<label className="se-template-save-update">
<input
type="checkbox"
checked={templateSaveUpdateContent}
onChange={e => setTemplateSaveUpdateContent(e.target.checked)}
/>
<span> {signalTab === 'buy' ? '매수' : '매도'} </span>
</label>
)}
<div className="se-modal-actions"> <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--ghost" onClick={() => setTemplateSaveOpen(false)}></button>
<button type="button" className="se-btn se-btn--gold" onClick={handleTemplateSaveConfirm}></button> <button type="button" className="se-btn se-btn--gold" onClick={handleTemplateSaveConfirm}>
{templateModalMode === 'edit' ? '수정' : '저장'}
</button>
</div> </div>
</div> </div>
</DraggableModalFrame> </DraggableModalFrame>
@@ -0,0 +1,127 @@
import React from 'react';
import type { StrategyTemplateDef } from '../../utils/strategyPresets';
import type { UserStrategyTemplate } from '../../utils/strategyTemplateStorage';
export type TemplatePaletteRow = {
key: string;
source: 'user' | 'builtin';
label: string;
description?: string;
signal: 'buy' | 'sell';
user?: UserStrategyTemplate;
builtinId?: string;
builtin?: StrategyTemplateDef;
};
interface Props {
rows: TemplatePaletteRow[];
searchQuery: string;
onSearchChange: (query: string) => void;
selectedKey: string | null;
onSelectKey: (key: string | null) => void;
onApply: (row: TemplatePaletteRow) => void;
onAdd: () => void;
onEdit: () => void;
onDelete: () => void;
}
export default function TemplatePaletteTab({
rows,
searchQuery,
onSearchChange,
selectedKey,
onSelectKey,
onApply,
onAdd,
onEdit,
onDelete,
}: Props) {
return (
<div className="se-palette-section se-palette-section--templates se-palette-section--scroll">
<div className="se-palette-toolbar">
<span className="se-palette-toolbar-title">릿</span>
<div className="se-palette-toolbar-actions" role="toolbar" aria-label="템플릿 관리">
<button
type="button"
className="se-palette-tool-btn"
title="현재 조건을 템플릿으로 추가"
aria-label="템플릿 추가"
onClick={onAdd}
>
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden>
<path d="M12 5v14M5 12h14" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
</button>
<button
type="button"
className="se-palette-tool-btn"
title="선택 템플릿 수정"
aria-label="템플릿 수정"
disabled={!selectedKey}
onClick={onEdit}
>
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden>
<path d="M4 20h4l10-10-4-4L4 16v4z" stroke="currentColor" strokeWidth="1.75" fill="none" strokeLinejoin="round" />
<path d="M14 6l4 4" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" />
</svg>
</button>
<button
type="button"
className="se-palette-tool-btn se-palette-tool-btn--danger"
title="선택 템플릿 삭제"
aria-label="템플릿 삭제"
disabled={!selectedKey}
onClick={onDelete}
>
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden>
<path d="M6 7h12M9 7V5h6v2M10 11v6M14 11v6M8 7l1 12h6l1-12" stroke="currentColor" strokeWidth="1.75" fill="none" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
</div>
</div>
<input
className="se-palette-search"
placeholder="템플릿 검색 (RSI, MA, 매수…)"
value={searchQuery}
onChange={e => onSearchChange(e.target.value)}
/>
<div className="se-template-list">
{rows.length === 0 ? (
<div className="se-template-empty"> </div>
) : (
rows.map(row => (
<div
key={row.key}
className={`se-template-row${selectedKey === row.key ? ' se-template-row--selected' : ''}`}
onClick={() => onSelectKey(row.key)}
onDoubleClick={() => onApply(row)}
role="button"
tabIndex={0}
onKeyDown={e => {
if (e.key === 'Enter') {
e.preventDefault();
onApply(row);
}
}}
>
<div className="se-template-item">
<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>
</div>
</div>
))
)}
</div>
</div>
);
}
+27 -18
View File
@@ -2250,8 +2250,14 @@
.se-template-row { .se-template-row {
display: flex; display: flex;
align-items: stretch; align-items: stretch;
gap: 4px;
margin-bottom: 6px; margin-bottom: 6px;
border-radius: 8px;
cursor: pointer;
transition: border-color 0.12s, box-shadow 0.12s;
}
.se-template-row--selected .se-template-item {
border-color: var(--se-gold);
box-shadow: 0 0 0 1px color-mix(in srgb, var(--se-gold) 35%, transparent);
} }
.se-template-item { .se-template-item {
display: flex; display: flex;
@@ -2260,36 +2266,27 @@
gap: 6px 8px; gap: 6px 8px;
flex: 1; flex: 1;
min-width: 0; min-width: 0;
width: 100%;
padding: 10px 12px; padding: 10px 12px;
border-radius: 8px; border-radius: 8px;
border: 1px solid var(--se-border); border: 1px solid var(--se-border);
background: var(--se-palette-card-bg); background: var(--se-palette-card-bg);
color: var(--se-text); color: var(--se-text);
cursor: pointer;
text-align: left; text-align: left;
transition: border-color 0.12s, box-shadow 0.12s; transition: border-color 0.12s, box-shadow 0.12s;
} }
.se-template-item:hover { .se-template-row:hover .se-template-item {
border-color: var(--se-palette-hover-border); border-color: var(--se-palette-hover-border);
box-shadow: 0 0 12px color-mix(in srgb, var(--se-gold) 10%, transparent); box-shadow: 0 0 12px color-mix(in srgb, var(--se-gold) 10%, transparent);
} }
.se-template-delete { .se-palette-section--templates {
flex-shrink: 0;
width: 34px;
border: 1px solid var(--se-border);
border-radius: 8px;
background: var(--se-palette-card-bg);
color: var(--se-text-muted);
cursor: pointer;
display: flex; display: flex;
align-items: center; flex-direction: column;
justify-content: center; flex: 1;
transition: color 0.12s, border-color 0.12s, background 0.12s; min-height: 0;
} }
.se-template-delete:hover { .se-palette-section--templates .se-palette-search {
color: var(--se-danger); flex-shrink: 0;
border-color: color-mix(in srgb, var(--se-danger) 45%, var(--se-border));
background: color-mix(in srgb, var(--se-danger) 8%, var(--se-palette-card-bg));
} }
.se-template-label { .se-template-label {
font-size: 0.76rem; font-size: 0.76rem;
@@ -2314,6 +2311,18 @@
color: var(--se-text-muted); color: var(--se-text-muted);
line-height: 1.45; line-height: 1.45;
} }
.se-template-save-update {
display: flex;
align-items: flex-start;
gap: 8px;
margin-top: 12px;
font-size: 0.76rem;
color: var(--se-text-muted);
cursor: pointer;
}
.se-template-save-update input {
margin-top: 2px;
}
.se-template-desc { .se-template-desc {
flex: 1 1 100%; flex: 1 1 100%;
font-size: 0.66rem; font-size: 0.66rem;
@@ -65,6 +65,23 @@ export function deleteUserStrategyTemplate(id: string): UserStrategyTemplate[] {
return templates; return templates;
} }
export function updateUserStrategyTemplate(
id: string,
patch: Partial<Pick<UserStrategyTemplate, 'label' | 'description' | 'signal' | 'editorState' | 'orphans'>>,
): UserStrategyTemplate[] {
const templates = loadUserStrategyTemplates().map(t => {
if (t.id !== id) return t;
return {
...t,
...patch,
...(patch.editorState != null ? { editorState: cloneJson(patch.editorState) } : null),
...(patch.orphans != null ? { orphans: cloneJson(patch.orphans) } : null),
};
});
saveUserStrategyTemplates(templates);
return templates;
}
export function loadHiddenBuiltinTemplateIds(): string[] { export function loadHiddenBuiltinTemplateIds(): string[] {
const raw = getUiPreferences().strategyEditor?.hiddenBuiltinTemplateIds; const raw = getUiPreferences().strategyEditor?.hiddenBuiltinTemplateIds;
if (!Array.isArray(raw)) return []; if (!Array.isArray(raw)) return [];