mobile download
This commit is contained in:
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user