다중 시간봉 전략 설정기능 추가

This commit is contained in:
Macbook
2026-05-25 02:49:39 +09:00
parent cc01f311bc
commit 30dedc4abc
17 changed files with 1903 additions and 249 deletions
+236 -30
View File
@@ -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}