|
|
@@ -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>
|
|
|
|