다중 시간봉 전략 설정기능 추가
This commit is contained in:
@@ -19,6 +19,19 @@ import {
|
||||
type StrategyDto,
|
||||
} from '../utils/strategyEditorShared';
|
||||
import { findLogicNode, getIndicatorPeriodLabel } from '../utils/strategyFlowLayout';
|
||||
import {
|
||||
decodeConditionForEditor,
|
||||
encodeConditionForSave,
|
||||
addExtraStartSection,
|
||||
hasMultipleStartSections,
|
||||
normalizeStartCombineOp,
|
||||
updateStartCombineOp,
|
||||
type EditorConditionState,
|
||||
} from '../utils/strategyConditionSerde';
|
||||
import {
|
||||
defaultStartMeta,
|
||||
type StartCombineOp,
|
||||
} from '../utils/strategyStartNodes';
|
||||
import {
|
||||
COMPOSITE_INDICATOR_ITEMS,
|
||||
compositePeriodLabel,
|
||||
@@ -36,6 +49,7 @@ import {
|
||||
import LogicExpressionPreview from './strategyEditor/LogicExpressionPreview';
|
||||
import StrategyEditorCanvas, { type FlowLayoutSeed } from './strategyEditor/StrategyEditorCanvas';
|
||||
import StrategyListEditor from './strategyEditor/StrategyListEditor';
|
||||
import StartCombineOpControl from './strategyEditor/StartCombineOpControl';
|
||||
import { layoutFlushRef } from './strategyEditor/strategyEditorCallbacks';
|
||||
import {
|
||||
loadEditorMode,
|
||||
@@ -80,6 +94,34 @@ function readTabLayout(strategyKey: string, tab: 'buy' | 'sell'): SignalFlowLayo
|
||||
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'>,
|
||||
): EditorConditionState {
|
||||
return {
|
||||
root,
|
||||
startMeta: layout.startMeta ?? defaultStartMeta(),
|
||||
extraStartIds: layout.extraStartIds ?? [],
|
||||
extraRoots: layout.extraRoots ?? {},
|
||||
startCombineOp: normalizeStartCombineOp(layout.startCombineOp),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeTabLayoutState(snap: SignalFlowLayoutSnapshot) {
|
||||
return {
|
||||
positions: snap.positions ?? {},
|
||||
edgeHandles: snap.edgeHandles ?? {},
|
||||
startMeta: snap.startMeta ?? defaultStartMeta(),
|
||||
extraStartIds: snap.extraStartIds ?? [],
|
||||
extraRoots: snap.extraRoots ?? {},
|
||||
startCombineOp: normalizeStartCombineOp(snap.startCombineOp),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -114,10 +156,18 @@ export default function StrategyEditorPage({ theme }: Props) {
|
||||
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 buyLayoutRef = useRef(buyLayout);
|
||||
const sellLayoutRef = useRef(sellLayout);
|
||||
@@ -165,10 +215,28 @@ export default function StrategyEditorPage({ theme }: Props) {
|
||||
const currentOrphans = signalTab === 'buy' ? buyOrphans : sellOrphans;
|
||||
const setCurrentOrphans = signalTab === 'buy' ? setBuyOrphans : setSellOrphans;
|
||||
|
||||
const currentLayout = signalTab === 'buy' ? buyLayout : sellLayout;
|
||||
const setCurrentLayout = signalTab === 'buy' ? setBuyLayout : setSellLayout;
|
||||
|
||||
const buyEditorState = useMemo(
|
||||
() => toEditorState(buyCondition, buyLayout),
|
||||
[buyCondition, buyLayout],
|
||||
);
|
||||
const sellEditorState = useMemo(
|
||||
() => toEditorState(sellCondition, sellLayout),
|
||||
[sellCondition, sellLayout],
|
||||
);
|
||||
const currentEditorState = signalTab === 'buy' ? buyEditorState : sellEditorState;
|
||||
|
||||
const selectedLogicNode = useMemo(() => {
|
||||
if (!selectedNodeId) return null;
|
||||
return findLogicNode(currentRoot, currentOrphans, selectedNodeId);
|
||||
}, [selectedNodeId, currentRoot, currentOrphans]);
|
||||
return findLogicNode(
|
||||
currentRoot,
|
||||
currentOrphans,
|
||||
selectedNodeId,
|
||||
currentLayout.extraRoots ?? {},
|
||||
);
|
||||
}, [selectedNodeId, currentRoot, currentOrphans, currentLayout.extraRoots]);
|
||||
|
||||
const orphanTotal = buyOrphans.length + sellOrphans.length;
|
||||
|
||||
@@ -197,8 +265,8 @@ export default function StrategyEditorPage({ theme }: Props) {
|
||||
const resetFlowLayout = useCallback((strategyKey: string, tab: 'buy' | 'sell' = 'buy') => {
|
||||
const emptyBuy = emptySignalFlowLayout();
|
||||
const emptySell = emptySignalFlowLayout();
|
||||
setBuyLayout(emptyBuy);
|
||||
setSellLayout(emptySell);
|
||||
setBuyLayout(normalizeTabLayoutState(emptyBuy));
|
||||
setSellLayout(normalizeTabLayoutState(emptySell));
|
||||
setBuyOrphans([]);
|
||||
setSellOrphans([]);
|
||||
saveStrategyFlowLayout(strategyKey, { buy: emptyBuy, sell: emptySell });
|
||||
@@ -211,10 +279,18 @@ export default function StrategyEditorPage({ theme }: Props) {
|
||||
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);
|
||||
@@ -223,8 +299,8 @@ export default function StrategyEditorPage({ theme }: Props) {
|
||||
} else {
|
||||
const emptyBuy = emptySignalFlowLayout();
|
||||
const emptySell = emptySignalFlowLayout();
|
||||
setBuyLayout(emptyBuy);
|
||||
setSellLayout(emptySell);
|
||||
setBuyLayout(normalizeTabLayoutState(emptyBuy));
|
||||
setSellLayout(normalizeTabLayoutState(emptySell));
|
||||
setBuyOrphans([]);
|
||||
setSellOrphans([]);
|
||||
}
|
||||
@@ -232,15 +308,47 @@ export default function StrategyEditorPage({ theme }: Props) {
|
||||
}, [bumpLayoutSeed]);
|
||||
|
||||
const handleLayoutChange = useCallback((snapshot: FlowLayoutChangePayload) => {
|
||||
const next = {
|
||||
const patch = {
|
||||
positions: snapshot.positions,
|
||||
edgeHandles: snapshot.edgeHandles,
|
||||
};
|
||||
if (snapshot.tab === 'buy') setBuyLayout(next);
|
||||
else setSellLayout(next);
|
||||
if (snapshot.tab === 'buy') setBuyLayout(prev => ({ ...prev, ...patch }));
|
||||
else setSellLayout(prev => ({ ...prev, ...patch }));
|
||||
schedulePersistFlowLayout(layoutStrategyKey);
|
||||
}, [layoutStrategyKey, schedulePersistFlowLayout]);
|
||||
|
||||
const handleStartMetaChange = useCallback((meta: Record<string, { candleType: string }>) => {
|
||||
setCurrentLayout(prev => ({ ...prev, startMeta: meta }));
|
||||
schedulePersistFlowLayout(layoutStrategyKey);
|
||||
}, [setCurrentLayout, layoutStrategyKey, schedulePersistFlowLayout]);
|
||||
|
||||
const handleExtraStartIdsChange = useCallback((ids: string[]) => {
|
||||
setCurrentLayout(prev => ({ ...prev, extraStartIds: ids }));
|
||||
schedulePersistFlowLayout(layoutStrategyKey);
|
||||
bumpLayoutSeed(layoutStrategyKey, signalTab);
|
||||
}, [setCurrentLayout, layoutStrategyKey, schedulePersistFlowLayout, bumpLayoutSeed, signalTab]);
|
||||
|
||||
const handleExtraRootsChange = useCallback((roots: Record<string, LogicNode | null>) => {
|
||||
setCurrentLayout(prev => ({ ...prev, extraRoots: roots }));
|
||||
schedulePersistFlowLayout(layoutStrategyKey);
|
||||
}, [setCurrentLayout, layoutStrategyKey, schedulePersistFlowLayout]);
|
||||
|
||||
const handleEditorStateChange = useCallback((next: EditorConditionState) => {
|
||||
setCurrentRoot(next.root);
|
||||
setCurrentLayout(prev => ({
|
||||
...prev,
|
||||
startMeta: next.startMeta,
|
||||
extraStartIds: next.extraStartIds,
|
||||
extraRoots: next.extraRoots,
|
||||
startCombineOp: normalizeStartCombineOp(next.startCombineOp),
|
||||
}));
|
||||
schedulePersistFlowLayout(layoutStrategyKey);
|
||||
}, [setCurrentRoot, setCurrentLayout, layoutStrategyKey, schedulePersistFlowLayout]);
|
||||
|
||||
const handleStartCombineOpChange = useCallback((op: StartCombineOp) => {
|
||||
handleEditorStateChange(updateStartCombineOp(currentEditorState, op));
|
||||
}, [handleEditorStateChange, currentEditorState]);
|
||||
|
||||
const switchSignalTab = useCallback((tab: 'buy' | 'sell') => {
|
||||
if (editorMode === 'graph') layoutFlushRef.current?.();
|
||||
persistFlowLayout(layoutStrategyKey);
|
||||
@@ -333,13 +441,39 @@ 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));
|
||||
|
||||
setSelectedId(s.id);
|
||||
setStratName(s.name);
|
||||
setStratDesc(s.description ?? '');
|
||||
setBuyCondition(s.buyCondition ?? null);
|
||||
setSellCondition(s.sellCondition ?? null);
|
||||
setBuyCondition(buyDecoded.root);
|
||||
setSellCondition(sellDecoded.root);
|
||||
setBuyOrphans(stored?.buy.orphans ?? []);
|
||||
setSellOrphans(stored?.sell.orphans ?? []);
|
||||
setBuyLayout({
|
||||
positions: stored?.buy.positions ?? {},
|
||||
edgeHandles: stored?.buy.edgeHandles ?? {},
|
||||
startMeta: stored?.buy.startMeta ?? buyDecoded.startMeta,
|
||||
extraStartIds: stored?.buy.extraStartIds?.length ? stored.buy.extraStartIds : buyDecoded.extraStartIds,
|
||||
extraRoots: stored?.buy.extraRoots && Object.keys(stored.buy.extraRoots).length
|
||||
? stored.buy.extraRoots
|
||||
: buyDecoded.extraRoots,
|
||||
startCombineOp: normalizeStartCombineOp(stored?.buy.startCombineOp ?? buyDecoded.startCombineOp),
|
||||
});
|
||||
setSellLayout({
|
||||
positions: stored?.sell.positions ?? {},
|
||||
edgeHandles: stored?.sell.edgeHandles ?? {},
|
||||
startMeta: stored?.sell.startMeta ?? sellDecoded.startMeta,
|
||||
extraStartIds: stored?.sell.extraStartIds?.length ? stored.sell.extraStartIds : sellDecoded.extraStartIds,
|
||||
extraRoots: stored?.sell.extraRoots && Object.keys(stored.sell.extraRoots).length
|
||||
? stored.sell.extraRoots
|
||||
: sellDecoded.extraRoots,
|
||||
startCombineOp: normalizeStartCombineOp(stored?.sell.startCombineOp ?? sellDecoded.startCombineOp),
|
||||
});
|
||||
setSelectedNodeId(null);
|
||||
applyStoredFlowLayout(String(s.id), signalTab);
|
||||
bumpLayoutSeed(String(s.id), signalTab);
|
||||
};
|
||||
|
||||
const handleNew = () => {
|
||||
@@ -354,15 +488,17 @@ export default function StrategyEditorPage({ theme }: Props) {
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!stratName.trim()) { showSnack('전략 이름을 입력하세요', false); return; }
|
||||
if (!buyCondition && !sellCondition) { showSnack('조건을 최소 1개 추가하세요', false); return; }
|
||||
const encodedBuy = encodeConditionForSave(buyEditorState);
|
||||
const encodedSell = encodeConditionForSave(sellEditorState);
|
||||
if (!encodedBuy && !encodedSell) { showSnack('조건을 최소 1개 추가하세요', false); return; }
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const payload: ApiStrategyDto = {
|
||||
id: selectedId ?? undefined,
|
||||
name: stratName,
|
||||
description: stratDesc,
|
||||
buyCondition,
|
||||
sellCondition,
|
||||
buyCondition: encodedBuy,
|
||||
sellCondition: encodedSell,
|
||||
enabled: true,
|
||||
};
|
||||
const saved = await saveStrategy(payload);
|
||||
@@ -373,11 +509,11 @@ export default function StrategyEditorPage({ theme }: Props) {
|
||||
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, sellCondition, updatedAt: saved?.updatedAt ?? now }
|
||||
? { ...s, id: dbId, name: stratName, description: stratDesc, buyCondition: encodedBuy, sellCondition: encodedSell, updatedAt: saved?.updatedAt ?? now }
|
||||
: s);
|
||||
}
|
||||
setSelectedId(dbId);
|
||||
return [...prev, { id: dbId, name: stratName, description: stratDesc, buyCondition, sellCondition, enabled: true, createdAt: saved?.createdAt ?? now, updatedAt: saved?.updatedAt ?? now }];
|
||||
return [...prev, { id: dbId, name: stratName, description: stratDesc, buyCondition: encodedBuy, sellCondition: encodedSell, enabled: true, createdAt: saved?.createdAt ?? now, updatedAt: saved?.updatedAt ?? now }];
|
||||
});
|
||||
if (!selectedId) setSelectedId(dbId);
|
||||
if (wasDraft) {
|
||||
@@ -412,10 +548,18 @@ export default function StrategyEditorPage({ theme }: Props) {
|
||||
setBuyLayout({
|
||||
positions: layout.buy.positions ?? {},
|
||||
edgeHandles: layout.buy.edgeHandles ?? {},
|
||||
startMeta: layout.buy.startMeta ?? defaultStartMeta(),
|
||||
extraStartIds: layout.buy.extraStartIds ?? [],
|
||||
extraRoots: layout.buy.extraRoots ?? {},
|
||||
startCombineOp: normalizeStartCombineOp(layout.buy.startCombineOp),
|
||||
});
|
||||
setSellLayout({
|
||||
positions: layout.sell.positions ?? {},
|
||||
edgeHandles: layout.sell.edgeHandles ?? {},
|
||||
startMeta: layout.sell.startMeta ?? defaultStartMeta(),
|
||||
extraStartIds: layout.sell.extraStartIds ?? [],
|
||||
extraRoots: layout.sell.extraRoots ?? {},
|
||||
startCombineOp: normalizeStartCombineOp(layout.sell.startCombineOp),
|
||||
});
|
||||
setBuyOrphans(layout.buy.orphans ?? []);
|
||||
setSellOrphans(layout.sell.orphans ?? []);
|
||||
@@ -427,7 +571,9 @@ export default function StrategyEditorPage({ theme }: Props) {
|
||||
}, [resetFlowLayout, bumpLayoutSeed, signalTab]);
|
||||
|
||||
const handleExport = useCallback(() => {
|
||||
if (!buyCondition && !sellCondition) {
|
||||
const encodedBuy = encodeConditionForSave(buyEditorState);
|
||||
const encodedSell = encodeConditionForSave(sellEditorState);
|
||||
if (!encodedBuy && !encodedSell) {
|
||||
showSnack('내보낼 조건이 없습니다', false);
|
||||
return;
|
||||
}
|
||||
@@ -435,8 +581,8 @@ export default function StrategyEditorPage({ theme }: Props) {
|
||||
const payload = buildStrategyExportPayload({
|
||||
name: stratName,
|
||||
description: stratDesc,
|
||||
buyCondition,
|
||||
sellCondition,
|
||||
buyCondition: encodedBuy,
|
||||
sellCondition: encodedSell,
|
||||
flowLayout: {
|
||||
buy: { ...buyLayoutRef.current, orphans: buyOrphans },
|
||||
sell: { ...sellLayoutRef.current, orphans: sellOrphans },
|
||||
@@ -446,7 +592,7 @@ export default function StrategyEditorPage({ theme }: Props) {
|
||||
const safeName = (stratName || '전략').replace(/[^\w\uAC00-\uD7A3-]+/g, '_');
|
||||
downloadStrategyJson(`${safeName}_${new Date().toISOString().slice(0, 10)}`, payload);
|
||||
showSnack('JSON으로 내보냈습니다');
|
||||
}, [buyCondition, sellCondition, stratName, stratDesc, buyOrphans, sellOrphans, editorMode]);
|
||||
}, [buyEditorState, sellEditorState, stratName, stratDesc, buyOrphans, sellOrphans, editorMode]);
|
||||
|
||||
const handleImport = useCallback(async () => {
|
||||
const text = await pickJsonFile();
|
||||
@@ -459,13 +605,23 @@ export default function StrategyEditorPage({ theme }: Props) {
|
||||
}
|
||||
if (editorMode === 'graph') layoutFlushRef.current?.();
|
||||
const { data } = result;
|
||||
setBuyCondition(data.buyCondition ?? null);
|
||||
setSellCondition(data.sellCondition ?? null);
|
||||
const buyDecoded = decodeConditionForEditor(data.buyCondition ?? null);
|
||||
const sellDecoded = decodeConditionForEditor(data.sellCondition ?? null);
|
||||
setBuyCondition(buyDecoded.root);
|
||||
setSellCondition(sellDecoded.root);
|
||||
setStratName(data.name ?? '가져온 전략');
|
||||
setStratDesc(data.description ?? '');
|
||||
setSelectedId(null);
|
||||
setSelectedNodeId(null);
|
||||
applyImportedFlowLayout(data.flowLayout);
|
||||
setBuyLayout(prev => ({
|
||||
...prev,
|
||||
startCombineOp: normalizeStartCombineOp(data.flowLayout?.buy?.startCombineOp ?? buyDecoded.startCombineOp),
|
||||
}));
|
||||
setSellLayout(prev => ({
|
||||
...prev,
|
||||
startCombineOp: normalizeStartCombineOp(data.flowLayout?.sell?.startCombineOp ?? sellDecoded.startCombineOp),
|
||||
}));
|
||||
if (data.editorMode === 'list' || data.editorMode === 'graph') {
|
||||
setEditorMode(data.editorMode);
|
||||
saveEditorMode(data.editorMode);
|
||||
@@ -514,13 +670,22 @@ export default function StrategyEditorPage({ theme }: Props) {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleAddStartSection = useCallback(() => {
|
||||
handleEditorStateChange(addExtraStartSection(currentEditorState));
|
||||
bumpLayoutSeed(layoutStrategyKey, signalTab);
|
||||
}, [handleEditorStateChange, currentEditorState, bumpLayoutSeed, layoutStrategyKey, signalTab]);
|
||||
|
||||
const applyPalette = useCallback((type: string, value: string, _label: string, composite = false) => {
|
||||
if (type === 'start') {
|
||||
handleAddStartSection();
|
||||
return;
|
||||
}
|
||||
const newNode = makeNode(type, value, signalTab, DEF, composite ? { composite: true } : undefined);
|
||||
const root = currentRoot;
|
||||
if (!root) setCurrentRoot(newNode);
|
||||
else if (type === 'operator') setCurrentRoot(mergeAtRoot(root, newNode, true));
|
||||
else setCurrentRoot(mergeAtRoot(root, newNode, false));
|
||||
}, [signalTab, DEF, currentRoot, setCurrentRoot]);
|
||||
}, [signalTab, DEF, currentRoot, setCurrentRoot, handleAddStartSection]);
|
||||
|
||||
const templates = useMemo(() => getStrategyTemplates(DEF), [DEF]);
|
||||
|
||||
@@ -725,7 +890,7 @@ export default function StrategyEditorPage({ theme }: Props) {
|
||||
<div className="se-main-row">
|
||||
<main className="se-center">
|
||||
<div className="se-center-panel">
|
||||
<div className="se-center-work">
|
||||
<div className={`se-center-work${editorMode === 'list' ? ' se-center-work--list' : ''}`}>
|
||||
<div className="se-center-head">
|
||||
<div className="se-signal-tabs">
|
||||
<button
|
||||
@@ -744,6 +909,12 @@ export default function StrategyEditorPage({ theme }: Props) {
|
||||
</button>
|
||||
</div>
|
||||
{stratName && <span className="se-editing-name">{stratName}</span>}
|
||||
{hasMultipleStartSections(currentEditorState) && (
|
||||
<StartCombineOpControl
|
||||
value={normalizeStartCombineOp(currentEditorState.startCombineOp)}
|
||||
onChange={handleStartCombineOpChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{editorMode === 'graph' ? (
|
||||
@@ -761,6 +932,12 @@ export default function StrategyEditorPage({ theme }: Props) {
|
||||
onSelectNode={setSelectedNodeId}
|
||||
layoutSeed={layoutSeed}
|
||||
onLayoutChange={handleLayoutChange}
|
||||
startMeta={currentLayout.startMeta}
|
||||
extraStartIds={currentLayout.extraStartIds}
|
||||
extraRoots={currentLayout.extraRoots}
|
||||
onStartMetaChange={handleStartMetaChange}
|
||||
onExtraStartIdsChange={handleExtraStartIdsChange}
|
||||
onExtraRootsChange={handleExtraRootsChange}
|
||||
/>
|
||||
</ReactFlowProvider>
|
||||
|
||||
@@ -778,8 +955,22 @@ export default function StrategyEditorPage({ theme }: Props) {
|
||||
setCurrentOrphans(currentOrphans.map(o => (
|
||||
o.id === selectedNodeId ? updateNode(o, selectedNodeId, n => ({ ...n, condition: c })) : o
|
||||
)));
|
||||
} else if (currentRoot) {
|
||||
setCurrentRoot(updateNode(currentRoot, selectedNodeId, n => ({ ...n, condition: c })));
|
||||
return;
|
||||
}
|
||||
if (currentRoot && findLogicNode(currentRoot, currentOrphans, selectedNodeId, currentLayout.extraRoots ?? {})) {
|
||||
if (findLogicNode(currentRoot, [], selectedNodeId)) {
|
||||
setCurrentRoot(updateNode(currentRoot, selectedNodeId, n => ({ ...n, condition: c })));
|
||||
return;
|
||||
}
|
||||
for (const [startId, branch] of Object.entries(currentLayout.extraRoots ?? {})) {
|
||||
if (branch && findLogicNode(branch, [], selectedNodeId)) {
|
||||
handleExtraRootsChange({
|
||||
...(currentLayout.extraRoots ?? {}),
|
||||
[startId]: updateNode(branch, selectedNodeId, n => ({ ...n, condition: c })),
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
@@ -789,10 +980,13 @@ export default function StrategyEditorPage({ theme }: Props) {
|
||||
</>
|
||||
) : (
|
||||
<StrategyListEditor
|
||||
root={currentRoot}
|
||||
editorState={currentEditorState}
|
||||
signalTab={signalTab}
|
||||
def={DEF}
|
||||
onChange={setCurrentRoot}
|
||||
onEditorStateChange={handleEditorStateChange}
|
||||
onAddStart={handleAddStartSection}
|
||||
orphans={currentOrphans}
|
||||
onOrphansChange={setCurrentOrphans}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -811,6 +1005,8 @@ export default function StrategyEditorPage({ theme }: Props) {
|
||||
<LogicExpressionPreview
|
||||
buyCondition={buyCondition}
|
||||
sellCondition={sellCondition}
|
||||
buyEditorState={buyEditorState}
|
||||
sellEditorState={sellEditorState}
|
||||
orphanCount={orphanTotal}
|
||||
def={DEF}
|
||||
/>
|
||||
@@ -834,8 +1030,18 @@ export default function StrategyEditorPage({ theme }: Props) {
|
||||
onChange={e => setPaletteSearch(e.target.value)}
|
||||
/>
|
||||
<div className="se-palette-section se-palette-section--logic">
|
||||
<h3>논리 연산</h3>
|
||||
<h3>시작 · 논리</h3>
|
||||
<div className="se-palette-grid se-palette-grid--3">
|
||||
<PaletteChip
|
||||
type="start"
|
||||
value="START"
|
||||
label="START"
|
||||
desc="시간봉 시작점"
|
||||
color="logic-start"
|
||||
selected={selectedPaletteKey === 'start:START'}
|
||||
onSelect={() => setSelectedPaletteKey('start:START')}
|
||||
onAdd={() => applyPalette('start', 'START', 'START')}
|
||||
/>
|
||||
{operators.map(op => (
|
||||
<PaletteChip
|
||||
key={op.value}
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
} from '../utils/strategyPresets';
|
||||
|
||||
// ─── 타입 ─────────────────────────────────────────────────────────────────────
|
||||
type LogicNodeType = 'AND' | 'OR' | 'NOT' | 'CONDITION';
|
||||
type LogicNodeType = 'AND' | 'OR' | 'NOT' | 'CONDITION' | 'TIMEFRAME';
|
||||
|
||||
interface ConditionDSL {
|
||||
indicatorType: string;
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import React, { memo, useCallback, useState } from 'react';
|
||||
import { Handle, Position, useConnection, useReactFlow, type NodeProps } from '@xyflow/react';
|
||||
import { START_NODE_ID, type HandleSide, type StrategyFlowNodeData } from '../../utils/strategyFlowLayout';
|
||||
import {
|
||||
isStartNodeId,
|
||||
STRATEGY_CANDLE_TYPES,
|
||||
type HandleSide,
|
||||
type StrategyFlowNodeData,
|
||||
} from '../../utils/strategyFlowLayout';
|
||||
import { hasNodeSettings } from '../../utils/conditionPeriods';
|
||||
import ConditionNodeSettings from './ConditionNodeSettings';
|
||||
|
||||
@@ -122,10 +127,14 @@ function usePaletteDropHandlers(id: string, d: StrategyFlowNodeData) {
|
||||
return { onDragOver, onDragLeave, onDrop };
|
||||
}
|
||||
|
||||
export const StartNode = memo(function StartNode({ data }: NodeProps) {
|
||||
export const StartNode = memo(function StartNode({ id, data }: NodeProps) {
|
||||
const d = data as StrategyFlowNodeData;
|
||||
const isSell = d.signalTab === 'sell';
|
||||
const { onDragOver, onDragLeave, onDrop } = usePaletteDropHandlers(START_NODE_ID, d);
|
||||
const { onDragOver, onDragLeave, onDrop } = usePaletteDropHandlers(id, d);
|
||||
const candleType = d.candleType ?? '1m';
|
||||
const canDelete = id !== '__strategy_start__' && !!d.onDeleteStart;
|
||||
|
||||
const stop = (e: React.SyntheticEvent) => e.stopPropagation();
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -135,13 +144,41 @@ export const StartNode = memo(function StartNode({ data }: NodeProps) {
|
||||
onDrop={onDrop}
|
||||
>
|
||||
<MultiSideHandles
|
||||
nodeId={START_NODE_ID}
|
||||
nodeId={id}
|
||||
sourceOnly
|
||||
activeSourceSide={d.activeSourceSide}
|
||||
canSourceConnect={d.canSourceConnect !== false}
|
||||
/>
|
||||
<div className="se-flow-start-dot" />
|
||||
<span>START</span>
|
||||
<div className="se-flow-start-head">
|
||||
<div className="se-flow-start-dot" />
|
||||
<span className="se-flow-start-label">START</span>
|
||||
<select
|
||||
className="se-flow-start-tf"
|
||||
value={candleType}
|
||||
title="조건 판별 시간봉"
|
||||
onPointerDown={stop}
|
||||
onMouseDown={stop}
|
||||
onClick={stop}
|
||||
onChange={e => d.onStartCandleTypeChange?.(id, e.target.value)}
|
||||
>
|
||||
{STRATEGY_CANDLE_TYPES.map(ct => (
|
||||
<option key={ct} value={ct}>{ct}</option>
|
||||
))}
|
||||
</select>
|
||||
{canDelete && (
|
||||
<button
|
||||
type="button"
|
||||
className="se-flow-del se-flow-del--start"
|
||||
title="START 삭제"
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
d.onDeleteStart?.(id);
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -2,10 +2,18 @@ import React from 'react';
|
||||
import type { LogicNode } from '../../utils/strategyTypes';
|
||||
import { nodeToText, type DefType } from '../../utils/strategyEditorShared';
|
||||
import { getIndicatorPaletteCategory } from './paletteCategories';
|
||||
import {
|
||||
collectEditorBranches,
|
||||
normalizeStartCombineOp,
|
||||
type EditorConditionState,
|
||||
} from '../../utils/strategyConditionSerde';
|
||||
import { formatStartCandleLabel } from '../../utils/strategyStartNodes';
|
||||
|
||||
interface Props {
|
||||
buyCondition: LogicNode | null;
|
||||
sellCondition: LogicNode | null;
|
||||
buyEditorState?: EditorConditionState;
|
||||
sellEditorState?: EditorConditionState;
|
||||
orphanCount: number;
|
||||
def: DefType;
|
||||
}
|
||||
@@ -81,34 +89,106 @@ function renderNode(node: LogicNode, def: DefType): React.ReactNode {
|
||||
);
|
||||
}
|
||||
|
||||
if (node.type === 'TIMEFRAME') {
|
||||
const inner = node.children?.[0];
|
||||
const tf = formatStartCandleLabel(node.candleType ?? '1m');
|
||||
return inner ? (
|
||||
<>
|
||||
<span className="se-formula-tf">[{tf}]</span>
|
||||
{' '}
|
||||
{renderNode(inner, def)}
|
||||
</>
|
||||
) : (
|
||||
<span className="se-formula-empty">[{tf} 빈 조건]</span>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function renderSignalBranches(
|
||||
editorState: EditorConditionState | undefined,
|
||||
fallbackRoot: LogicNode | null,
|
||||
def: DefType,
|
||||
): React.ReactNode {
|
||||
const branches = editorState
|
||||
? collectEditorBranches(editorState)
|
||||
: fallbackRoot
|
||||
? [{ candleType: '1m', root: fallbackRoot }]
|
||||
: [];
|
||||
|
||||
if (branches.length === 0) {
|
||||
return <span className="se-formula-empty">(연결된 조건 없음)</span>;
|
||||
}
|
||||
|
||||
if (branches.length === 1) {
|
||||
const { candleType, root } = branches[0];
|
||||
if (!root) return <span className="se-formula-empty">(연결된 조건 없음)</span>;
|
||||
return (
|
||||
<>
|
||||
<span className="se-formula-tf">[{formatStartCandleLabel(candleType)}]</span>
|
||||
{' '}
|
||||
{renderNode(root, def)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const combineOp = normalizeStartCombineOp(editorState?.startCombineOp);
|
||||
const combineClass = combineOp === 'AND' ? 'se-formula-logic--and' : 'se-formula-logic--or';
|
||||
|
||||
return (
|
||||
<>
|
||||
<span className="se-formula-punc">(</span>
|
||||
{branches.map((branch, i) => (
|
||||
<React.Fragment key={`${branch.candleType}-${i}`}>
|
||||
{i > 0 ? (
|
||||
<span className={`se-formula-logic ${combineClass}`}> {combineOp} </span>
|
||||
) : null}
|
||||
{branch.root ? (
|
||||
<>
|
||||
<span className="se-formula-tf">[{formatStartCandleLabel(branch.candleType)}]</span>
|
||||
{' '}
|
||||
{renderNode(branch.root, def)}
|
||||
</>
|
||||
) : (
|
||||
<span className="se-formula-empty">[{formatStartCandleLabel(branch.candleType)} 빈 조건]</span>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
<span className="se-formula-punc">)</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function LogicExpressionPreview({
|
||||
buyCondition,
|
||||
sellCondition,
|
||||
buyEditorState,
|
||||
sellEditorState,
|
||||
orphanCount,
|
||||
def,
|
||||
}: Props) {
|
||||
const hasBody = buyCondition || sellCondition;
|
||||
const hasBody = buyCondition || sellCondition
|
||||
|| (buyEditorState && collectEditorBranches(buyEditorState).some(b => b.root))
|
||||
|| (sellEditorState && collectEditorBranches(sellEditorState).some(b => b.root));
|
||||
|
||||
return (
|
||||
<div className="se-terminal-text se-terminal-text--rich">
|
||||
{!hasBody && (
|
||||
<span className="se-formula-empty">(연결된 조건을 구성하세요)</span>
|
||||
)}
|
||||
{buyCondition && (
|
||||
{(buyCondition || buyEditorState) && (
|
||||
<div className="se-formula-line">
|
||||
<span className="se-formula-signal se-formula-signal--buy">[매수]</span>
|
||||
{' '}
|
||||
{renderNode(buyCondition, def)}
|
||||
{renderSignalBranches(buyEditorState, buyCondition, def)}
|
||||
</div>
|
||||
)}
|
||||
{sellCondition && (
|
||||
{(sellCondition || sellEditorState) && (
|
||||
<div className="se-formula-line">
|
||||
<span className="se-formula-signal se-formula-signal--sell">[매도]</span>
|
||||
{' '}
|
||||
{renderNode(sellCondition, def)}
|
||||
{renderSignalBranches(sellEditorState, sellCondition, def)}
|
||||
</div>
|
||||
)}
|
||||
{orphanCount > 0 && (
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import React from 'react';
|
||||
|
||||
export interface PaletteDragPayload {
|
||||
type: 'operator' | 'indicator';
|
||||
type: 'operator' | 'indicator' | 'start';
|
||||
value: string;
|
||||
label: string;
|
||||
composite?: boolean;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
type: 'operator' | 'indicator';
|
||||
type: 'operator' | 'indicator' | 'start';
|
||||
value: string;
|
||||
label: string;
|
||||
desc?: string;
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import React from 'react';
|
||||
import type { StartCombineOp } from '../../utils/strategyStartNodes';
|
||||
|
||||
interface Props {
|
||||
value: StartCombineOp;
|
||||
onChange: (op: StartCombineOp) => void;
|
||||
}
|
||||
|
||||
export default function StartCombineOpControl({ value, onChange }: Props) {
|
||||
return (
|
||||
<div className="se-start-combine" role="group" aria-label="START 간 연산">
|
||||
<span className="se-start-combine-label">START 간</span>
|
||||
<div className="se-start-combine-toggle">
|
||||
<button
|
||||
type="button"
|
||||
className={`se-start-combine-btn se-start-combine-btn--and${value === 'AND' ? ' se-start-combine-btn--on' : ''}`}
|
||||
aria-pressed={value === 'AND'}
|
||||
onClick={() => onChange('AND')}
|
||||
>
|
||||
AND
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`se-start-combine-btn se-start-combine-btn--or${value === 'OR' ? ' se-start-combine-btn--on' : ''}`}
|
||||
aria-pressed={value === 'OR'}
|
||||
onClick={() => onChange('OR')}
|
||||
>
|
||||
OR
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -43,13 +43,23 @@ import {
|
||||
isDescendant,
|
||||
isOrphanNode,
|
||||
isPaletteConnectTarget,
|
||||
isStartNodeId,
|
||||
isValidStrategyConnection,
|
||||
resolveStrategyConnection,
|
||||
resolveOrphanDragConnection,
|
||||
logicNodeToFlow,
|
||||
computeAutoFlowLayout,
|
||||
spawnPositionNearAnchor,
|
||||
subtreeToFlatOrphans,
|
||||
type EdgeHandleBinding,
|
||||
} from '../../utils/strategyFlowLayout';
|
||||
import {
|
||||
createStartNodeId,
|
||||
defaultStartMeta,
|
||||
DEFAULT_START_CANDLE,
|
||||
normalizeStartCandleType,
|
||||
type StartNodeMeta,
|
||||
} from '../../utils/strategyStartNodes';
|
||||
import { ConditionFlowNode, LogicGateNode, StartNode } from './FlowNodes';
|
||||
import { StrategyFlowEdge } from './StrategyFlowEdge';
|
||||
import { edgeDisconnectRef, edgeBindingUpdateRef, layoutFlushRef } from './strategyEditorCallbacks';
|
||||
@@ -90,6 +100,12 @@ interface Props {
|
||||
onSelectNode: (id: string | null) => void;
|
||||
layoutSeed: FlowLayoutSeed;
|
||||
onLayoutChange?: (snapshot: FlowLayoutChangePayload) => void;
|
||||
startMeta?: Record<string, StartNodeMeta>;
|
||||
extraStartIds?: string[];
|
||||
extraRoots?: Record<string, LogicNode | null>;
|
||||
onStartMetaChange?: (meta: Record<string, StartNodeMeta>) => void;
|
||||
onExtraStartIdsChange?: (ids: string[]) => void;
|
||||
onExtraRootsChange?: (roots: Record<string, LogicNode | null>) => void;
|
||||
}
|
||||
|
||||
function collectTreeIds(node: LogicNode | null): string[] {
|
||||
@@ -117,28 +133,76 @@ function saveEdgeHandles(
|
||||
function applyTreeConnection(
|
||||
sourceId: string,
|
||||
targetId: string,
|
||||
root: LogicNode,
|
||||
root: LogicNode | null,
|
||||
extraRoots: Record<string, LogicNode | null>,
|
||||
onChange: (root: LogicNode | null) => void,
|
||||
onExtraRootsChange: (roots: Record<string, LogicNode | null>) => void,
|
||||
) {
|
||||
if (sourceId === START_NODE_ID) {
|
||||
const { tree, node } = extractNode(root, targetId);
|
||||
if (!node) return;
|
||||
onChange(node);
|
||||
if (tree && tree.children?.length) {
|
||||
onChange(mergeAtRoot(node, tree, false));
|
||||
const promoteToStart = (startId: string, node: LogicNode, remainder: LogicNode | null) => {
|
||||
if (startId === START_NODE_ID) {
|
||||
onChange(remainder ? mergeAtRoot(node, remainder, false) : node);
|
||||
return;
|
||||
}
|
||||
const branch = extraRoots[startId] ?? null;
|
||||
onExtraRootsChange({
|
||||
...extraRoots,
|
||||
[startId]: branch ? mergeAtRoot(branch, node, false) : node,
|
||||
});
|
||||
};
|
||||
|
||||
if (isStartNodeId(sourceId)) {
|
||||
if (root && (root.id === targetId || findNodeInTree(root, targetId))) {
|
||||
const { tree, node } = extractNode(root, targetId);
|
||||
if (!node) return;
|
||||
onChange(tree);
|
||||
promoteToStart(sourceId, node, tree);
|
||||
return;
|
||||
}
|
||||
for (const [sid, branch] of Object.entries(extraRoots)) {
|
||||
if (!branch || !findNodeInTree(branch, targetId)) continue;
|
||||
const { tree, node } = extractNode(branch, targetId);
|
||||
if (!node) return;
|
||||
onExtraRootsChange({ ...extraRoots, [sid]: tree });
|
||||
promoteToStart(sourceId, node, null);
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const sourceNode = findNodeInTree(root, sourceId);
|
||||
if (!root) return;
|
||||
const sourceNode = findNodeInTree(root, sourceId)
|
||||
?? Object.values(extraRoots).map(br => findNodeInTree(br, sourceId)).find(Boolean);
|
||||
if (!sourceNode || !['AND', 'OR', 'NOT'].includes(sourceNode.type)) return;
|
||||
if (isDescendant(root, targetId, sourceId)) return;
|
||||
|
||||
const { tree: afterRemove, node: moved } = extractNode(root, targetId);
|
||||
if (!moved) return;
|
||||
const base = afterRemove ?? root;
|
||||
if (sourceId === targetId) return;
|
||||
onChange(addChild(base, sourceId, moved));
|
||||
if (findNodeInTree(root, targetId)) {
|
||||
if (isDescendant(root, targetId, sourceId)) return;
|
||||
const { tree: afterRemove, node: moved } = extractNode(root, targetId);
|
||||
if (!moved) return;
|
||||
onChange(addChild(afterRemove ?? root, sourceId, moved));
|
||||
return;
|
||||
}
|
||||
|
||||
for (const [sid, branch] of Object.entries(extraRoots)) {
|
||||
if (!branch || !findNodeInTree(branch, targetId)) continue;
|
||||
if (isDescendant(branch, targetId, sourceId)) return;
|
||||
const { tree, node: moved } = extractNode(branch, targetId);
|
||||
if (!moved) return;
|
||||
onExtraRootsChange({ ...extraRoots, [sid]: tree });
|
||||
if (findNodeInTree(root, sourceId)) {
|
||||
onChange(addChild(root, sourceId, moved));
|
||||
} else {
|
||||
const parentBranch = Object.entries(extraRoots).find(([, br]) => br && findNodeInTree(br, sourceId));
|
||||
if (parentBranch) {
|
||||
const [psid, pbr] = parentBranch;
|
||||
onExtraRootsChange({
|
||||
...extraRoots,
|
||||
[sid]: tree,
|
||||
[psid]: addChild(pbr!, sourceId, moved),
|
||||
});
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
function pruneEdgeHandles(handles: Map<string, EdgeHandleBinding>, edges: Edge[]) {
|
||||
@@ -152,11 +216,21 @@ function connectOrphanIntoTree(
|
||||
conn: Connection,
|
||||
orphan: LogicNode,
|
||||
root: LogicNode | null,
|
||||
extraRoots: Record<string, LogicNode | null>,
|
||||
onChange: (root: LogicNode | null) => void,
|
||||
onExtraRootsChange: (roots: Record<string, LogicNode | null>) => void,
|
||||
): void {
|
||||
const sourceId = conn.source!;
|
||||
if (sourceId === START_NODE_ID) {
|
||||
onChange(root ? mergeAtRoot(root, orphan, false) : orphan);
|
||||
if (isStartNodeId(sourceId)) {
|
||||
if (sourceId === START_NODE_ID) {
|
||||
onChange(root ? mergeAtRoot(root, orphan, false) : orphan);
|
||||
return;
|
||||
}
|
||||
const branch = extraRoots[sourceId] ?? null;
|
||||
onExtraRootsChange({
|
||||
...extraRoots,
|
||||
[sourceId]: branch ? mergeAtRoot(branch, orphan, false) : orphan,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!root) return;
|
||||
@@ -189,6 +263,12 @@ function StrategyEditorCanvasInner({
|
||||
onSelectNode,
|
||||
layoutSeed,
|
||||
onLayoutChange,
|
||||
startMeta = defaultStartMeta(),
|
||||
extraStartIds = [],
|
||||
extraRoots = {},
|
||||
onStartMetaChange,
|
||||
onExtraStartIdsChange,
|
||||
onExtraRootsChange,
|
||||
}: Props) {
|
||||
const { fitView, screenToFlowPosition } = useReactFlow();
|
||||
const positionsRef = useRef(new Map<string, { x: number; y: number }>());
|
||||
@@ -212,29 +292,34 @@ function StrategyEditorCanvasInner({
|
||||
nodesRef.current = nodes;
|
||||
edgesRef.current = edges;
|
||||
|
||||
const treeStructureKey = useMemo(
|
||||
() => collectTreeIds(root).join('|'),
|
||||
[root],
|
||||
);
|
||||
const treeStructureKey = useMemo(() => {
|
||||
const parts = [collectTreeIds(root).join('|')];
|
||||
for (const sid of extraStartIds) parts.push(sid);
|
||||
for (const br of Object.values(extraRoots)) parts.push(collectTreeIds(br).join('|'));
|
||||
return parts.join('::');
|
||||
}, [root, extraStartIds, extraRoots]);
|
||||
const orphanKey = useMemo(() => orphans.map(o => o.id).join('|'), [orphans]);
|
||||
|
||||
/** START에서 연결된 트리 전체 (미연결 고아 제외) */
|
||||
const treeLinkedIds = useMemo(() => {
|
||||
const ids = new Set<string>([START_NODE_ID]);
|
||||
const ids = new Set<string>([START_NODE_ID, ...extraStartIds]);
|
||||
for (const id of collectTreeIds(root)) ids.add(id);
|
||||
for (const br of Object.values(extraRoots)) {
|
||||
for (const id of collectTreeIds(br)) ids.add(id);
|
||||
}
|
||||
return ids;
|
||||
}, [root]);
|
||||
}, [root, extraStartIds, extraRoots]);
|
||||
|
||||
const minimapColors = useMemo(() => getMinimapColors(theme, signalTab), [theme, signalTab]);
|
||||
|
||||
const minimapNodeColor = useCallback((node: Node) => {
|
||||
if (node.id === START_NODE_ID) return minimapColors.start;
|
||||
if (isStartNodeId(node.id)) return minimapColors.start;
|
||||
if (node.type === 'condition') return minimapColors.condition;
|
||||
return minimapColors.logic;
|
||||
}, [minimapColors]);
|
||||
|
||||
const minimapNodeStrokeColor = useCallback((node: Node) => {
|
||||
if (node.id === START_NODE_ID) return minimapColors.start;
|
||||
if (isStartNodeId(node.id)) return minimapColors.start;
|
||||
return minimapColors.stroke;
|
||||
}, [minimapColors]);
|
||||
|
||||
@@ -285,31 +370,90 @@ function StrategyEditorCanvasInner({
|
||||
};
|
||||
}, [emitLayoutChange]);
|
||||
|
||||
const handleStartCandleTypeChange = useCallback((startId: string, candleType: string) => {
|
||||
onStartMetaChange?.({
|
||||
...startMeta,
|
||||
[startId]: { candleType: normalizeStartCandleType(candleType) },
|
||||
});
|
||||
}, [startMeta, onStartMetaChange]);
|
||||
|
||||
const handleDeleteStart = useCallback((startId: string) => {
|
||||
if (startId === START_NODE_ID) return;
|
||||
const branch = extraRoots[startId];
|
||||
if (branch) {
|
||||
onOrphansChange([...orphans, ...subtreeToFlatOrphans(branch)]);
|
||||
}
|
||||
onExtraStartIdsChange?.(extraStartIds.filter(id => id !== startId));
|
||||
const nextMeta = { ...startMeta };
|
||||
delete nextMeta[startId];
|
||||
onStartMetaChange?.(nextMeta);
|
||||
const nextRoots = { ...extraRoots };
|
||||
delete nextRoots[startId];
|
||||
onExtraRootsChange?.(nextRoots);
|
||||
positionsRef.current.delete(startId);
|
||||
scheduleLayoutEmit();
|
||||
}, [
|
||||
extraRoots, extraStartIds, startMeta, orphans,
|
||||
onOrphansChange, onExtraStartIdsChange, onStartMetaChange, onExtraRootsChange, scheduleLayoutEmit,
|
||||
]);
|
||||
|
||||
const addStartAt = useCallback((flowPos: { x: number; y: number }) => {
|
||||
const newId = createStartNodeId();
|
||||
onExtraStartIdsChange?.([...extraStartIds, newId]);
|
||||
onStartMetaChange?.({
|
||||
...startMeta,
|
||||
[newId]: { candleType: DEFAULT_START_CANDLE },
|
||||
});
|
||||
onExtraRootsChange?.({ ...extraRoots, [newId]: null });
|
||||
positionsRef.current.set(newId, {
|
||||
x: flowPos.x - getNodeDimensions(newId).w / 2,
|
||||
y: flowPos.y - getNodeDimensions(newId).h / 2,
|
||||
});
|
||||
scheduleLayoutEmit();
|
||||
}, [
|
||||
extraStartIds, extraRoots, startMeta,
|
||||
onExtraStartIdsChange, onStartMetaChange, onExtraRootsChange, scheduleLayoutEmit,
|
||||
]);
|
||||
|
||||
const handleDelete = useCallback((id: string) => {
|
||||
if (id === START_NODE_ID) return;
|
||||
if (isStartNodeId(id)) return;
|
||||
if (isOrphanNode(orphans, id)) {
|
||||
onOrphansChange(orphans.filter(o => o.id !== id));
|
||||
positionsRef.current.delete(id);
|
||||
if (selectedNodeId === id) onSelectNode(null);
|
||||
return;
|
||||
}
|
||||
if (!root) return;
|
||||
if (root.id === id) {
|
||||
onChange(null);
|
||||
onSelectNode(null);
|
||||
if (root && (root.id === id || findNodeInTree(root, id))) {
|
||||
if (root.id === id) {
|
||||
onChange(null);
|
||||
} else {
|
||||
onChange(deleteNode(root, id));
|
||||
}
|
||||
positionsRef.current.delete(id);
|
||||
if (selectedNodeId === id) onSelectNode(null);
|
||||
scheduleLayoutEmit();
|
||||
return;
|
||||
}
|
||||
const next = deleteNode(root, id);
|
||||
onChange(next);
|
||||
positionsRef.current.delete(id);
|
||||
if (selectedNodeId === id) onSelectNode(null);
|
||||
scheduleLayoutEmit();
|
||||
}, [root, orphans, onChange, onOrphansChange, onSelectNode, selectedNodeId, scheduleLayoutEmit]);
|
||||
for (const [sid, branch] of Object.entries(extraRoots)) {
|
||||
if (!branch || !findNodeInTree(branch, id)) continue;
|
||||
if (branch.id === id) {
|
||||
onExtraRootsChange?.({ ...extraRoots, [sid]: null });
|
||||
} else {
|
||||
onExtraRootsChange?.({ ...extraRoots, [sid]: deleteNode(branch, id) });
|
||||
}
|
||||
positionsRef.current.delete(id);
|
||||
if (selectedNodeId === id) onSelectNode(null);
|
||||
scheduleLayoutEmit();
|
||||
return;
|
||||
}
|
||||
}, [
|
||||
root, extraRoots, orphans, onChange, onOrphansChange, onExtraRootsChange,
|
||||
onSelectNode, selectedNodeId, scheduleLayoutEmit,
|
||||
]);
|
||||
|
||||
const deleteSelectedNodes = useCallback(() => {
|
||||
const ids = nodesRef.current
|
||||
.filter(n => n.selected && n.id !== START_NODE_ID)
|
||||
.filter(n => n.selected && !isStartNodeId(n.id))
|
||||
.map(n => n.id);
|
||||
if (!ids.length) return;
|
||||
|
||||
@@ -426,6 +570,11 @@ function StrategyEditorCanvasInner({
|
||||
data: { type: string; value: string; label: string; composite?: boolean },
|
||||
flowPos: { x: number; y: number },
|
||||
) => {
|
||||
if (data.type === 'start') {
|
||||
addStartAt(flowPos);
|
||||
return;
|
||||
}
|
||||
|
||||
const newNode = makeNode(data.type, data.value, signalTab, def, data.composite ? { composite: true } : undefined);
|
||||
const anchor = nodesRef.current.find(n => n.id === anchorId);
|
||||
if (!anchor) return;
|
||||
@@ -438,20 +587,23 @@ function StrategyEditorCanvasInner({
|
||||
saveEdgeHandles(edgeHandlesRef.current, `${parentId}-${newNode.id}`, { sourceHandle, targetHandle });
|
||||
};
|
||||
|
||||
if (!root) {
|
||||
onChange(newNode);
|
||||
saveAttachHandles(START_NODE_ID);
|
||||
scheduleLayoutEmit();
|
||||
return;
|
||||
}
|
||||
if (anchorId === START_NODE_ID) {
|
||||
onChange(mergeAtRoot(root, newNode, data.type === 'operator'));
|
||||
saveAttachHandles(START_NODE_ID);
|
||||
if (isStartNodeId(anchorId)) {
|
||||
if (anchorId === START_NODE_ID) {
|
||||
onChange(root ? mergeAtRoot(root, newNode, data.type === 'operator') : newNode);
|
||||
} else {
|
||||
const branch = extraRoots[anchorId] ?? null;
|
||||
onExtraRootsChange?.({
|
||||
...extraRoots,
|
||||
[anchorId]: branch ? mergeAtRoot(branch, newNode, data.type === 'operator') : newNode,
|
||||
});
|
||||
}
|
||||
saveAttachHandles(anchorId);
|
||||
scheduleLayoutEmit();
|
||||
return;
|
||||
}
|
||||
|
||||
const anchorNode = findNodeInTree(root, anchorId);
|
||||
const anchorNode = findNodeInTree(root, anchorId)
|
||||
?? Object.values(extraRoots).map(br => findNodeInTree(br, anchorId)).find(Boolean);
|
||||
if (anchorNode?.type === 'CONDITION') {
|
||||
onChange(mergeAtRoot(root, newNode, data.type === 'operator'));
|
||||
return;
|
||||
@@ -459,10 +611,21 @@ function StrategyEditorCanvasInner({
|
||||
|
||||
if (!canAcceptPaletteAsParent(anchorId, root)) return;
|
||||
|
||||
onChange(addChild(root, anchorId, newNode));
|
||||
if (root && findNodeInTree(root, anchorId)) {
|
||||
onChange(addChild(root, anchorId, newNode));
|
||||
} else {
|
||||
const parentBranch = Object.entries(extraRoots).find(([, br]) => br && findNodeInTree(br, anchorId));
|
||||
if (parentBranch) {
|
||||
const [sid, br] = parentBranch;
|
||||
onExtraRootsChange?.({
|
||||
...extraRoots,
|
||||
[sid]: addChild(br!, anchorId, newNode),
|
||||
});
|
||||
}
|
||||
}
|
||||
saveAttachHandles(anchorId);
|
||||
scheduleLayoutEmit();
|
||||
}, [root, signalTab, def, onChange, scheduleLayoutEmit]);
|
||||
}, [root, extraRoots, signalTab, def, onChange, onExtraRootsChange, addStartAt, scheduleLayoutEmit]);
|
||||
|
||||
const handleDragOverTarget = useCallback((anchorId: string, flowPos: { x: number; y: number }) => {
|
||||
updateDropPreview(anchorId, flowPos);
|
||||
@@ -496,9 +659,20 @@ function StrategyEditorCanvasInner({
|
||||
)));
|
||||
return;
|
||||
}
|
||||
if (!root) return;
|
||||
onChange(updateNode(root, id, n => ({ ...n, condition })));
|
||||
}, [root, orphans, onChange, onOrphansChange]);
|
||||
if (root && findNodeInTree(root, id)) {
|
||||
onChange(updateNode(root, id, n => ({ ...n, condition })));
|
||||
return;
|
||||
}
|
||||
for (const [sid, branch] of Object.entries(extraRoots)) {
|
||||
if (branch && findNodeInTree(branch, id)) {
|
||||
onExtraRootsChange?.({
|
||||
...extraRoots,
|
||||
[sid]: updateNode(branch, id, n => ({ ...n, condition })),
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
}, [root, extraRoots, orphans, onChange, onOrphansChange, onExtraRootsChange]);
|
||||
|
||||
const flowCallbacks = useMemo(() => ({
|
||||
onDelete: handleDelete,
|
||||
@@ -506,7 +680,13 @@ function StrategyEditorCanvasInner({
|
||||
onDropTarget: handleDropTarget,
|
||||
onDragOverTarget: handleDragOverTarget,
|
||||
onDragLeaveTarget: handleDragLeaveTarget,
|
||||
}), [handleDelete, handleUpdateCondition, handleDropTarget, handleDragOverTarget, handleDragLeaveTarget]);
|
||||
onStartCandleTypeChange: handleStartCandleTypeChange,
|
||||
onDeleteStart: handleDeleteStart,
|
||||
}), [
|
||||
handleDelete, handleUpdateCondition, handleDropTarget,
|
||||
handleDragOverTarget, handleDragLeaveTarget,
|
||||
handleStartCandleTypeChange, handleDeleteStart,
|
||||
]);
|
||||
|
||||
const rebuildFlow = useCallback(() => logicNodeToFlow(
|
||||
root,
|
||||
@@ -516,7 +696,10 @@ function StrategyEditorCanvasInner({
|
||||
flowCallbacks,
|
||||
null,
|
||||
orphans,
|
||||
), [root, def, signalTab, flowCallbacks, orphans]);
|
||||
startMeta,
|
||||
extraStartIds,
|
||||
extraRoots,
|
||||
), [root, def, signalTab, flowCallbacks, orphans, startMeta, extraStartIds, extraRoots]);
|
||||
|
||||
// 트리 구조 변경 → 전체 재배치 / 조건 편집 → 라벨만 갱신(위치 유지)
|
||||
useEffect(() => {
|
||||
@@ -573,20 +756,22 @@ function StrategyEditorCanvasInner({
|
||||
if (!edge?.source || !edge.target) return;
|
||||
|
||||
edgeHandlesRef.current.delete(edgeId);
|
||||
const { root: nextRoot, orphans: nextOrphans } = disconnectTreeEdge(
|
||||
const { root: nextRoot, orphans: nextOrphans, extraRoots: nextExtraRoots } = disconnectTreeEdge(
|
||||
edge.source,
|
||||
edge.target,
|
||||
root,
|
||||
orphans,
|
||||
extraRoots,
|
||||
);
|
||||
|
||||
if (nextRoot === root && nextOrphans.length === orphans.length) return;
|
||||
if (nextRoot === root && nextOrphans.length === orphans.length && nextExtraRoots === extraRoots) return;
|
||||
|
||||
onChange(nextRoot);
|
||||
onOrphansChange(nextOrphans);
|
||||
onExtraRootsChange?.(nextExtraRoots);
|
||||
onSelectNode(null);
|
||||
setEdges(prev => prev.map(e => ({ ...e, selected: false })));
|
||||
}, [root, orphans, onChange, onOrphansChange, onSelectNode, setEdges]);
|
||||
}, [root, orphans, extraRoots, onChange, onOrphansChange, onExtraRootsChange, onSelectNode, setEdges]);
|
||||
|
||||
useEffect(() => {
|
||||
edgeDisconnectRef.current = handleDisconnectEdge;
|
||||
@@ -607,8 +792,31 @@ function StrategyEditorCanvasInner({
|
||||
};
|
||||
}, [handleDisconnectEdge, setEdges, scheduleLayoutEmit]);
|
||||
|
||||
const handleAutoLayout = useCallback(() => {
|
||||
const nextPositions = computeAutoFlowLayout(root, extraStartIds, extraRoots, orphans);
|
||||
positionsRef.current = new Map(Object.entries(nextPositions));
|
||||
edgeHandlesRef.current.clear();
|
||||
|
||||
const { nodes: layoutNodes, edges: layoutEdges } = rebuildFlow();
|
||||
const mergedEdges = applyEdgeHandles(layoutEdges, positionsRef.current, edgeHandlesRef.current);
|
||||
|
||||
setNodes(layoutNodes.map(n => ({
|
||||
...n,
|
||||
position: positionsRef.current.get(n.id) ?? n.position,
|
||||
selected: nodesRef.current.find(p => p.id === n.id)?.selected ?? false,
|
||||
})));
|
||||
setEdges(mergedEdges);
|
||||
scheduleLayoutEmit();
|
||||
requestAnimationFrame(() => {
|
||||
fitView({ padding: 0.35, duration: 320 });
|
||||
});
|
||||
}, [
|
||||
root, extraStartIds, extraRoots, orphans, rebuildFlow, setNodes, setEdges,
|
||||
scheduleLayoutEmit, fitView,
|
||||
]);
|
||||
|
||||
const onSelectionChange: OnSelectionChangeFunc = useCallback(({ nodes: selNodes, edges: selEdges }) => {
|
||||
const picked = selNodes.filter(n => n.id !== START_NODE_ID);
|
||||
const picked = selNodes.filter(n => !isStartNodeId(n.id));
|
||||
if (picked.length === 1) onSelectNode(picked[0].id);
|
||||
else onSelectNode(null);
|
||||
if (selEdges.length === 1) {
|
||||
@@ -622,8 +830,9 @@ function StrategyEditorCanvasInner({
|
||||
{ source: conn.source, target: conn.target, sourceHandle: conn.sourceHandle ?? null, targetHandle: conn.targetHandle ?? null },
|
||||
root,
|
||||
orphans,
|
||||
extraRoots,
|
||||
),
|
||||
[root, orphans],
|
||||
[root, orphans, extraRoots],
|
||||
);
|
||||
|
||||
const applyResolvedConnection = useCallback((
|
||||
@@ -639,7 +848,14 @@ function StrategyEditorCanvasInner({
|
||||
|
||||
if (childOrphan) {
|
||||
onOrphansChange(orphans.filter(o => o.id !== childId));
|
||||
connectOrphanIntoTree(wired, childOrphan, root, onChange);
|
||||
connectOrphanIntoTree(
|
||||
wired,
|
||||
childOrphan,
|
||||
root,
|
||||
extraRoots,
|
||||
onChange,
|
||||
next => onExtraRootsChange?.(next),
|
||||
);
|
||||
scheduleLayoutEmit();
|
||||
return;
|
||||
}
|
||||
@@ -650,10 +866,16 @@ function StrategyEditorCanvasInner({
|
||||
return;
|
||||
}
|
||||
|
||||
if (!root) return;
|
||||
applyTreeConnection(parentId, childId, root, onChange);
|
||||
applyTreeConnection(
|
||||
parentId,
|
||||
childId,
|
||||
root,
|
||||
extraRoots,
|
||||
onChange,
|
||||
next => onExtraRootsChange?.(next),
|
||||
);
|
||||
scheduleLayoutEmit();
|
||||
}, [root, orphans, onChange, onOrphansChange, scheduleLayoutEmit]);
|
||||
}, [root, orphans, extraRoots, onChange, onExtraRootsChange, onOrphansChange, scheduleLayoutEmit]);
|
||||
|
||||
const onNodesChange: OnNodesChange = useCallback((changes) => {
|
||||
const positionChanges = changes.filter(
|
||||
@@ -720,6 +942,7 @@ function StrategyEditorCanvasInner({
|
||||
nodesSnapshot,
|
||||
root,
|
||||
orphans,
|
||||
extraRoots,
|
||||
);
|
||||
orphanDragTargetRef.current = resolved;
|
||||
if (resolved) {
|
||||
@@ -766,13 +989,13 @@ function StrategyEditorCanvasInner({
|
||||
]);
|
||||
|
||||
const onConnect = useCallback((conn: Connection) => {
|
||||
const resolved = resolveStrategyConnection(conn, root, orphans);
|
||||
const resolved = resolveStrategyConnection(conn, root, orphans, extraRoots);
|
||||
if (!resolved) return;
|
||||
applyResolvedConnection(resolved.parentId, resolved.childId, conn);
|
||||
}, [root, orphans, applyResolvedConnection]);
|
||||
}, [root, orphans, extraRoots, applyResolvedConnection]);
|
||||
|
||||
const onReconnect = useCallback((oldEdge: Edge, newConnection: Connection) => {
|
||||
const resolved = resolveStrategyConnection(newConnection, root, orphans);
|
||||
const resolved = resolveStrategyConnection(newConnection, root, orphans, extraRoots);
|
||||
if (!resolved) return;
|
||||
|
||||
edgeHandlesRef.current.delete(oldEdge.id);
|
||||
@@ -797,7 +1020,7 @@ function StrategyEditorCanvasInner({
|
||||
positionsRef.current,
|
||||
edgeHandlesRef.current,
|
||||
));
|
||||
}, [root, orphans, applyResolvedConnection, setEdges]);
|
||||
}, [root, orphans, extraRoots, applyResolvedConnection, setEdges]);
|
||||
|
||||
const isPaletteDrag = (e: React.DragEvent) => e.dataTransfer.types.includes('application/json');
|
||||
|
||||
@@ -815,6 +1038,10 @@ function StrategyEditorCanvasInner({
|
||||
try {
|
||||
const data = JSON.parse(e.dataTransfer.getData('application/json'));
|
||||
const flowPos = screenToFlowPosition({ x: e.clientX, y: e.clientY });
|
||||
if (data.type === 'start') {
|
||||
addStartAt(flowPos);
|
||||
return;
|
||||
}
|
||||
const drop = resolvePaletteDrop(flowPos);
|
||||
if (drop.mode === 'connect') {
|
||||
applyDropWithAttach(drop.anchorId, data, flowPos);
|
||||
@@ -822,7 +1049,7 @@ function StrategyEditorCanvasInner({
|
||||
addOrphanAt(data, flowPos);
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}, [clearDropPreview, screenToFlowPosition, applyDropWithAttach, addOrphanAt, resolvePaletteDrop]);
|
||||
}, [clearDropPreview, screenToFlowPosition, applyDropWithAttach, addOrphanAt, addStartAt, resolvePaletteDrop]);
|
||||
|
||||
const onPaneDragOver = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -917,6 +1144,22 @@ function StrategyEditorCanvasInner({
|
||||
nodeColor={minimapNodeColor}
|
||||
nodeStrokeColor={minimapNodeStrokeColor}
|
||||
/>
|
||||
<Panel position="top-right" className="se-canvas-auto-layout">
|
||||
<button
|
||||
type="button"
|
||||
className="se-canvas-auto-layout-btn"
|
||||
title="자동 정렬"
|
||||
aria-label="노드 자동 정렬"
|
||||
onClick={handleAutoLayout}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden>
|
||||
<rect x="3" y="3" width="7" height="7" rx="1" />
|
||||
<rect x="14" y="3" width="7" height="7" rx="1" />
|
||||
<rect x="3" y="14" width="7" height="7" rx="1" />
|
||||
<path d="M14 17h7M17.5 14v7" />
|
||||
</svg>
|
||||
</button>
|
||||
</Panel>
|
||||
<Panel position="top-left" className="se-canvas-toolbar">
|
||||
<span className="se-canvas-toolbar-title">전략 빌더</span>
|
||||
<div className="se-canvas-interaction" role="group" aria-label="캔버스 조작 모드">
|
||||
|
||||
@@ -10,6 +10,15 @@ import {
|
||||
} from '../../utils/strategyEditorShared';
|
||||
import { hasNodeSettings } from '../../utils/conditionPeriods';
|
||||
import ConditionNodeSettings from './ConditionNodeSettings';
|
||||
import {
|
||||
type EditorConditionState,
|
||||
addExtraStartSection,
|
||||
updateBranchRoot,
|
||||
updateStartCandleType,
|
||||
removeStartSection,
|
||||
collectStartSections,
|
||||
} from '../../utils/strategyConditionSerde';
|
||||
import { STRATEGY_CANDLE_TYPES } from '../../utils/strategyStartNodes';
|
||||
|
||||
const NODE_COLORS: Record<string, string> = {
|
||||
AND: '#4caf50',
|
||||
@@ -17,17 +26,20 @@ const NODE_COLORS: Record<string, string> = {
|
||||
NOT: '#ff9800',
|
||||
};
|
||||
const IND_COLOR = '#9c27b0';
|
||||
const LIST_DROP_KEY = '__list__';
|
||||
|
||||
interface TreeNodeProps {
|
||||
node: LogicNode;
|
||||
signalType: 'buy' | 'sell';
|
||||
onUpdate: (n: LogicNode) => void;
|
||||
onDelete: () => void;
|
||||
onDropOnNode?: (targetId: string, data: { type: string; value: string; label: string }) => void;
|
||||
dragOverId?: string | null;
|
||||
setDragOverId?: (id: string | null) => void;
|
||||
onDropOnNode?: (targetId: string, data: { type: string; value: string; label: string; composite?: boolean }) => void;
|
||||
dragOverKey?: string | null;
|
||||
setDragOverKey?: (key: string | null) => void;
|
||||
dropScope: string;
|
||||
depth?: number;
|
||||
def: DefType;
|
||||
onAddStart?: () => void;
|
||||
}
|
||||
|
||||
const TreeNodeComp: React.FC<TreeNodeProps> = ({
|
||||
@@ -36,13 +48,16 @@ const TreeNodeComp: React.FC<TreeNodeProps> = ({
|
||||
onUpdate,
|
||||
onDelete,
|
||||
onDropOnNode,
|
||||
dragOverId,
|
||||
setDragOverId,
|
||||
dragOverKey,
|
||||
setDragOverKey,
|
||||
dropScope,
|
||||
depth = 0,
|
||||
def,
|
||||
onAddStart,
|
||||
}) => {
|
||||
const isLogic = ['AND', 'OR', 'NOT'].includes(node.type);
|
||||
const color = isLogic ? NODE_COLORS[node.type] : IND_COLOR;
|
||||
const nodeDropKey = `${dropScope}:${node.id}`;
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const showSettings = node.type === 'CONDITION' && node.condition && hasNodeSettings(node.condition);
|
||||
const label = node.type === 'CONDITION' && node.condition
|
||||
@@ -57,16 +72,20 @@ const TreeNodeComp: React.FC<TreeNodeProps> = ({
|
||||
if (!isLogic) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setDragOverId?.(node.id);
|
||||
setDragOverKey?.(nodeDropKey);
|
||||
};
|
||||
const handleDragLeave = () => setDragOverId?.(null);
|
||||
const handleDragLeave = () => setDragOverKey?.(null);
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
if (!isLogic) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setDragOverId?.(null);
|
||||
setDragOverKey?.(null);
|
||||
try {
|
||||
const data = JSON.parse(e.dataTransfer.getData('application/json'));
|
||||
if (data.type === 'start') {
|
||||
onAddStart?.();
|
||||
return;
|
||||
}
|
||||
onDropOnNode?.(node.id, data);
|
||||
} catch {
|
||||
/* ignore */
|
||||
@@ -81,7 +100,7 @@ const TreeNodeComp: React.FC<TreeNodeProps> = ({
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`sp-tree-node${dragOverId === node.id ? ' sp-tree-node--over' : ''}`}
|
||||
className={`sp-tree-node${dragOverKey === nodeDropKey ? ' sp-tree-node--over' : ''}`}
|
||||
style={{ marginLeft: depth > 0 ? 12 : 0 }}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
@@ -138,13 +157,15 @@ const TreeNodeComp: React.FC<TreeNodeProps> = ({
|
||||
onUpdate={u => handleChildUpdate(child.id, u)}
|
||||
onDelete={() => handleChildDelete(child.id)}
|
||||
onDropOnNode={onDropOnNode}
|
||||
dragOverId={dragOverId}
|
||||
setDragOverId={setDragOverId}
|
||||
dragOverKey={dragOverKey}
|
||||
setDragOverKey={setDragOverKey}
|
||||
dropScope={dropScope}
|
||||
depth={depth + 1}
|
||||
def={def}
|
||||
onAddStart={onAddStart}
|
||||
/>
|
||||
))}
|
||||
{dragOverId === node.id && (
|
||||
{dragOverKey === nodeDropKey && (
|
||||
<div className="sp-drop-hint-inner">여기에 드롭하세요</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -153,37 +174,64 @@ const TreeNodeComp: React.FC<TreeNodeProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
interface Props {
|
||||
interface StartSectionBlockProps {
|
||||
startId: string;
|
||||
candleType: string;
|
||||
root: LogicNode | null;
|
||||
canDelete: boolean;
|
||||
signalTab: 'buy' | 'sell';
|
||||
def: DefType;
|
||||
onChange: (root: LogicNode | null) => void;
|
||||
dragOverKey: string | null;
|
||||
setDragOverKey: (key: string | null) => void;
|
||||
onRootChange: (root: LogicNode | null) => void;
|
||||
onCandleTypeChange: (candleType: string) => void;
|
||||
onDeleteStart?: () => void;
|
||||
onAddStart?: () => void;
|
||||
}
|
||||
|
||||
export default function StrategyListEditor({ root, signalTab, def, onChange }: Props) {
|
||||
const [dragOverId, setDragOverId] = useState<string | null>(null);
|
||||
function StartSectionBlock({
|
||||
startId,
|
||||
candleType,
|
||||
root,
|
||||
canDelete,
|
||||
signalTab,
|
||||
def,
|
||||
dragOverKey,
|
||||
setDragOverKey,
|
||||
onRootChange,
|
||||
onCandleTypeChange,
|
||||
onDeleteStart,
|
||||
onAddStart,
|
||||
}: StartSectionBlockProps) {
|
||||
const sectionDropKey = `${startId}:__root__`;
|
||||
|
||||
const applyDrop = useCallback((
|
||||
targetId: string | null,
|
||||
data: { type: string; value: string; label: string; composite?: boolean },
|
||||
) => {
|
||||
if (data.type === 'start') {
|
||||
onAddStart?.();
|
||||
return;
|
||||
}
|
||||
const newNode = makeNode(data.type, data.value, signalTab, def, data.composite ? { composite: true } : undefined);
|
||||
if (!root) {
|
||||
onChange(newNode);
|
||||
onRootChange(newNode);
|
||||
return;
|
||||
}
|
||||
if (!targetId) {
|
||||
onChange(mergeAtRoot(root, newNode, data.type === 'operator'));
|
||||
onRootChange(mergeAtRoot(root, newNode, data.type === 'operator'));
|
||||
return;
|
||||
}
|
||||
onChange(addChild(root, targetId, newNode));
|
||||
}, [root, signalTab, def, onChange]);
|
||||
onRootChange(addChild(root, targetId, newNode));
|
||||
}, [root, signalTab, def, onRootChange, onAddStart]);
|
||||
|
||||
const handleRootDrop = (e: React.DragEvent) => {
|
||||
const handleSectionDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setDragOverId(null);
|
||||
e.stopPropagation();
|
||||
setDragOverKey(null);
|
||||
try {
|
||||
applyDrop(null, JSON.parse(e.dataTransfer.getData('application/json')));
|
||||
const data = JSON.parse(e.dataTransfer.getData('application/json'));
|
||||
applyDrop(null, data);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
@@ -193,27 +241,153 @@ export default function StrategyListEditor({ root, signalTab, def, onChange }: P
|
||||
applyDrop(targetId, data);
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="sp-start-section">
|
||||
<header className="sp-start-head">
|
||||
<span className="sp-start-dot" aria-hidden />
|
||||
<span className="sp-start-label">START</span>
|
||||
<select
|
||||
className="sp-start-tf"
|
||||
value={candleType}
|
||||
title="조건 판별 시간봉"
|
||||
onChange={e => onCandleTypeChange(e.target.value)}
|
||||
>
|
||||
{STRATEGY_CANDLE_TYPES.map(ct => (
|
||||
<option key={ct} value={ct}>{ct}</option>
|
||||
))}
|
||||
</select>
|
||||
{canDelete && (
|
||||
<button type="button" className="sp-start-del" title="START 삭제" onClick={onDeleteStart}>×</button>
|
||||
)}
|
||||
</header>
|
||||
|
||||
<div
|
||||
className={`sp-start-body${dragOverKey === sectionDropKey ? ' sp-start-body--over' : ''}`}
|
||||
onDragOver={e => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setDragOverKey(sectionDropKey);
|
||||
}}
|
||||
onDragLeave={() => setDragOverKey(null)}
|
||||
onDrop={handleSectionDrop}
|
||||
>
|
||||
{!root ? (
|
||||
<div className="sp-drop-empty sp-drop-empty--section">
|
||||
논리 연산자·지표를 드래그하여 [{candleType}] 조건을 구성하세요.
|
||||
</div>
|
||||
) : (
|
||||
<TreeNodeComp
|
||||
node={root}
|
||||
signalType={signalTab}
|
||||
onUpdate={onRootChange}
|
||||
onDelete={() => onRootChange(null)}
|
||||
onDropOnNode={handleDropOnNode}
|
||||
dragOverKey={dragOverKey}
|
||||
setDragOverKey={setDragOverKey}
|
||||
dropScope={startId}
|
||||
def={def}
|
||||
onAddStart={onAddStart}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
interface Props {
|
||||
editorState: EditorConditionState;
|
||||
signalTab: 'buy' | 'sell';
|
||||
def: DefType;
|
||||
onEditorStateChange: (next: EditorConditionState) => void;
|
||||
onAddStart?: () => void;
|
||||
onOrphansChange?: (nodes: LogicNode[]) => void;
|
||||
orphans?: LogicNode[];
|
||||
}
|
||||
|
||||
export default function StrategyListEditor({
|
||||
editorState,
|
||||
signalTab,
|
||||
def,
|
||||
onEditorStateChange,
|
||||
onAddStart,
|
||||
onOrphansChange,
|
||||
orphans = [],
|
||||
}: Props) {
|
||||
const [dragOverKey, setDragOverKey] = useState<string | null>(null);
|
||||
const sections = collectStartSections(editorState);
|
||||
|
||||
const handleAddStart = useCallback(() => {
|
||||
if (onAddStart) {
|
||||
onAddStart();
|
||||
return;
|
||||
}
|
||||
onEditorStateChange(addExtraStartSection(editorState));
|
||||
}, [onAddStart, onEditorStateChange, editorState]);
|
||||
|
||||
const handleRootChange = useCallback((startId: string, root: LogicNode | null) => {
|
||||
onEditorStateChange(updateBranchRoot(editorState, startId, root));
|
||||
}, [editorState, onEditorStateChange]);
|
||||
|
||||
const handleCandleTypeChange = useCallback((startId: string, candleType: string) => {
|
||||
onEditorStateChange(updateStartCandleType(editorState, startId, candleType));
|
||||
}, [editorState, onEditorStateChange]);
|
||||
|
||||
const handleDeleteStart = useCallback((startId: string) => {
|
||||
const { state, orphanedNodes } = removeStartSection(editorState, startId);
|
||||
onEditorStateChange(state);
|
||||
if (orphanedNodes.length && onOrphansChange) {
|
||||
onOrphansChange([...orphans, ...orphanedNodes]);
|
||||
}
|
||||
}, [editorState, onEditorStateChange, onOrphansChange, orphans]);
|
||||
|
||||
const handleListDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'copy';
|
||||
setDragOverKey(LIST_DROP_KEY);
|
||||
};
|
||||
|
||||
const handleListDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setDragOverKey(null);
|
||||
try {
|
||||
const data = JSON.parse(e.dataTransfer.getData('application/json'));
|
||||
if (data.type === 'start') {
|
||||
handleAddStart();
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="se-list-editor sp-dropzone"
|
||||
onDragOver={e => e.preventDefault()}
|
||||
onDrop={handleRootDrop}
|
||||
className={`se-list-editor sp-dropzone${dragOverKey === LIST_DROP_KEY ? ' se-list-editor--over' : ''}`}
|
||||
onDragOver={handleListDragOver}
|
||||
onDragLeave={() => setDragOverKey(null)}
|
||||
onDrop={handleListDrop}
|
||||
>
|
||||
{!root ? (
|
||||
<div className="sp-drop-empty">
|
||||
우측 패널에서 논리 연산자나 지표를 드래그하여 전략을 시작하세요.
|
||||
</div>
|
||||
) : (
|
||||
<TreeNodeComp
|
||||
node={root}
|
||||
signalType={signalTab}
|
||||
onUpdate={onChange}
|
||||
onDelete={() => onChange(null)}
|
||||
onDropOnNode={handleDropOnNode}
|
||||
dragOverId={dragOverId}
|
||||
setDragOverId={setDragOverId}
|
||||
{sections.map(section => (
|
||||
<StartSectionBlock
|
||||
key={section.startId}
|
||||
startId={section.startId}
|
||||
candleType={section.candleType}
|
||||
root={section.root}
|
||||
canDelete={section.canDelete}
|
||||
signalTab={signalTab}
|
||||
def={def}
|
||||
dragOverKey={dragOverKey}
|
||||
setDragOverKey={setDragOverKey}
|
||||
onRootChange={root => handleRootChange(section.startId, root)}
|
||||
onCandleTypeChange={ct => handleCandleTypeChange(section.startId, ct)}
|
||||
onDeleteStart={section.canDelete ? () => handleDeleteStart(section.startId) : undefined}
|
||||
onAddStart={handleAddStart}
|
||||
/>
|
||||
))}
|
||||
{sections.length === 0 && (
|
||||
<div className="sp-drop-empty">
|
||||
START를 이 영역에 드래그하거나 우측 패널에서 추가하세요.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user