mobile download

This commit is contained in:
Macbook
2026-05-28 14:44:19 +09:00
parent e2816b037f
commit 3503ef33f5
152 changed files with 11021 additions and 687 deletions
+242 -159
View File
@@ -5,7 +5,8 @@ import React, { useState, useCallback, useEffect, useMemo, useRef } from 'react'
import { ReactFlowProvider } from '@xyflow/react';
import DraggableModalFrame from './DraggableModalFrame';
import type { Theme } from '../types/index';
import { loadStrategies, saveStrategy, deleteStrategy, type StrategyDto as ApiStrategyDto } from '../utils/backendApi';
import { loadStrategies, loadStrategy, saveStrategy, deleteStrategy, type StrategyDto as ApiStrategyDto } from '../utils/backendApi';
import { syncStrategyTimeframesFromLayoutIfNeeded } from '../utils/strategyTimeframeSync';
import type { LogicNode } from '../utils/strategyTypes';
import {
buildStrategyEditorDefFromSettings,
@@ -35,7 +36,7 @@ import {
type EditorConditionState,
} from '../utils/strategyConditionSerde';
import { migrateLogicRootForEditor } from '../utils/strategyConditionNormalize';
import { persistStrategyEvaluationTimeframes } from '../utils/strategyTimeframeSync';
import { persistStrategyEditorState } from '../utils/strategyTimeframeSync';
import {
START_NODE_ID,
defaultStartMeta,
@@ -48,10 +49,10 @@ import {
} from '../utils/strategyPaletteStorage';
import {
emptySignalFlowLayout,
loadStrategyFlowLayout,
migrateStrategyFlowLayout,
saveStrategyFlowLayout,
deleteStrategyFlowLayout,
buildStrategyFlowLayoutStore,
normalizeStrategyFlowLayout,
readLegacyFlowLayoutFromLocalStorage,
clearLegacyFlowLayoutFromLocalStorage,
type SignalFlowLayoutSnapshot,
type FlowLayoutChangePayload,
type StrategyFlowLayoutStore,
@@ -97,21 +98,6 @@ interface Props {
theme: Theme;
}
function readTabLayout(strategyKey: string, tab: 'buy' | 'sell'): SignalFlowLayoutSnapshot {
const stored = loadStrategyFlowLayout(strategyKey);
const snap = stored?.[tab];
if (!snap) return emptySignalFlowLayout();
return {
positions: snap.positions ?? {},
edgeHandles: snap.edgeHandles ?? {},
orphans: snap.orphans ?? [],
startMeta: snap.startMeta ?? defaultStartMeta(),
extraStartIds: snap.extraStartIds ?? [],
extraRoots: snap.extraRoots ?? {},
startCombineOp: normalizeStartCombineOp(snap.startCombineOp),
};
}
function toEditorState(
root: LogicNode | null,
layout: Pick<SignalFlowLayoutSnapshot, 'startMeta' | 'extraStartIds' | 'extraRoots' | 'startCombineOp'>,
@@ -136,6 +122,28 @@ function normalizeTabLayoutState(snap: SignalFlowLayoutSnapshot) {
};
}
function applyFlowLayoutFromStore(
stored: StrategyFlowLayoutStore | null,
setBuyLayout: React.Dispatch<React.SetStateAction<ReturnType<typeof normalizeTabLayoutState>>>,
setSellLayout: React.Dispatch<React.SetStateAction<ReturnType<typeof normalizeTabLayoutState>>>,
setBuyOrphans: React.Dispatch<React.SetStateAction<LogicNode[]>>,
setSellOrphans: React.Dispatch<React.SetStateAction<LogicNode[]>>,
) {
if (stored) {
setBuyLayout(normalizeTabLayoutState(stored.buy));
setSellLayout(normalizeTabLayoutState(stored.sell));
setBuyOrphans(stored.buy.orphans ?? []);
setSellOrphans(stored.sell.orphans ?? []);
} else {
const emptyBuy = emptySignalFlowLayout();
const emptySell = emptySignalFlowLayout();
setBuyLayout(normalizeTabLayoutState(emptyBuy));
setSellLayout(normalizeTabLayoutState(emptySell));
setBuyOrphans([]);
setSellOrphans([]);
}
}
export default function StrategyEditorPage({ theme }: Props) {
const { getParams, getVisualConfig, settingsRevision, settingsLoaded } = useIndicatorSettings();
const DEF = useMemo(
@@ -164,44 +172,32 @@ export default function StrategyEditorPage({ theme }: Props) {
const [descOpen, setDescOpen] = useState(false);
const [deleteId, setDeleteId] = useState<number | null>(null);
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
const initialDraftBuy = readTabLayout('draft', 'buy');
const initialDraftSell = readTabLayout('draft', 'sell');
const [buyOrphans, setBuyOrphans] = useState<LogicNode[]>(() => initialDraftBuy.orphans ?? []);
const [sellOrphans, setSellOrphans] = useState<LogicNode[]>(() => initialDraftSell.orphans ?? []);
const [buyOrphans, setBuyOrphans] = useState<LogicNode[]>([]);
const [sellOrphans, setSellOrphans] = useState<LogicNode[]>([]);
const [snack, setSnack] = useState<{ msg: string; ok: boolean } | null>(null);
const [saveToast, setSaveToast] = useState(false);
const [leftWidth, setLeftWidth] = useState(() => readStoredSize('se-left-width', LEFT_PANEL_DEFAULT));
const [terminalHeight, setTerminalHeight] = useState(() => readStoredSize('se-terminal-height', TERMINAL_DEFAULT));
const leftWidthRef = useRef(leftWidth);
const terminalHeightRef = useRef(terminalHeight);
const [buyLayout, setBuyLayout] = useState(() => ({
positions: initialDraftBuy.positions,
edgeHandles: initialDraftBuy.edgeHandles,
startMeta: initialDraftBuy.startMeta ?? defaultStartMeta(),
extraStartIds: initialDraftBuy.extraStartIds ?? [],
extraRoots: initialDraftBuy.extraRoots ?? {},
startCombineOp: normalizeStartCombineOp(initialDraftBuy.startCombineOp),
}));
const [sellLayout, setSellLayout] = useState(() => ({
positions: initialDraftSell.positions,
edgeHandles: initialDraftSell.edgeHandles,
startMeta: initialDraftSell.startMeta ?? defaultStartMeta(),
extraStartIds: initialDraftSell.extraStartIds ?? [],
extraRoots: initialDraftSell.extraRoots ?? {},
startCombineOp: normalizeStartCombineOp(initialDraftSell.startCombineOp),
}));
const [buyLayout, setBuyLayout] = useState(() => normalizeTabLayoutState(emptySignalFlowLayout()));
const [sellLayout, setSellLayout] = useState(() => normalizeTabLayoutState(emptySignalFlowLayout()));
const buyLayoutRef = useRef(buyLayout);
const sellLayoutRef = useRef(sellLayout);
const buyConditionRef = useRef(buyCondition);
const sellConditionRef = useRef(sellCondition);
const buyOrphansRef = useRef(buyOrphans);
const sellOrphansRef = useRef(sellOrphans);
buyLayoutRef.current = buyLayout;
sellLayoutRef.current = sellLayout;
buyConditionRef.current = buyCondition;
sellConditionRef.current = sellCondition;
const timeframeAutosaveRef = useRef<number | null>(null);
buyOrphansRef.current = buyOrphans;
sellOrphansRef.current = sellOrphans;
const layoutRevisionRef = useRef(0);
const persistLayoutTimerRef = useRef<number | null>(null);
const strategyAutosaveTimerRef = useRef<number | null>(null);
const layoutPersistReadyRef = useRef(false);
const STRATEGY_AUTOSAVE_MS = 350;
leftWidthRef.current = leftWidth;
terminalHeightRef.current = terminalHeight;
@@ -268,20 +264,67 @@ export default function StrategyEditorPage({ theme }: Props) {
const layoutStrategyKey = selectedId != null ? String(selectedId) : 'draft';
const persistFlowLayout = useCallback((strategyKey: string) => {
saveStrategyFlowLayout(strategyKey, {
buy: { ...buyLayoutRef.current, orphans: buyOrphans },
sell: { ...sellLayoutRef.current, orphans: sellOrphans },
});
}, [buyOrphans, sellOrphans]);
const buildCurrentFlowLayout = useCallback(
(): StrategyFlowLayoutStore => buildStrategyFlowLayoutStore(
buyLayoutRef.current,
sellLayoutRef.current,
buyOrphansRef.current,
sellOrphansRef.current,
),
[],
);
const schedulePersistFlowLayout = useCallback((strategyKey: string) => {
if (persistLayoutTimerRef.current != null) window.clearTimeout(persistLayoutTimerRef.current);
persistLayoutTimerRef.current = window.setTimeout(() => {
persistLayoutTimerRef.current = null;
persistFlowLayout(strategyKey);
}, 150);
}, [persistFlowLayout]);
const persistStrategyToDb = useCallback(async () => {
if (selectedId == null || !stratName.trim()) return;
const buy = toEditorState(buyConditionRef.current, buyLayoutRef.current);
const sell = toEditorState(sellConditionRef.current, sellLayoutRef.current);
const aligned = alignBuySellStartCandleTypesForSave(buy, sell);
const encodedBuy = encodeConditionForSave(aligned.buy);
const encodedSell = encodeConditionForSave(aligned.sell);
if (!encodedBuy && !encodedSell) return;
const flowLayout = buildCurrentFlowLayout();
const saved = await persistStrategyEditorState(
selectedId,
stratName,
stratDesc,
aligned.buy,
aligned.sell,
flowLayout,
);
setStrategies(prev => prev.map(s => s.id === selectedId
? {
...s,
buyCondition: encodedBuy,
sellCondition: encodedSell,
flowLayout,
updatedAt: saved?.updatedAt ?? s.updatedAt,
}
: s));
}, [selectedId, stratName, stratDesc, buildCurrentFlowLayout]);
const flushStrategyPersist = useCallback(() => {
if (strategyAutosaveTimerRef.current != null) {
window.clearTimeout(strategyAutosaveTimerRef.current);
strategyAutosaveTimerRef.current = null;
}
if (selectedId == null || !stratName.trim()) return;
void persistStrategyToDb().catch(err => {
console.warn('[StrategyEditor] 전략 DB 저장 실패:', err);
});
}, [selectedId, stratName, persistStrategyToDb]);
const scheduleStrategyPersist = useCallback(() => {
if (selectedId == null || !stratName.trim()) return;
if (strategyAutosaveTimerRef.current != null) {
window.clearTimeout(strategyAutosaveTimerRef.current);
}
strategyAutosaveTimerRef.current = window.setTimeout(() => {
strategyAutosaveTimerRef.current = null;
void persistStrategyToDb().catch(err => {
console.warn('[StrategyEditor] 전략 자동저장 실패:', err);
});
}, STRATEGY_AUTOSAVE_MS);
}, [selectedId, stratName, persistStrategyToDb]);
const bumpLayoutSeed = useCallback((strategyKey: string, tab: 'buy' | 'sell') => {
layoutRevisionRef.current += 1;
@@ -318,50 +361,11 @@ export default function StrategyEditorPage({ theme }: Props) {
extraRoots: syncExtraRoots(prev.extraRoots ?? {}, 'sell'),
}));
bumpLayoutSeed(layoutStrategyKey, signalTab);
}, [settingsLoaded, settingsRevision, DEF, layoutStrategyKey, signalTab, bumpLayoutSeed]);
if (selectedId != null && stratName.trim()) scheduleStrategyPersist();
}, [settingsLoaded, settingsRevision, DEF, layoutStrategyKey, signalTab, bumpLayoutSeed, selectedId, stratName, scheduleStrategyPersist]);
const resetFlowLayout = useCallback((strategyKey: string, tab: 'buy' | 'sell' = 'buy') => {
const emptyBuy = emptySignalFlowLayout();
const emptySell = emptySignalFlowLayout();
setBuyLayout(normalizeTabLayoutState(emptyBuy));
setSellLayout(normalizeTabLayoutState(emptySell));
setBuyOrphans([]);
setSellOrphans([]);
saveStrategyFlowLayout(strategyKey, { buy: emptyBuy, sell: emptySell });
bumpLayoutSeed(strategyKey, tab);
}, [bumpLayoutSeed]);
const applyStoredFlowLayout = useCallback((strategyKey: string, tab: 'buy' | 'sell' = 'buy') => {
const stored = loadStrategyFlowLayout(strategyKey);
if (stored) {
const nextBuy = {
positions: stored.buy.positions ?? {},
edgeHandles: stored.buy.edgeHandles ?? {},
startMeta: stored.buy.startMeta ?? defaultStartMeta(),
extraStartIds: stored.buy.extraStartIds ?? [],
extraRoots: stored.buy.extraRoots ?? {},
startCombineOp: normalizeStartCombineOp(stored.buy.startCombineOp),
};
const nextSell = {
positions: stored.sell.positions ?? {},
edgeHandles: stored.sell.edgeHandles ?? {},
startMeta: stored.sell.startMeta ?? defaultStartMeta(),
extraStartIds: stored.sell.extraStartIds ?? [],
extraRoots: stored.sell.extraRoots ?? {},
startCombineOp: normalizeStartCombineOp(stored.sell.startCombineOp),
};
setBuyLayout(nextBuy);
setSellLayout(nextSell);
setBuyOrphans(stored.buy.orphans ?? []);
setSellOrphans(stored.sell.orphans ?? []);
} else {
const emptyBuy = emptySignalFlowLayout();
const emptySell = emptySignalFlowLayout();
setBuyLayout(normalizeTabLayoutState(emptyBuy));
setSellLayout(normalizeTabLayoutState(emptySell));
setBuyOrphans([]);
setSellOrphans([]);
}
applyFlowLayoutFromStore(null, setBuyLayout, setSellLayout, setBuyOrphans, setSellOrphans);
bumpLayoutSeed(strategyKey, tab);
}, [bumpLayoutSeed]);
@@ -372,24 +376,34 @@ export default function StrategyEditorPage({ theme }: Props) {
};
if (snapshot.tab === 'buy') setBuyLayout(prev => ({ ...prev, ...patch }));
else setSellLayout(prev => ({ ...prev, ...patch }));
schedulePersistFlowLayout(layoutStrategyKey);
}, [layoutStrategyKey, schedulePersistFlowLayout]);
scheduleStrategyPersist();
}, [scheduleStrategyPersist]);
const handleRootChange = useCallback((root: LogicNode | null) => {
setCurrentRoot(root);
scheduleStrategyPersist();
}, [setCurrentRoot, scheduleStrategyPersist]);
const handleOrphansChange = useCallback((orphans: LogicNode[]) => {
setCurrentOrphans(orphans);
scheduleStrategyPersist();
}, [setCurrentOrphans, scheduleStrategyPersist]);
const handleStartMetaChange = useCallback((meta: Record<string, import('../utils/strategyStartNodes').StartNodeMeta>) => {
setCurrentLayout(prev => ({ ...prev, startMeta: meta }));
schedulePersistFlowLayout(layoutStrategyKey);
}, [setCurrentLayout, layoutStrategyKey, schedulePersistFlowLayout]);
scheduleStrategyPersist();
}, [setCurrentLayout, scheduleStrategyPersist]);
const handleExtraStartIdsChange = useCallback((ids: string[]) => {
setCurrentLayout(prev => ({ ...prev, extraStartIds: ids }));
schedulePersistFlowLayout(layoutStrategyKey);
scheduleStrategyPersist();
bumpLayoutSeed(layoutStrategyKey, signalTab);
}, [setCurrentLayout, layoutStrategyKey, schedulePersistFlowLayout, bumpLayoutSeed, signalTab]);
}, [setCurrentLayout, layoutStrategyKey, scheduleStrategyPersist, bumpLayoutSeed, signalTab]);
const handleExtraRootsChange = useCallback((roots: Record<string, LogicNode | null>) => {
setCurrentLayout(prev => ({ ...prev, extraRoots: roots }));
schedulePersistFlowLayout(layoutStrategyKey);
}, [setCurrentLayout, layoutStrategyKey, schedulePersistFlowLayout]);
scheduleStrategyPersist();
}, [setCurrentLayout, scheduleStrategyPersist]);
const handleEditorStateChange = useCallback((next: EditorConditionState) => {
setCurrentRoot(next.root);
@@ -400,24 +414,8 @@ export default function StrategyEditorPage({ theme }: Props) {
extraRoots: next.extraRoots,
startCombineOp: normalizeStartCombineOp(next.startCombineOp),
}));
schedulePersistFlowLayout(layoutStrategyKey);
}, [setCurrentRoot, setCurrentLayout, layoutStrategyKey, schedulePersistFlowLayout]);
const scheduleTimeframePersist = useCallback(() => {
if (selectedId == null || !stratName.trim()) return;
if (timeframeAutosaveRef.current != null) window.clearTimeout(timeframeAutosaveRef.current);
const id = selectedId;
const name = stratName;
const desc = stratDesc;
timeframeAutosaveRef.current = window.setTimeout(() => {
timeframeAutosaveRef.current = null;
const buy = toEditorState(buyConditionRef.current, buyLayoutRef.current);
const sell = toEditorState(sellConditionRef.current, sellLayoutRef.current);
void persistStrategyEvaluationTimeframes(id, name, desc, buy, sell).catch(err => {
console.warn('[StrategyEditor] START 분봉 자동저장 실패:', err);
});
}, 700);
}, [selectedId, stratName, stratDesc]);
scheduleStrategyPersist();
}, [setCurrentRoot, setCurrentLayout, scheduleStrategyPersist]);
const handleStartCandleTypesChange = useCallback((startId: string, candleTypes: string[]) => {
if (startId === START_NODE_ID) {
@@ -438,20 +436,16 @@ export default function StrategyEditorPage({ theme }: Props) {
extraRoots: synced.sell.extraRoots,
startCombineOp: normalizeStartCombineOp(synced.sell.startCombineOp),
}));
schedulePersistFlowLayout(layoutStrategyKey);
scheduleTimeframePersist();
scheduleStrategyPersist();
return;
}
const state = signalTab === 'buy' ? buyEditorState : sellEditorState;
handleEditorStateChange(updateStartCandleTypes(state, startId, candleTypes));
scheduleTimeframePersist();
}, [
buyEditorState,
sellEditorState,
handleEditorStateChange,
layoutStrategyKey,
schedulePersistFlowLayout,
scheduleTimeframePersist,
scheduleStrategyPersist,
signalTab,
]);
@@ -461,18 +455,18 @@ export default function StrategyEditorPage({ theme }: Props) {
const switchSignalTab = useCallback((tab: 'buy' | 'sell') => {
if (editorMode === 'graph') layoutFlushRef.current?.();
persistFlowLayout(layoutStrategyKey);
flushStrategyPersist();
layoutRevisionRef.current += 1;
setLayoutSeedKey(`${layoutStrategyKey}:${tab}:${layoutRevisionRef.current}`);
setSignalTab(tab);
setSelectedNodeId(null);
}, [layoutStrategyKey, persistFlowLayout, editorMode]);
}, [layoutStrategyKey, flushStrategyPersist, editorMode]);
const handleEditorModeChange = useCallback((mode: StrategyEditorMode) => {
if (mode === editorMode) return;
if (editorMode === 'graph') {
layoutFlushRef.current?.();
persistFlowLayout(layoutStrategyKey);
flushStrategyPersist();
}
if (mode === 'list' && (buyOrphans.length > 0 || sellOrphans.length > 0)) {
showSnack('목록 방식에서는 미연결(고아) 노드가 표시되지 않습니다', false);
@@ -480,7 +474,7 @@ export default function StrategyEditorPage({ theme }: Props) {
setEditorMode(mode);
saveEditorMode(mode);
setSelectedNodeId(null);
}, [editorMode, layoutStrategyKey, persistFlowLayout, buyOrphans.length, sellOrphans.length]);
}, [editorMode, flushStrategyPersist, buyOrphans.length, sellOrphans.length]);
const layoutSeed = useMemo((): FlowLayoutSeed => {
const source = signalTab === 'buy' ? buyLayout : sellLayout;
@@ -491,17 +485,29 @@ export default function StrategyEditorPage({ theme }: Props) {
};
}, [signalTab, layoutSeedKey, buyLayout, sellLayout]);
useEffect(() => () => {
if (persistLayoutTimerRef.current != null) window.clearTimeout(persistLayoutTimerRef.current);
}, []);
const persistStrategyToDbRef = useRef(persistStrategyToDb);
persistStrategyToDbRef.current = persistStrategyToDb;
useEffect(() => {
const id = selectedId;
const name = stratName;
return () => {
if (strategyAutosaveTimerRef.current != null) {
window.clearTimeout(strategyAutosaveTimerRef.current);
strategyAutosaveTimerRef.current = null;
}
if (id != null && name.trim()) {
void persistStrategyToDbRef.current().catch(() => { /* unmount flush */ });
}
};
}, [selectedId, stratName]);
useEffect(() => {
if (!layoutPersistReadyRef.current) {
layoutPersistReadyRef.current = true;
return;
}
schedulePersistFlowLayout(layoutStrategyKey);
}, [buyOrphans, sellOrphans, layoutStrategyKey, schedulePersistFlowLayout]);
scheduleStrategyPersist();
}, [buyOrphans, sellOrphans, scheduleStrategyPersist]);
useEffect(() => { saveStratsLocal(strategies); }, [strategies]);
@@ -514,6 +520,7 @@ export default function StrategyEditorPage({ theme }: Props) {
description: s.description,
buyCondition: s.buyCondition as LogicNode | null ?? null,
sellCondition: s.sellCondition as LogicNode | null ?? null,
flowLayout: normalizeStrategyFlowLayout(s.flowLayout) ?? undefined,
enabled: s.enabled ?? true,
createdAt: s.createdAt,
updatedAt: s.updatedAt,
@@ -553,7 +560,10 @@ export default function StrategyEditorPage({ theme }: Props) {
const handleSelectStrategy = (s: StrategyDto) => {
const buyDecoded = decodeConditionForEditor(s.buyCondition ?? null);
const sellDecoded = decodeConditionForEditor(s.sellCondition ?? null);
const stored = loadStrategyFlowLayout(String(s.id));
let stored = normalizeStrategyFlowLayout(s.flowLayout);
if (!stored) {
stored = readLegacyFlowLayoutFromLocalStorage(String(s.id));
}
const buyMigrated = migrateLogicRootForEditor(buyDecoded.root, stored?.buy.orphans ?? [], DEF, 'buy');
const sellMigrated = migrateLogicRootForEditor(sellDecoded.root, stored?.sell.orphans ?? [], DEF, 'sell');
@@ -600,6 +610,58 @@ export default function StrategyEditorPage({ theme }: Props) {
});
setSelectedNodeId(null);
bumpLayoutSeed(String(s.id), signalTab);
if (!s.flowLayout && stored) {
void (async () => {
try {
const aligned = alignBuySellStartCandleTypesForSave(
toEditorState(buySynced.root, {
startMeta: mergeStartMetaForLoad(buyDecoded.startMeta, stored.buy.startMeta),
extraStartIds: stored.buy.extraStartIds ?? buyDecoded.extraStartIds,
extraRoots: stored.buy.extraRoots ?? buyDecoded.extraRoots,
startCombineOp: normalizeStartCombineOp(stored.buy.startCombineOp ?? buyDecoded.startCombineOp),
}),
toEditorState(sellSynced.root, {
startMeta: mergeStartMetaForLoad(sellDecoded.startMeta, stored.sell.startMeta),
extraStartIds: stored.sell.extraStartIds ?? sellDecoded.extraStartIds,
extraRoots: stored.sell.extraRoots ?? sellDecoded.extraRoots,
startCombineOp: normalizeStartCombineOp(stored.sell.startCombineOp ?? sellDecoded.startCombineOp),
}),
);
await saveStrategy({
id: s.id,
name: s.name,
description: s.description ?? '',
buyCondition: encodeConditionForSave(aligned.buy),
sellCondition: encodeConditionForSave(aligned.sell),
flowLayout: stored,
enabled: s.enabled ?? true,
});
clearLegacyFlowLayoutFromLocalStorage(String(s.id));
setStrategies(prev => prev.map(x => x.id === s.id ? { ...x, flowLayout: stored! } : x));
} catch {
/* ignore */
}
})();
}
// flow layout START 분봉이 DB DSL보다 최신이면 서버에 반영 (실시간 평가는 DB 기준)
void (async () => {
const synced = await syncStrategyTimeframesFromLayoutIfNeeded(s.id, { ...s, flowLayout: stored ?? s.flowLayout });
if (!synced) return;
try {
const fresh = await loadStrategy(s.id);
if (!fresh) return;
const buyD = decodeConditionForEditor((fresh.buyCondition ?? null) as LogicNode | null);
const sellD = decodeConditionForEditor((fresh.sellCondition ?? null) as LogicNode | null);
const buyM = migrateLogicRootForEditor(buyD.root, buySynced.orphans, DEF, 'buy');
const sellM = migrateLogicRootForEditor(sellD.root, sellSynced.orphans, DEF, 'sell');
setBuyCondition(syncLogicRootWithGlobalDef(buyM.root, buyM.orphans, DEF, 'buy').root);
setSellCondition(syncLogicRootWithGlobalDef(sellM.root, sellM.orphans, DEF, 'sell').root);
} catch {
/* ignore */
}
})();
};
const handleNew = () => {
@@ -620,39 +682,59 @@ export default function StrategyEditorPage({ theme }: Props) {
}
setSaveNameError(false);
if (editorMode === 'graph') layoutFlushRef.current?.();
if (strategyAutosaveTimerRef.current != null) {
window.clearTimeout(strategyAutosaveTimerRef.current);
strategyAutosaveTimerRef.current = null;
}
const aligned = alignBuySellStartCandleTypesForSave(buyEditorState, sellEditorState);
const encodedBuy = encodeConditionForSave(aligned.buy);
const encodedSell = encodeConditionForSave(aligned.sell);
if (!encodedBuy && !encodedSell) { showSnack('조건을 최소 1개 추가하세요', false); return; }
setIsSaving(true);
try {
const flowLayout = buildCurrentFlowLayout();
const payload: ApiStrategyDto = {
id: selectedId ?? undefined,
name: stratName,
description: stratDesc,
buyCondition: encodedBuy,
sellCondition: encodedSell,
flowLayout,
enabled: true,
};
const saved = await saveStrategy(payload);
const now = new Date().toISOString();
const dbId = saved?.id ?? selectedId ?? Date.now();
const wasDraft = selectedId == null;
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, updatedAt: saved?.updatedAt ?? now }
? {
...s,
id: dbId,
name: stratName,
description: stratDesc,
buyCondition: encodedBuy,
sellCondition: encodedSell,
flowLayout,
updatedAt: saved?.updatedAt ?? now,
}
: s);
}
setSelectedId(dbId);
return [...prev, { id: dbId, name: stratName, description: stratDesc, buyCondition: encodedBuy, sellCondition: encodedSell, enabled: true, createdAt: saved?.createdAt ?? now, updatedAt: saved?.updatedAt ?? now }];
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);
if (wasDraft) {
migrateStrategyFlowLayout('draft', String(dbId));
}
persistFlowLayout(String(dbId));
bumpLayoutSeed(String(dbId), signalTab);
setSaveOpen(false);
setSaveToast(true);
@@ -668,7 +750,7 @@ export default function StrategyEditorPage({ theme }: Props) {
const handleDeleteConfirm = async () => {
if (!deleteId) return;
try { await deleteStrategy(deleteId); } catch { /* local */ }
deleteStrategyFlowLayout(String(deleteId));
clearLegacyFlowLayoutFromLocalStorage(String(deleteId));
setStrategies(prev => prev.filter(s => s.id !== deleteId));
if (selectedId === deleteId) handleNew();
setDeleteOpen(false);
@@ -696,7 +778,6 @@ export default function StrategyEditorPage({ theme }: Props) {
});
setBuyOrphans(layout.buy.orphans ?? []);
setSellOrphans(layout.sell.orphans ?? []);
saveStrategyFlowLayout('draft', layout);
} else {
resetFlowLayout('draft', tab);
}
@@ -773,7 +854,7 @@ export default function StrategyEditorPage({ theme }: Props) {
}
const items = strategies.map(s => strategyDtoToListExportItem(
s,
loadStrategyFlowLayout(String(s.id)),
s.flowLayout ?? null,
));
downloadStrategyJson(
`전략목록_${new Date().toISOString().slice(0, 10)}`,
@@ -794,7 +875,6 @@ export default function StrategyEditorPage({ theme }: Props) {
const baseId = Date.now();
const imported = result.data.strategies.map((item, index) => {
const id = baseId + index;
if (item.flowLayout) saveStrategyFlowLayout(String(id), item.flowLayout);
return listImportItemToStrategyDto(item, id);
});
setStrategies(prev => [...prev, ...imported]);
@@ -819,7 +899,8 @@ export default function StrategyEditorPage({ theme }: Props) {
if (!root) setCurrentRoot(newNode);
else if (type === 'operator') setCurrentRoot(mergeAtRoot(root, newNode, true));
else setCurrentRoot(mergeAtRoot(root, newNode, false));
}, [signalTab, DEF, currentRoot, setCurrentRoot, handleAddStartSection]);
scheduleStrategyPersist();
}, [signalTab, DEF, currentRoot, setCurrentRoot, handleAddStartSection, scheduleStrategyPersist]);
const applyPaletteItem = useCallback((item: PaletteItem) => {
const composite = item.kind === 'composite';
@@ -827,7 +908,8 @@ export default function StrategyEditorPage({ theme }: Props) {
const root = currentRoot;
if (!root) setCurrentRoot(newNode);
else setCurrentRoot(mergeAtRoot(root, newNode, false));
}, [signalTab, DEF, currentRoot, setCurrentRoot]);
scheduleStrategyPersist();
}, [signalTab, DEF, currentRoot, setCurrentRoot, scheduleStrategyPersist]);
const templates = useMemo(() => getStrategyTemplates(DEF), [DEF]);
@@ -846,9 +928,10 @@ export default function StrategyEditorPage({ theme }: Props) {
resetFlowLayout(layoutStrategyKey, targetTab);
if (targetTab !== signalTab) setSignalTab(targetTab);
scheduleStrategyPersist();
showSnack(`"${tmpl.label}" 템플릿이 적용됐습니다`);
}, [editorMode, layoutStrategyKey, resetFlowLayout, signalTab]);
}, [editorMode, layoutStrategyKey, resetFlowLayout, signalTab, scheduleStrategyPersist]);
const operators = [
{ type: 'operator' as const, value: 'AND', label: 'AND', color: 'logic-and' },
@@ -1068,10 +1151,10 @@ export default function StrategyEditorPage({ theme }: Props) {
theme={theme}
root={currentRoot}
orphans={currentOrphans}
onOrphansChange={setCurrentOrphans}
onOrphansChange={handleOrphansChange}
def={DEF}
signalTab={signalTab}
onChange={setCurrentRoot}
onChange={handleRootChange}
selectedNodeId={selectedNodeId}
onSelectNode={setSelectedNodeId}
layoutSeed={layoutSeed}
@@ -1097,14 +1180,14 @@ export default function StrategyEditorPage({ theme }: Props) {
if (!selectedNodeId) return;
const inOrphans = currentOrphans.some(o => o.id === selectedNodeId);
if (inOrphans) {
setCurrentOrphans(currentOrphans.map(o => (
handleOrphansChange(currentOrphans.map(o => (
o.id === selectedNodeId ? updateNode(o, selectedNodeId, n => ({ ...n, condition: c })) : o
)));
return;
}
if (currentRoot && findLogicNode(currentRoot, currentOrphans, selectedNodeId, currentLayout.extraRoots ?? {})) {
if (findLogicNode(currentRoot, [], selectedNodeId)) {
setCurrentRoot(updateNode(currentRoot, selectedNodeId, n => ({ ...n, condition: c })));
handleRootChange(updateNode(currentRoot, selectedNodeId, n => ({ ...n, condition: c })));
return;
}
for (const [startId, branch] of Object.entries(currentLayout.extraRoots ?? {})) {
@@ -1131,7 +1214,7 @@ export default function StrategyEditorPage({ theme }: Props) {
onEditorStateChange={handleEditorStateChange}
onAddStart={handleAddStartSection}
orphans={currentOrphans}
onOrphansChange={setCurrentOrphans}
onOrphansChange={handleOrphansChange}
/>
)}