From 30dedc4abcb10e90a9a465e20bd683baf29254f6 Mon Sep 17 00:00:00 2001 From: Macbook Date: Mon, 25 May 2026 02:49:39 +0900 Subject: [PATCH] =?UTF-8?q?=EB=8B=A4=EC=A4=91=20=EC=8B=9C=EA=B0=84?= =?UTF-8?q?=EB=B4=89=20=EC=A0=84=EB=9E=B5=20=EC=84=A4=EC=A0=95=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/LiveStrategyEvaluator.java | 9 +- .../service/StrategyDslToTa4jAdapter.java | 110 ++++- .../src/components/StrategyEditorPage.tsx | 266 ++++++++++-- frontend/src/components/StrategyPage.tsx | 2 +- .../components/strategyEditor/FlowNodes.tsx | 49 ++- .../strategyEditor/LogicExpressionPreview.tsx | 90 ++++- .../components/strategyEditor/PaletteChip.tsx | 4 +- .../strategyEditor/StartCombineOpControl.tsx | 33 ++ .../strategyEditor/StrategyEditorCanvas.tsx | 377 ++++++++++++++---- .../strategyEditor/StrategyListEditor.tsx | 252 ++++++++++-- frontend/src/styles/strategyEditor.css | 272 ++++++++++++- frontend/src/styles/strategyEditorTheme.css | 18 +- frontend/src/utils/strategyConditionSerde.ts | 259 ++++++++++++ .../src/utils/strategyEditorLayoutStorage.ts | 18 +- frontend/src/utils/strategyFlowLayout.ts | 348 +++++++++++++--- frontend/src/utils/strategyStartNodes.ts | 41 ++ frontend/src/utils/strategyTypes.ts | 4 +- 17 files changed, 1903 insertions(+), 249 deletions(-) create mode 100644 frontend/src/components/strategyEditor/StartCombineOpControl.tsx create mode 100644 frontend/src/utils/strategyConditionSerde.ts create mode 100644 frontend/src/utils/strategyStartNodes.ts diff --git a/backend/src/main/java/com/goldenchart/service/LiveStrategyEvaluator.java b/backend/src/main/java/com/goldenchart/service/LiveStrategyEvaluator.java index 7ea9f4c..7f3bcc7 100644 --- a/backend/src/main/java/com/goldenchart/service/LiveStrategyEvaluator.java +++ b/backend/src/main/java/com/goldenchart/service/LiveStrategyEvaluator.java @@ -307,8 +307,8 @@ public class LiveStrategyEvaluator { log.debug("[Evaluator] Strategy 빌드: strategyId={} market={} barCount={}", strategyId, market, barCount); try { - Rule entryRule = buildRule(strategy.getBuyConditionJson(), series, indicatorParams); - Rule exitRule = buildRule(strategy.getSellConditionJson(), series, indicatorParams); + Rule entryRule = buildRule(strategy.getBuyConditionJson(), series, indicatorParams, market); + Rule exitRule = buildRule(strategy.getSellConditionJson(), series, indicatorParams, market); BaseStrategy builtStrategy = new BaseStrategy(entryRule, exitRule); return builtStrategy; } catch (Exception e) { @@ -318,11 +318,12 @@ public class LiveStrategyEvaluator { } private Rule buildRule(String conditionJson, BarSeries series, - Map> params) { + Map> params, + String market) { if (conditionJson == null || conditionJson.isBlank()) return new BooleanRule(false); try { JsonNode node = objectMapper.readTree(conditionJson); - return adapter.toRule(node, series, params); + return adapter.toRule(node, series, params, market, ta4jStorage); } catch (Exception e) { log.warn("[Evaluator] 조건 JSON 파싱 실패: {}", e.getMessage()); return new BooleanRule(false); diff --git a/backend/src/main/java/com/goldenchart/service/StrategyDslToTa4jAdapter.java b/backend/src/main/java/com/goldenchart/service/StrategyDslToTa4jAdapter.java index a09324f..c5fc28f 100644 --- a/backend/src/main/java/com/goldenchart/service/StrategyDslToTa4jAdapter.java +++ b/backend/src/main/java/com/goldenchart/service/StrategyDslToTa4jAdapter.java @@ -1,6 +1,7 @@ package com.goldenchart.service; import com.fasterxml.jackson.databind.JsonNode; +import com.goldenchart.storage.Ta4jStorage; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; import org.ta4j.core.BarSeries; @@ -98,56 +99,133 @@ public class StrategyDslToTa4jAdapter { // ── Public API ──────────────────────────────────────────────────────────── + public record RuleBuildContext( + BarSeries primarySeries, + Map> indicatorParams, + String market, + Ta4jStorage storage + ) { + BarSeries resolveSeries(String candleType) { + String ct = LiveStrategyTimeframeService.normalize(candleType); + if (storage != null && market != null && !market.isBlank() + && storage.exists(market, ct)) { + return storage.getOrCreate(market, ct); + } + return primarySeries; + } + } + public Rule toRule(JsonNode node, BarSeries series, Map> indicatorParams) { + return toRule(node, new RuleBuildContext(series, indicatorParams, null, null)); + } + + public Rule toRule(JsonNode node, BarSeries series, + Map> indicatorParams, + String market, Ta4jStorage storage) { + return toRule(node, new RuleBuildContext(series, indicatorParams, market, storage)); + } + + public Rule toRule(JsonNode node, RuleBuildContext ctx) { if (node == null || node.isNull()) return new BooleanRule(false); String type = node.path("type").asText("CONDITION"); return switch (type) { - case "AND" -> buildAndRule(node, series, indicatorParams); - case "OR" -> buildOrRule(node, series, indicatorParams); - case "NOT" -> buildNotRule(node, series, indicatorParams); - default -> buildConditionRule(node.path("condition"), series, indicatorParams); + case "AND" -> buildAndRule(node, ctx); + case "OR" -> buildOrRule(node, ctx); + case "NOT" -> buildNotRule(node, ctx); + case "TIMEFRAME" -> buildTimeframeRule(node, ctx); + default -> buildConditionRule(node.path("condition"), ctx.primarySeries(), ctx.indicatorParams()); }; } + private Rule buildTimeframeRule(JsonNode node, RuleBuildContext ctx) { + String ct = node.path("candleType").asText("1m"); + BarSeries branchSeries = ctx.resolveSeries(ct); + RuleBuildContext branchCtx = new RuleBuildContext( + branchSeries, ctx.indicatorParams(), ctx.market(), ctx.storage()); + List rules = childRules(node, branchCtx); + if (rules.isEmpty()) return new BooleanRule(false); + Rule inner = rules.get(0); + if (branchSeries == ctx.primarySeries()) return inner; + return new CrossSeriesRule(inner, branchSeries, ctx.primarySeries()); + } + + /** 다른 시간봉 시리즈로 빌드된 Rule — 트리거 시리즈 인덱스에서 해당 분봉 최신봉으로 평가 */ + private static final class CrossSeriesRule implements Rule { + private final Rule inner; + private final BarSeries branchSeries; + private final BarSeries triggerSeries; + + CrossSeriesRule(Rule inner, BarSeries branchSeries, BarSeries triggerSeries) { + this.inner = inner; + this.branchSeries = branchSeries; + this.triggerSeries = triggerSeries; + } + + @Override + public boolean isSatisfied(int index, TradingRecord tradingRecord) { + int evalIndex = branchSeries == triggerSeries + ? index + : branchSeries.getEndIndex(); + if (evalIndex < 0 || branchSeries.getBarCount() == 0) return false; + return inner.isSatisfied(evalIndex, tradingRecord); + } + } + // ── AND / OR / NOT ──────────────────────────────────────────────────────── - private Rule buildAndRule(JsonNode node, BarSeries series, - Map> p) { - List rules = childRules(node, series, p); + private Rule buildAndRule(JsonNode node, RuleBuildContext ctx) { + List rules = childRules(node, ctx); if (rules.isEmpty()) return new BooleanRule(false); Rule result = rules.get(0); for (int i = 1; i < rules.size(); i++) result = new AndRule(result, rules.get(i)); return result; } - private Rule buildOrRule(JsonNode node, BarSeries series, - Map> p) { - List rules = childRules(node, series, p); + private Rule buildOrRule(JsonNode node, RuleBuildContext ctx) { + List rules = childRules(node, ctx); if (rules.isEmpty()) return new BooleanRule(false); Rule result = rules.get(0); for (int i = 1; i < rules.size(); i++) result = new OrRule(result, rules.get(i)); return result; } - private Rule buildNotRule(JsonNode node, BarSeries series, - Map> p) { - List rules = childRules(node, series, p); + private Rule buildNotRule(JsonNode node, RuleBuildContext ctx) { + List rules = childRules(node, ctx); if (rules.isEmpty()) return new BooleanRule(false); return new NotRule(rules.get(0)); } - private List childRules(JsonNode node, BarSeries series, - Map> p) { + private List childRules(JsonNode node, RuleBuildContext ctx) { List list = new ArrayList<>(); JsonNode children = node.path("children"); if (children.isArray()) { - for (JsonNode child : children) list.add(toRule(child, series, p)); + for (JsonNode child : children) list.add(toRule(child, ctx)); } return list; } + private Rule buildAndRule(JsonNode node, BarSeries series, + Map> p) { + return buildAndRule(node, new RuleBuildContext(series, p, null, null)); + } + + private Rule buildOrRule(JsonNode node, BarSeries series, + Map> p) { + return buildOrRule(node, new RuleBuildContext(series, p, null, null)); + } + + private Rule buildNotRule(JsonNode node, BarSeries series, + Map> p) { + return buildNotRule(node, new RuleBuildContext(series, p, null, null)); + } + + private List childRules(JsonNode node, BarSeries series, + Map> p) { + return childRules(node, new RuleBuildContext(series, p, null, null)); + } + // ── CONDITION ───────────────────────────────────────────────────────────── private Rule buildConditionRule(JsonNode cond, BarSeries series, diff --git a/frontend/src/components/StrategyEditorPage.tsx b/frontend/src/components/StrategyEditorPage.tsx index b673e0d..02adbcf 100644 --- a/frontend/src/components/StrategyEditorPage.tsx +++ b/frontend/src/components/StrategyEditorPage.tsx @@ -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, +): 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) => { + 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) => { + 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) {
-
+
{stratName && {stratName}} + {hasMultipleStartSections(currentEditorState) && ( + + )}
{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} /> @@ -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) { ) : ( )} @@ -811,6 +1005,8 @@ export default function StrategyEditorPage({ theme }: Props) { @@ -834,8 +1030,18 @@ export default function StrategyEditorPage({ theme }: Props) { onChange={e => setPaletteSearch(e.target.value)} />
-

논리 연산

+

시작 · 논리

+ setSelectedPaletteKey('start:START')} + onAdd={() => applyPalette('start', 'START', 'START')} + /> {operators.map(op => ( e.stopPropagation(); return (
-
- START +
+
+ START + + {canDelete && ( + + )} +
); }); diff --git a/frontend/src/components/strategyEditor/LogicExpressionPreview.tsx b/frontend/src/components/strategyEditor/LogicExpressionPreview.tsx index a8cedf2..cb439b3 100644 --- a/frontend/src/components/strategyEditor/LogicExpressionPreview.tsx +++ b/frontend/src/components/strategyEditor/LogicExpressionPreview.tsx @@ -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 ? ( + <> + [{tf}] + {' '} + {renderNode(inner, def)} + + ) : ( + [{tf} 빈 조건] + ); + } + 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 (연결된 조건 없음); + } + + if (branches.length === 1) { + const { candleType, root } = branches[0]; + if (!root) return (연결된 조건 없음); + return ( + <> + [{formatStartCandleLabel(candleType)}] + {' '} + {renderNode(root, def)} + + ); + } + + const combineOp = normalizeStartCombineOp(editorState?.startCombineOp); + const combineClass = combineOp === 'AND' ? 'se-formula-logic--and' : 'se-formula-logic--or'; + + return ( + <> + ( + {branches.map((branch, i) => ( + + {i > 0 ? ( + {combineOp} + ) : null} + {branch.root ? ( + <> + [{formatStartCandleLabel(branch.candleType)}] + {' '} + {renderNode(branch.root, def)} + + ) : ( + [{formatStartCandleLabel(branch.candleType)} 빈 조건] + )} + + ))} + ) + + ); +} + 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 (
{!hasBody && ( (연결된 조건을 구성하세요) )} - {buyCondition && ( + {(buyCondition || buyEditorState) && (
[매수] {' '} - {renderNode(buyCondition, def)} + {renderSignalBranches(buyEditorState, buyCondition, def)}
)} - {sellCondition && ( + {(sellCondition || sellEditorState) && (
[매도] {' '} - {renderNode(sellCondition, def)} + {renderSignalBranches(sellEditorState, sellCondition, def)}
)} {orphanCount > 0 && ( diff --git a/frontend/src/components/strategyEditor/PaletteChip.tsx b/frontend/src/components/strategyEditor/PaletteChip.tsx index 18bb5e8..da6748b 100644 --- a/frontend/src/components/strategyEditor/PaletteChip.tsx +++ b/frontend/src/components/strategyEditor/PaletteChip.tsx @@ -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; diff --git a/frontend/src/components/strategyEditor/StartCombineOpControl.tsx b/frontend/src/components/strategyEditor/StartCombineOpControl.tsx new file mode 100644 index 0000000..a9fad5e --- /dev/null +++ b/frontend/src/components/strategyEditor/StartCombineOpControl.tsx @@ -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 ( +
+ START 간 +
+ + +
+
+ ); +} diff --git a/frontend/src/components/strategyEditor/StrategyEditorCanvas.tsx b/frontend/src/components/strategyEditor/StrategyEditorCanvas.tsx index 256cef5..595d8d6 100644 --- a/frontend/src/components/strategyEditor/StrategyEditorCanvas.tsx +++ b/frontend/src/components/strategyEditor/StrategyEditorCanvas.tsx @@ -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; + extraStartIds?: string[]; + extraRoots?: Record; + onStartMetaChange?: (meta: Record) => void; + onExtraStartIdsChange?: (ids: string[]) => void; + onExtraRootsChange?: (roots: Record) => 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, onChange: (root: LogicNode | null) => void, + onExtraRootsChange: (roots: Record) => 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, edges: Edge[]) { @@ -152,11 +216,21 @@ function connectOrphanIntoTree( conn: Connection, orphan: LogicNode, root: LogicNode | null, + extraRoots: Record, onChange: (root: LogicNode | null) => void, + onExtraRootsChange: (roots: Record) => 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()); @@ -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([START_NODE_ID]); + const ids = new Set([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} /> + + + 전략 빌더
diff --git a/frontend/src/components/strategyEditor/StrategyListEditor.tsx b/frontend/src/components/strategyEditor/StrategyListEditor.tsx index fe39510..d9c3f6b 100644 --- a/frontend/src/components/strategyEditor/StrategyListEditor.tsx +++ b/frontend/src/components/strategyEditor/StrategyListEditor.tsx @@ -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 = { AND: '#4caf50', @@ -17,17 +26,20 @@ const NODE_COLORS: Record = { 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 = ({ @@ -36,13 +48,16 @@ const TreeNodeComp: React.FC = ({ 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 = ({ 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 = ({ return (
0 ? 12 : 0 }} onDragOver={handleDragOver} onDragLeave={handleDragLeave} @@ -138,13 +157,15 @@ const TreeNodeComp: React.FC = ({ 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 && (
여기에 드롭하세요
)}
@@ -153,37 +174,64 @@ const TreeNodeComp: React.FC = ({ ); }; -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(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 ( +
+
+ + START + + {canDelete && ( + + )} +
+ +
{ + e.preventDefault(); + e.stopPropagation(); + setDragOverKey(sectionDropKey); + }} + onDragLeave={() => setDragOverKey(null)} + onDrop={handleSectionDrop} + > + {!root ? ( +
+ 논리 연산자·지표를 드래그하여 [{candleType}] 조건을 구성하세요. +
+ ) : ( + onRootChange(null)} + onDropOnNode={handleDropOnNode} + dragOverKey={dragOverKey} + setDragOverKey={setDragOverKey} + dropScope={startId} + def={def} + onAddStart={onAddStart} + /> + )} +
+
+ ); +} + +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(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 (
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 ? ( -
- 우측 패널에서 논리 연산자나 지표를 드래그하여 전략을 시작하세요. -
- ) : ( - onChange(null)} - onDropOnNode={handleDropOnNode} - dragOverId={dragOverId} - setDragOverId={setDragOverId} + {sections.map(section => ( + handleRootChange(section.startId, root)} + onCandleTypeChange={ct => handleCandleTypeChange(section.startId, ct)} + onDeleteStart={section.canDelete ? () => handleDeleteStart(section.startId) : undefined} + onAddStart={handleAddStart} /> + ))} + {sections.length === 0 && ( +
+ START를 이 영역에 드래그하거나 우측 패널에서 추가하세요. +
)}
); diff --git a/frontend/src/styles/strategyEditor.css b/frontend/src/styles/strategyEditor.css index a301d8c..be008d0 100644 --- a/frontend/src/styles/strategyEditor.css +++ b/frontend/src/styles/strategyEditor.css @@ -92,12 +92,136 @@ .se-list-editor { flex: 1; min-height: 0; - overflow: auto; + overflow-x: hidden; + overflow-y: auto; + overscroll-behavior: contain; + -webkit-overflow-scrolling: touch; margin: 8px 12px 12px; padding: 12px; + padding-bottom: 28px; border: 1px dashed var(--se-border); border-radius: 10px; background: color-mix(in srgb, var(--se-center-bg) 92%, var(--se-accent) 8%); + display: flex; + flex-direction: column; + gap: 14px; + align-items: stretch; + transition: border-color 0.15s, background 0.15s; +} + +/* App.css .sp-dropzone 와 중복 flex/overflow 정리 */ +.se-list-editor.sp-dropzone { + flex: 1; + min-height: 0; + overflow-x: hidden; + overflow-y: auto; + padding: 12px; + padding-bottom: 28px; +} + +.se-list-editor--over { + border-color: var(--se-node-start-border, var(--se-gold)); + background: color-mix(in srgb, var(--se-node-start-accent, var(--se-gold)) 8%, var(--se-center-bg)); +} + +.sp-start-section { + border: 1px solid var(--se-node-start-border); + border-radius: 10px; + background: var(--se-node-start-bg); + box-shadow: 0 0 14px color-mix(in srgb, var(--se-node-start-accent, var(--se-gold)) 14%, transparent); + flex-shrink: 0; + overflow: visible; +} + +.sp-start-head { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + border-bottom: 1px solid color-mix(in srgb, var(--se-node-start-border) 70%, transparent); + background: color-mix(in srgb, var(--se-node-start-accent, var(--se-gold)) 8%, transparent); +} + +.sp-start-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--se-node-start-accent, var(--se-gold)); + box-shadow: 0 0 6px color-mix(in srgb, var(--se-node-start-accent, var(--se-gold)) 50%, transparent); + flex-shrink: 0; +} + +.sp-start-label { + font-weight: 800; + font-size: 0.78rem; + letter-spacing: 0.05em; + color: var(--se-node-start-accent, var(--se-gold)); +} + +.sp-start-tf { + font-size: 0.72rem; + padding: 3px 6px; + border-radius: 4px; + border: 1px solid color-mix(in srgb, var(--se-node-start-border) 80%, transparent); + background: color-mix(in srgb, var(--se-bg) 75%, transparent); + color: var(--se-text); + cursor: pointer; +} + +.sp-start-del { + margin-left: auto; + width: 22px; + height: 22px; + border: none; + border-radius: 4px; + background: color-mix(in srgb, var(--se-danger) 15%, transparent); + color: var(--se-danger); + cursor: pointer; + font-size: 14px; + line-height: 1; +} + +.sp-start-body { + padding: 10px 12px 12px; + min-height: 56px; + height: auto; + overflow: visible; + transition: background 0.15s; +} + +/* 목록형: START 내부 조건 트리 전체 표시 */ +.se-list-editor .sp-tree-node { + flex-shrink: 0; + overflow: visible; +} + +.se-list-editor .sp-node-label { + white-space: normal; + overflow: visible; + text-overflow: unset; + line-height: 1.35; +} + +.se-list-editor .sp-node-children { + overflow: visible; +} + +.se-list-editor .sp-cond-editor { + overflow: visible; +} + +.se-list-editor .sp-cond-row4 { + grid-template-columns: repeat(auto-fit, minmax(132px, 1fr)); +} + +.sp-start-body--over { + background: color-mix(in srgb, var(--se-accent) 10%, transparent); +} + +.sp-drop-empty--section { + margin: 0; + padding: 16px 8px; + font-size: 0.78rem; } .se-btn { @@ -394,16 +518,73 @@ min-height: 0; display: flex; flex-direction: column; + overflow: hidden; +} + +.se-center-work--list .se-list-editor { + flex: 1; + min-height: 0; } .se-center-head { display: flex; align-items: center; justify-content: space-between; + gap: 10px; + flex-wrap: wrap; padding: 8px 12px 0; flex-shrink: 0; } +.se-start-combine { + display: flex; + align-items: center; + gap: 8px; + margin-left: auto; +} + +.se-start-combine-label { + font-size: 0.72rem; + font-weight: 600; + color: var(--se-text-muted); + white-space: nowrap; +} + +.se-start-combine-toggle { + display: inline-flex; + border: 1px solid var(--se-border); + border-radius: 6px; + overflow: hidden; + background: var(--se-btn-bg); +} + +.se-start-combine-btn { + border: none; + background: transparent; + color: var(--se-text-muted); + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.03em; + padding: 5px 12px; + cursor: pointer; + transition: background 0.15s, color 0.15s; +} + +.se-start-combine-btn--and.se-start-combine-btn--on { + background: color-mix(in srgb, #4caf50 22%, transparent); + color: #4caf50; +} + +.se-start-combine-btn--or.se-start-combine-btn--on { + background: color-mix(in srgb, #2196f3 22%, transparent); + color: #2196f3; +} + +.se-start-combine-btn:hover:not(.se-start-combine-btn--on) { + background: color-mix(in srgb, var(--se-accent) 10%, transparent); + color: var(--se-text); +} + .se-signal-tabs { display: flex; gap: 6px; } .se-signal-tab { @@ -500,6 +681,35 @@ box-shadow: inset 0 -2px 0 var(--se-tab-active); } +.se-canvas-auto-layout { + margin: 8px 10px 0 0; +} + +.se-canvas-auto-layout-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 34px; + height: 34px; + border: 1px solid var(--se-border); + border-radius: 8px; + background: var(--se-toolbar-bg); + color: var(--se-text-muted); + cursor: pointer; + transition: background 0.12s, color 0.12s, border-color 0.12s, box-shadow 0.12s; + box-shadow: 0 2px 8px color-mix(in srgb, var(--se-bg) 40%, transparent); +} + +.se-canvas-auto-layout-btn:hover { + background: color-mix(in srgb, var(--se-accent) 12%, var(--se-toolbar-bg)); + color: var(--se-tab-active); + border-color: color-mix(in srgb, var(--se-accent) 35%, var(--se-border)); +} + +.se-canvas-auto-layout-btn:active { + transform: scale(0.96); +} + .se-canvas-wrap--pan .react-flow__pane { cursor: grab; } @@ -652,17 +862,62 @@ .se-flow-node--start { display: flex; - align-items: center; - gap: 8px; - padding: 8px 14px; + flex-direction: column; + align-items: stretch; + gap: 6px; + padding: 8px 10px; + min-width: 150px; background: var(--se-node-start-bg); border: 1px solid var(--se-node-start-border); color: var(--se-text-muted); + box-shadow: 0 0 18px color-mix(in srgb, var(--se-node-start-accent, var(--se-gold)) 20%, transparent); +} +.se-flow-node--start.se-flow-node--sell-mode { + background: linear-gradient(145deg, color-mix(in srgb, var(--se-sell) 18%, transparent), color-mix(in srgb, var(--bg) 92%, transparent)); + border-color: color-mix(in srgb, var(--se-sell) 42%, transparent); + box-shadow: 0 0 18px color-mix(in srgb, var(--se-sell) 16%, transparent); +} +.se-flow-start-head { + display: flex; + align-items: center; + gap: 6px; + flex-wrap: nowrap; +} +.se-flow-start-label { + font-weight: 700; + font-size: 0.72rem; + letter-spacing: 0.04em; + flex-shrink: 0; + color: var(--se-node-start-accent, var(--se-gold)); +} +.se-flow-start-tf { + flex: 1; + min-width: 0; + max-width: 72px; + font-size: 0.65rem; + padding: 2px 4px; + border-radius: 4px; + border: 1px solid color-mix(in srgb, var(--se-node-start-border) 80%, transparent); + background: color-mix(in srgb, var(--se-bg) 70%, transparent); + color: var(--se-text); + cursor: pointer; +} +.se-flow-start-tf:focus { + outline: none; + border-color: var(--se-accent); +} +.se-flow-del--start { + position: static; + flex-shrink: 0; + width: 18px; + height: 18px; + line-height: 16px; + font-size: 14px; } .se-flow-start-dot { width: 8px; height: 8px; border-radius: 50%; - background: var(--se-text-dim); - box-shadow: 0 0 6px color-mix(in srgb, var(--se-text-dim) 60%, transparent); + background: var(--se-node-start-accent, var(--se-gold)); + box-shadow: 0 0 8px color-mix(in srgb, var(--se-node-start-accent, var(--se-gold)) 55%, transparent); } .se-flow-node--logic { @@ -1000,6 +1255,11 @@ color: var(--se-sell); font-weight: 700; } +.se-formula-tf { + color: var(--se-accent); + font-weight: 700; + font-size: 0.85em; +} .se-formula-logic { font-weight: 800; letter-spacing: 0.03em; diff --git a/frontend/src/styles/strategyEditorTheme.css b/frontend/src/styles/strategyEditorTheme.css index 1e5bd46..b406fc9 100644 --- a/frontend/src/styles/strategyEditorTheme.css +++ b/frontend/src/styles/strategyEditorTheme.css @@ -77,8 +77,9 @@ --se-node-logic-bg: linear-gradient(145deg, color-mix(in srgb, var(--accent) 18%, transparent), color-mix(in srgb, var(--bg) 92%, transparent)); --se-node-cond-bg: linear-gradient(145deg, color-mix(in srgb, var(--se-success) 16%, transparent), color-mix(in srgb, var(--bg) 92%, transparent)); --se-node-cond-border: color-mix(in srgb, var(--se-success) 42%, transparent); - --se-node-start-bg: color-mix(in srgb, var(--bg3) 88%, transparent); - --se-node-start-border: var(--border); + --se-node-start-bg: linear-gradient(145deg, color-mix(in srgb, var(--se-gold) 22%, transparent), color-mix(in srgb, var(--bg2) 92%, transparent)); + --se-node-start-border: color-mix(in srgb, var(--se-gold) 48%, transparent); + --se-node-start-accent: var(--se-gold); --se-btn-bg: color-mix(in srgb, var(--bg2) 90%, transparent); --se-btn-gold-bg: linear-gradient(135deg, color-mix(in srgb, var(--se-gold) 25%, transparent), color-mix(in srgb, var(--se-gold) 12%, transparent)); --se-btn-gold-border: color-mix(in srgb, var(--se-gold) 55%, transparent); @@ -120,8 +121,9 @@ --se-node-logic-bg: linear-gradient(145deg, rgba(25, 118, 210, 0.08), #ffffff); --se-node-cond-bg: linear-gradient(145deg, rgba(46, 125, 50, 0.08), #ffffff); --se-node-cond-border: rgba(46, 125, 50, 0.35); - --se-node-start-bg: #f5f5f5; - --se-node-start-border: rgba(0, 0, 0, 0.12); + --se-node-start-bg: linear-gradient(145deg, rgba(245, 127, 23, 0.14), #ffffff); + --se-node-start-border: rgba(245, 127, 23, 0.42); + --se-node-start-accent: #e65100; --se-btn-gold-bg: linear-gradient(135deg, rgba(25, 118, 210, 0.12), rgba(25, 118, 210, 0.04)); --se-btn-gold-border: rgba(25, 118, 210, 0.45); --se-btn-gold-text: #1565c0; @@ -163,7 +165,9 @@ --se-node-logic-bg: linear-gradient(145deg, rgba(94, 181, 255, 0.14), rgba(12, 25, 41, 0.92)); --se-node-cond-bg: linear-gradient(145deg, rgba(105, 240, 174, 0.12), rgba(12, 25, 41, 0.92)); --se-node-cond-border: rgba(105, 240, 174, 0.38); - --se-node-start-bg: rgba(21, 39, 61, 0.88); + --se-node-start-bg: linear-gradient(145deg, rgba(255, 213, 79, 0.18), rgba(12, 25, 41, 0.92)); + --se-node-start-border: rgba(255, 213, 79, 0.45); + --se-node-start-accent: #ffd54f; --se-btn-gold-text: #ffd54f; --se-minimap-bg: rgba(12, 25, 41, 0.96); --se-palette-logic-and-accent: #5eb5ff; @@ -191,6 +195,8 @@ box-shadow: 0 2px 12px rgba(198, 40, 40, 0.06); } .se-page--light .se-flow-gate-badge { color: #1565c0; } +.se-page--light .se-flow-start-label { color: var(--se-node-start-accent, #e65100); } +.se-page--light .se-flow-start-dot { background: var(--se-node-start-accent, #e65100); } .se-page--light .se-flow-cond-badge { color: #2e7d32; } .se-page--light .se-flow-node--cross .se-flow-cond-badge { color: #f57f17; } .se-page--light .se-flow-node--sell-mode .se-flow-cond-badge { color: #c62828; } @@ -216,6 +222,8 @@ } .se-page--blue .se-flow-gate-badge { color: #5eb5ff; } +.se-page--blue .se-flow-start-label { color: var(--se-node-start-accent, #ffd54f); } +.se-page--blue .se-flow-start-dot { background: var(--se-node-start-accent, #ffd54f); } .se-page--blue .se-flow-cond-badge { color: #69f0ae; } .se-page--blue .se-flow-handle { background: #5eb5ff !important; diff --git a/frontend/src/utils/strategyConditionSerde.ts b/frontend/src/utils/strategyConditionSerde.ts new file mode 100644 index 0000000..af2356b --- /dev/null +++ b/frontend/src/utils/strategyConditionSerde.ts @@ -0,0 +1,259 @@ +import type { LogicNode } from './strategyTypes'; +import { generateNodeId } from './strategyTypes'; +import { subtreeToFlatOrphans } from './strategyFlowLayout'; +import { + DEFAULT_START_CANDLE, + DEFAULT_START_COMBINE_OP, + START_NODE_ID, + createStartNodeId, + defaultStartMeta, + normalizeStartCandleType, + type StartCombineOp, + type StartNodeMeta, +} from './strategyStartNodes'; + +export type EditorConditionState = { + root: LogicNode | null; + startMeta: Record; + extraStartIds: string[]; + extraRoots: Record; + /** START 2개 이상일 때 분기 결합 (기본 AND) */ + startCombineOp?: StartCombineOp; +}; + +export type { StartCombineOp }; + +export type ConditionBranch = { + candleType: string; + root: LogicNode | null; +}; + +export type StartSection = { + startId: string; + candleType: string; + root: LogicNode | null; + canDelete: boolean; +}; + +export function collectStartSections(state: EditorConditionState): StartSection[] { + const sections: StartSection[] = [{ + startId: START_NODE_ID, + candleType: normalizeStartCandleType(state.startMeta[START_NODE_ID]?.candleType), + root: state.root, + canDelete: false, + }]; + for (const startId of state.extraStartIds) { + sections.push({ + startId, + candleType: normalizeStartCandleType(state.startMeta[startId]?.candleType), + root: state.extraRoots[startId] ?? null, + canDelete: true, + }); + } + return sections; +} + +export function hasMultipleStartSections(state: EditorConditionState): boolean { + return state.extraStartIds.length >= 1; +} + +export function normalizeStartCombineOp(value: string | undefined | null): StartCombineOp { + return value === 'OR' ? 'OR' : DEFAULT_START_COMBINE_OP; +} + +export function updateStartCombineOp( + state: EditorConditionState, + op: StartCombineOp, +): EditorConditionState { + return { ...state, startCombineOp: op }; +} + +export function updateBranchRoot( + state: EditorConditionState, + startId: string, + root: LogicNode | null, +): EditorConditionState { + if (startId === START_NODE_ID) { + return { ...state, root }; + } + return { + ...state, + extraRoots: { ...state.extraRoots, [startId]: root }, + }; +} + +export function updateStartCandleType( + state: EditorConditionState, + startId: string, + candleType: string, +): EditorConditionState { + return { + ...state, + startMeta: { + ...state.startMeta, + [startId]: { candleType: normalizeStartCandleType(candleType) }, + }, + }; +} + +export function addExtraStartSection(state: EditorConditionState): EditorConditionState { + const startId = createStartNodeId(); + return { + ...state, + extraStartIds: [...state.extraStartIds, startId], + startMeta: { + ...state.startMeta, + [startId]: { candleType: DEFAULT_START_CANDLE }, + }, + extraRoots: { ...state.extraRoots, [startId]: null }, + }; +} + +export function removeStartSection( + state: EditorConditionState, + startId: string, +): { state: EditorConditionState; orphanedNodes: LogicNode[] } { + if (startId === START_NODE_ID) { + return { state, orphanedNodes: [] }; + } + const branch = state.extraRoots[startId]; + const nextMeta = { ...state.startMeta }; + delete nextMeta[startId]; + const nextRoots = { ...state.extraRoots }; + delete nextRoots[startId]; + return { + state: { + ...state, + startMeta: nextMeta, + extraStartIds: state.extraStartIds.filter(id => id !== startId), + extraRoots: nextRoots, + }, + orphanedNodes: branch ? subtreeToFlatOrphans(branch) : [], + }; +} + +export function emptyEditorConditionState(): EditorConditionState { + return { + root: null, + startMeta: defaultStartMeta(), + extraStartIds: [], + extraRoots: {}, + startCombineOp: DEFAULT_START_COMBINE_OP, + }; +} + +function wrapTimeframe(candleType: string, root: LogicNode): LogicNode { + return { + id: generateNodeId(), + type: 'TIMEFRAME', + candleType: normalizeStartCandleType(candleType), + children: [root], + }; +} + +/** 편집기 상태 → 저장/평가용 DSL (TIMEFRAME 래핑) */ +export function encodeConditionForSave(state: EditorConditionState): LogicNode | null { + const branches = collectEditorBranches(state).filter(b => b.root != null) as Array<{ + candleType: string; + root: LogicNode; + }>; + + if (branches.length === 0) return null; + if (branches.length === 1) { + const { candleType, root } = branches[0]; + if (normalizeStartCandleType(candleType) === DEFAULT_START_CANDLE) return root; + return wrapTimeframe(candleType, root); + } + + const combineOp = normalizeStartCombineOp(state.startCombineOp); + return { + id: generateNodeId(), + type: combineOp, + children: branches.map(b => wrapTimeframe(b.candleType, b.root)), + }; +} + +function parseMultiStartWrapper(dsl: LogicNode): EditorConditionState | null { + if (dsl.type !== 'AND' && dsl.type !== 'OR') return null; + const children = dsl.children ?? []; + const allTimeframe = children.length > 0 && children.every(c => c.type === 'TIMEFRAME'); + if (!allTimeframe) return null; + + const startMeta: Record = {}; + const extraStartIds: string[] = []; + const extraRoots: Record = {}; + let primaryRoot: LogicNode | null = null; + + children.forEach((tf, index) => { + const candleType = normalizeStartCandleType(tf.candleType); + const branchRoot = tf.children?.[0] ?? null; + if (index === 0) { + startMeta[START_NODE_ID] = { candleType }; + primaryRoot = branchRoot; + return; + } + const startId = createStartNodeId(); + startMeta[startId] = { candleType }; + extraStartIds.push(startId); + extraRoots[startId] = branchRoot; + }); + + return { + root: primaryRoot, + startMeta, + extraStartIds, + extraRoots, + startCombineOp: dsl.type as StartCombineOp, + }; +} + +/** DSL → 편집기 상태 (TIMEFRAME / AND|OR+TIMEFRAME 분해) */ +export function decodeConditionForEditor(dsl: LogicNode | null): EditorConditionState { + if (!dsl) return emptyEditorConditionState(); + + const multiStart = parseMultiStartWrapper(dsl); + if (multiStart) return multiStart; + + if (dsl.type === 'TIMEFRAME') { + return { + root: dsl.children?.[0] ?? null, + startMeta: { + [START_NODE_ID]: { candleType: normalizeStartCandleType(dsl.candleType) }, + }, + extraStartIds: [], + extraRoots: {}, + startCombineOp: DEFAULT_START_COMBINE_OP, + }; + } + + return { + root: dsl, + startMeta: defaultStartMeta(), + extraStartIds: [], + extraRoots: {}, + startCombineOp: DEFAULT_START_COMBINE_OP, + }; +} + +/** Logic Expression / 미리보기용 — START별 평가 분봉과 서브트리 */ +export function collectEditorBranches(state: EditorConditionState): ConditionBranch[] { + const branches: ConditionBranch[] = []; + const primaryCt = state.startMeta[START_NODE_ID]?.candleType ?? DEFAULT_START_CANDLE; + if (state.root) branches.push({ candleType: primaryCt, root: state.root }); + + for (const startId of state.extraStartIds) { + const root = state.extraRoots[startId] ?? null; + if (root) { + branches.push({ + candleType: state.startMeta[startId]?.candleType ?? DEFAULT_START_CANDLE, + root, + }); + } + } + return branches; +} + +/** 저장된 DSL에서 Logic Expression용 분기 목록 */ +export function collectDslBranches(dsl: LogicNode | null): ConditionBranch[] { + return collectEditorBranches(decodeConditionForEditor(dsl)); +} diff --git a/frontend/src/utils/strategyEditorLayoutStorage.ts b/frontend/src/utils/strategyEditorLayoutStorage.ts index f520ff3..7b841ac 100644 --- a/frontend/src/utils/strategyEditorLayoutStorage.ts +++ b/frontend/src/utils/strategyEditorLayoutStorage.ts @@ -1,10 +1,19 @@ import type { LogicNode } from './strategyTypes'; import type { EdgeHandleBinding } from './strategyFlowLayout'; +import { defaultStartMeta, type StartCombineOp, type StartNodeMeta } from './strategyStartNodes'; export type SignalFlowLayoutSnapshot = { positions: Record; edgeHandles: Record; orphans?: LogicNode[]; + /** START 노드별 조건 판별 시간봉 */ + startMeta?: Record; + /** 기본 START 외 추가 START 노드 ID (순서 유지) */ + extraStartIds?: string[]; + /** 추가 START에 연결된 서브트리 루트 */ + extraRoots?: Record; + /** START 2개 이상일 때 분기 결합 (AND | OR) */ + startCombineOp?: StartCombineOp; }; export type FlowLayoutChangePayload = Omit & { @@ -19,7 +28,14 @@ export type StrategyFlowLayoutStore = { const STORAGE_KEY = 'gc_se_flow_layout_v1'; export function emptySignalFlowLayout(): SignalFlowLayoutSnapshot { - return { positions: {}, edgeHandles: {}, orphans: [] }; + return { + positions: {}, + edgeHandles: {}, + orphans: [], + startMeta: defaultStartMeta(), + extraStartIds: [], + extraRoots: {}, + }; } export function emptyStrategyFlowLayout(): StrategyFlowLayoutStore { diff --git a/frontend/src/utils/strategyFlowLayout.ts b/frontend/src/utils/strategyFlowLayout.ts index 336334f..421281e 100644 --- a/frontend/src/utils/strategyFlowLayout.ts +++ b/frontend/src/utils/strategyFlowLayout.ts @@ -1,8 +1,16 @@ import type { Connection, Edge, Node } from '@xyflow/react'; import type { LogicNode, ConditionDSL } from './strategyTypes'; import { nodeToText, type DefType } from './strategyEditorShared'; +import { + START_NODE_ID, + isStartNodeId, + normalizeStartCandleType, + STRATEGY_CANDLE_TYPES, + type StartNodeMeta, +} from './strategyStartNodes'; -export const START_NODE_ID = '__strategy_start__'; +export { START_NODE_ID, isStartNodeId, STRATEGY_CANDLE_TYPES }; +export type { StartNodeMeta }; const H_GAP = 280; const V_GAP = 130; @@ -22,6 +30,10 @@ export type StrategyFlowNodeData = { def?: DefType; signalTab?: 'buy' | 'sell'; selected?: boolean; + /** START 노드 — 조건 판별 시간봉 */ + candleType?: string; + onStartCandleTypeChange?: (startId: string, candleType: string) => void; + onDeleteStart?: (startId: string) => void; onDelete?: (id: string) => void; onUpdateCondition?: (nodeId: string, condition: ConditionDSL) => void; onDropTarget?: (targetId: string, data: { type: string; value: string; label: string }, flowPos: { x: number; y: number }) => void; @@ -45,10 +57,15 @@ export function findLogicNode( root: LogicNode | null, orphans: LogicNode[], id: string, + extraRoots: Record = {}, ): LogicNode | null { - if (id === START_NODE_ID) return null; + if (isStartNodeId(id)) return null; const inTree = findNodeInTree(root, id); if (inTree) return inTree; + for (const extraRoot of Object.values(extraRoots)) { + const hit = findNodeInTree(extraRoot, id); + if (hit) return hit; + } return orphans.find(o => o.id === id) ?? null; } @@ -59,7 +76,7 @@ export function isPaletteConnectTarget( orphans: LogicNode[], ): boolean { if (isOrphanNode(orphans, anchorId)) return false; - if (anchorId === START_NODE_ID) return true; + if (isStartNodeId(anchorId)) return true; if (!root) return false; return !!findNodeInTree(root, anchorId); } @@ -69,7 +86,7 @@ export function getNodeConnectionCaps( nodeId: string, logicNode: LogicNode | undefined, ): { canSource: boolean; canTarget: boolean } { - if (nodeId === START_NODE_ID) return { canSource: true, canTarget: false }; + if (nodeId === START_NODE_ID || isStartNodeId(nodeId)) return { canSource: true, canTarget: false }; if (!logicNode) return { canSource: false, canTarget: false }; switch (logicNode.type) { case 'CONDITION': @@ -86,7 +103,7 @@ export function getNodeConnectionCaps( /** 팔레트 드롭 시 부모(출발) 노드로 쓸 수 있는지 */ export function canAcceptPaletteAsParent(anchorId: string, root: LogicNode | null): boolean { - if (anchorId === START_NODE_ID) return true; + if (isStartNodeId(anchorId)) return true; if (!root) return false; const node = findNodeInTree(root, anchorId); if (!node || node.type === 'CONDITION') return false; @@ -102,40 +119,59 @@ export function isValidStrategyConnectionDirected( childId: string, root: LogicNode | null, orphans: LogicNode[] = [], + extraRoots: Record = {}, ): boolean { if (!parentId || !childId || parentId === childId) return false; - if (childId === START_NODE_ID) return false; + if (isStartNodeId(childId)) return false; const childOrphan = orphans.find(o => o.id === childId); const parentOrphan = orphans.find(o => o.id === parentId); if (childOrphan) { - if (parentId === START_NODE_ID) return true; - const parentNode = findNodeInTree(root, parentId); + if (isStartNodeId(parentId)) return true; + const parentNode = findNodeInTree(root, parentId) + ?? Object.values(extraRoots).map(er => findNodeInTree(er, parentId)).find(Boolean) + ?? null; if (!parentNode || !getNodeConnectionCaps(parentId, parentNode).canSource) return false; return true; } if (parentOrphan) { if (!getNodeConnectionCaps(parentId, parentOrphan).canSource) return false; - const childNode = findNodeInTree(root, childId); + const childNode = findNodeInTree(root, childId) + ?? Object.values(extraRoots).map(er => findNodeInTree(er, childId)).find(Boolean) + ?? null; if (!childNode || !getNodeConnectionCaps(childId, childNode).canTarget) return false; if (root && isDescendant(root, childId, parentId)) return false; + for (const er of Object.values(extraRoots)) { + if (er && isDescendant(er, childId, parentId)) return false; + } return true; } - if (!root) return false; + if (!root && Object.values(extraRoots).every(r => !r)) return false; - const parentNode = parentId === START_NODE_ID ? null : findNodeInTree(root, parentId); - const childNode = findNodeInTree(root, childId); - if (parentId !== START_NODE_ID && !parentNode) return false; + const parentNode = isStartNodeId(parentId) + ? null + : findNodeInTree(root, parentId) + ?? Object.values(extraRoots).map(er => findNodeInTree(er, parentId)).find(Boolean) + ?? null; + const childNode = findNodeInTree(root, childId) + ?? Object.values(extraRoots).map(er => findNodeInTree(er, childId)).find(Boolean) + ?? null; + if (!isStartNodeId(parentId) && !parentNode) return false; if (!childNode) return false; const parentCaps = getNodeConnectionCaps(parentId, parentNode ?? undefined); const childCaps = getNodeConnectionCaps(childId, childNode); if (!parentCaps.canSource || !childCaps.canTarget) return false; - if (parentId !== START_NODE_ID && isDescendant(root, childId, parentId)) return false; + if (!isStartNodeId(parentId)) { + if (root && isDescendant(root, childId, parentId)) return false; + for (const er of Object.values(extraRoots)) { + if (er && isDescendant(er, childId, parentId)) return false; + } + } return true; } @@ -144,34 +180,35 @@ export function isValidStrategyConnection( conn: Connection, root: LogicNode | null, orphans: LogicNode[] = [], + extraRoots: Record = {}, ): boolean { const { source, target } = conn; if (!source || !target) return false; return ( - isValidStrategyConnectionDirected(source, target, root, orphans) - || isValidStrategyConnectionDirected(target, source, root, orphans) + isValidStrategyConnectionDirected(source, target, root, orphans, extraRoots) + || isValidStrategyConnectionDirected(target, source, root, orphans, extraRoots) ); } -/** onConnect용 — 부모·자식 ID (드래그 시작 방향 우선, 불가 시 반대 방향) */ export function resolveStrategyConnection( conn: Connection, root: LogicNode | null, orphans: LogicNode[] = [], + extraRoots: Record = {}, ): { parentId: string; childId: string } | null { const { source, target } = conn; if (!source || !target) return null; - if (isValidStrategyConnectionDirected(source, target, root, orphans)) { + if (isValidStrategyConnectionDirected(source, target, root, orphans, extraRoots)) { return { parentId: source, childId: target }; } - if (isValidStrategyConnectionDirected(target, source, root, orphans)) { + if (isValidStrategyConnectionDirected(target, source, root, orphans, extraRoots)) { return { parentId: target, childId: source }; } return null; } export function getNodeDimensions(nodeId: string): { w: number; h: number } { - return nodeId === START_NODE_ID + return isStartNodeId(nodeId) ? { w: FLOW_START_W, h: FLOW_START_H } : { w: FLOW_NODE_W, h: FLOW_NODE_H }; } @@ -252,6 +289,7 @@ export function resolveOrphanDragConnection( nodes: { id: string; position: { x: number; y: number } }[], root: LogicNode | null, orphans: LogicNode[], + extraRoots: Record = {}, ): { parentId: string; childId: string } | null { const dim = getNodeDimensions(orphanId); const center = { @@ -269,10 +307,12 @@ export function resolveOrphanDragConnection( { source: hit.id, target: orphanId, sourceHandle: null, targetHandle: null }, root, orphans, + extraRoots, ) ?? resolveStrategyConnection( { source: orphanId, target: hit.id, sourceHandle: null, targetHandle: null }, root, orphans, + extraRoots, ); } @@ -313,7 +353,7 @@ export type EdgeHandleBinding = { export const FLOW_NODE_W = 200; export const FLOW_NODE_H = 72; -export const FLOW_START_W = 100; +export const FLOW_START_W = 168; export const FLOW_START_H = 56; function nodeCenter( @@ -321,8 +361,8 @@ function nodeCenter( positions: Map, ): { x: number; y: number } { const p = positions.get(id) ?? { x: 0, y: 0 }; - const w = id === START_NODE_ID ? FLOW_START_W : FLOW_NODE_W; - const h = id === START_NODE_ID ? FLOW_START_H : FLOW_NODE_H; + const w = isStartNodeId(id) ? FLOW_START_W : FLOW_NODE_W; + const h = isStartNodeId(id) ? FLOW_START_H : FLOW_NODE_H; return { x: p.x + w / 2, y: p.y + h / 2 }; } @@ -469,6 +509,101 @@ function layoutTree( }); } +const AUTO_LAYOUT_ORIGIN_X = 48; +const AUTO_LAYOUT_ORIGIN_Y = 48; +const AUTO_LAYOUT_SECTION_GAP = 72; +const AUTO_LAYOUT_START_TREE_GAP = 72; +const AUTO_LAYOUT_ORPHAN_GAP = 100; + +function measureSubtreeHeight(node: LogicNode): number { + const children = node.children ?? []; + if (children.length === 0) return FLOW_NODE_H; + const childHeights = children.map(measureSubtreeHeight); + return childHeights.reduce((sum, h) => sum + h, 0) + (children.length - 1) * V_GAP; +} + +function placeSubtree( + node: LogicNode, + x: number, + centerY: number, + positions: Map, +): void { + const children = node.children ?? []; + if (children.length === 0) { + positions.set(node.id, { x, y: centerY - FLOW_NODE_H / 2 }); + return; + } + const childHeights = children.map(measureSubtreeHeight); + const totalHeight = childHeights.reduce((a, b) => a + b, 0) + (children.length - 1) * V_GAP; + let y = centerY - totalHeight / 2; + children.forEach((child, i) => { + const ch = childHeights[i]; + placeSubtree(child, x + H_GAP, y + ch / 2, positions); + y += ch + V_GAP; + }); + positions.set(node.id, { x, y: centerY - FLOW_NODE_H / 2 }); +} + +function maxLayoutX(positions: Map): number { + let max = 0; + for (const [id, pos] of positions) { + const dim = getNodeDimensions(id); + max = Math.max(max, pos.x + dim.w); + } + return max; +} + +/** 그래프 캔버스 자동 정렬 — START별 트리를 좌→우, 형제는 위→아래 배치 */ +export function computeAutoFlowLayout( + root: LogicNode | null, + extraStartIds: string[], + extraRoots: Record, + orphans: LogicNode[], +): Record { + const positions = new Map(); + let yCursor = AUTO_LAYOUT_ORIGIN_Y; + + const placeStartSection = (startId: string, branchRoot: LogicNode | null) => { + const startDim = getNodeDimensions(startId); + const branchHeight = branchRoot ? measureSubtreeHeight(branchRoot) : 0; + const sectionHeight = Math.max(startDim.h, branchHeight); + const centerY = yCursor + sectionHeight / 2; + + positions.set(startId, { + x: AUTO_LAYOUT_ORIGIN_X, + y: centerY - startDim.h / 2, + }); + + if (branchRoot) { + placeSubtree( + branchRoot, + AUTO_LAYOUT_ORIGIN_X + startDim.w + AUTO_LAYOUT_START_TREE_GAP, + centerY, + positions, + ); + } + + yCursor += sectionHeight + AUTO_LAYOUT_SECTION_GAP; + }; + + placeStartSection(START_NODE_ID, root); + for (const startId of extraStartIds) { + placeStartSection(startId, extraRoots[startId] ?? null); + } + + if (orphans.length > 0) { + let orphanY = AUTO_LAYOUT_ORIGIN_Y; + const orphanX = (positions.size > 0 ? maxLayoutX(positions) : AUTO_LAYOUT_ORIGIN_X) + AUTO_LAYOUT_ORPHAN_GAP; + for (const orphan of orphans) { + const h = measureSubtreeHeight(orphan); + positions.set(orphan.id, { x: orphanX, y: orphanY }); + orphanY += h + V_GAP; + } + } + + return Object.fromEntries(positions); +} + function appendOrphanFlowNodes( orphans: LogicNode[], nodes: Node[], @@ -513,33 +648,69 @@ export function logicNodeToFlow( def: DefType, signalTab: 'buy' | 'sell', positions: Map, - callbacks: Pick, + callbacks: Pick< + StrategyFlowNodeData, + | 'onDelete' + | 'onUpdateCondition' + | 'onDropTarget' + | 'onDragOverTarget' + | 'onDragLeaveTarget' + | 'onStartCandleTypeChange' + | 'onDeleteStart' + >, dragOverId: string | null = null, orphans: LogicNode[] = [], + startMeta: Record = { [START_NODE_ID]: { candleType: '1m' } }, + extraStartIds: string[] = [], + extraRoots: Record = {}, ): { nodes: Node[]; edges: Edge[] } { const nodes: Node[] = []; const edges: Edge[] = []; - nodes.push({ - id: START_NODE_ID, - type: 'start', - position: positions.get(START_NODE_ID) ?? { x: 0, y: 220 }, - data: { - label: 'START', - signalTab, - onDropTarget: callbacks.onDropTarget, - onDragOverTarget: callbacks.onDragOverTarget, - onDragLeaveTarget: callbacks.onDragLeaveTarget, - canSourceConnect: true, - canTargetConnect: false, - } satisfies StrategyFlowNodeData, - draggable: true, - selectable: true, - }); + const appendStart = (startId: string, branchRoot: LogicNode | null, defaultY: number) => { + const candleType = normalizeStartCandleType(startMeta[startId]?.candleType); + nodes.push({ + id: startId, + type: 'start', + position: positions.get(startId) ?? { x: 0, y: defaultY }, + data: { + label: 'START', + signalTab, + candleType, + onStartCandleTypeChange: callbacks.onStartCandleTypeChange, + onDeleteStart: startId === START_NODE_ID ? undefined : callbacks.onDeleteStart, + onDropTarget: callbacks.onDropTarget, + onDragOverTarget: callbacks.onDragOverTarget, + onDragLeaveTarget: callbacks.onDragLeaveTarget, + canSourceConnect: true, + canTargetConnect: false, + } satisfies StrategyFlowNodeData, + draggable: true, + selectable: true, + }); - if (root) { - layoutTree(root, START_NODE_ID, 1, 0, 1, nodes, edges, positions, def, signalTab, callbacks, dragOverId); - } + if (branchRoot) { + layoutTree( + branchRoot, + startId, + 1, + 0, + 1, + nodes, + edges, + positions, + def, + signalTab, + callbacks, + dragOverId, + ); + } + }; + + appendStart(START_NODE_ID, root, 220); + extraStartIds.forEach((startId, index) => { + appendStart(startId, extraRoots[startId] ?? null, 80 + index * 140); + }); appendOrphanFlowNodes(orphans, nodes, positions, def, signalTab, callbacks); @@ -663,20 +834,34 @@ function mergeOrphans(existing: LogicNode[], added: LogicNode[]): LogicNode[] { function resolveDisconnectEndpoints( sourceId: string, targetId: string, - root: LogicNode, + root: LogicNode | null, orphans: LogicNode[], + extraRoots: Record, ): { parentId: string; childId: string } | null { const conn = { source: sourceId, target: targetId, sourceHandle: null, targetHandle: null }; - const resolved = resolveStrategyConnection(conn, root, orphans); + const resolved = resolveStrategyConnection(conn, root, orphans, extraRoots); if (resolved) return resolved; - if (sourceId === START_NODE_ID && root.id === targetId) { - return { parentId: START_NODE_ID, childId: targetId }; + if (isStartNodeId(sourceId)) { + const branchRoot = sourceId === START_NODE_ID ? root : extraRoots[sourceId] ?? null; + if (branchRoot?.id === targetId) { + return { parentId: sourceId, childId: targetId }; + } } - const parentInTree = findParentIdInTree(root, targetId); - if (parentInTree === sourceId) { - return { parentId: sourceId, childId: targetId }; + if (root) { + const parentInTree = findParentIdInTree(root, targetId); + if (parentInTree === sourceId) { + return { parentId: sourceId, childId: targetId }; + } + } + + for (const [startId, branchRoot] of Object.entries(extraRoots)) { + if (!branchRoot) continue; + const parentInTree = findParentIdInTree(branchRoot, targetId, startId); + if (parentInTree === sourceId) { + return { parentId: sourceId, childId: targetId }; + } } return null; @@ -688,36 +873,67 @@ export function isDescendant(root: LogicNode, ancestorId: string, nodeId: string return findNodeInTree(ancestor, nodeId) != null && ancestorId !== nodeId; } -/** 연결선 제거 — 자식 서브트리를 트리에서 분리·미연결(고아)로 → Logic Expression 제외 */ export function disconnectTreeEdge( sourceId: string, targetId: string, root: LogicNode | null, orphans: LogicNode[], -): { root: LogicNode | null; orphans: LogicNode[] } { - if (!root) return { root, orphans }; - - const endpoints = resolveDisconnectEndpoints(sourceId, targetId, root, orphans); - if (!endpoints) return { root, orphans }; + extraRoots: Record = {}, +): { + root: LogicNode | null; + orphans: LogicNode[]; + extraRoots: Record; +} { + const endpoints = resolveDisconnectEndpoints(sourceId, targetId, root, orphans, extraRoots); + if (!endpoints) return { root, orphans, extraRoots }; const { parentId, childId } = endpoints; - if (childId === START_NODE_ID) return { root, orphans }; + if (isStartNodeId(childId)) return { root, orphans, extraRoots }; - const childInTree = root.id === childId || !!findNodeInTree(root, childId); - if (!childInTree) return { root, orphans }; + const inPrimary = root && (root.id === childId || !!findNodeInTree(root, childId)); + const inExtraStartId = Object.entries(extraRoots).find(([, branch]) => + branch && (branch.id === childId || !!findNodeInTree(branch, childId)), + )?.[0]; - if (parentId === START_NODE_ID && root.id === childId) { + if (!inPrimary && !inExtraStartId) return { root, orphans, extraRoots }; + + if (isStartNodeId(parentId)) { + if (parentId === START_NODE_ID && root?.id === childId) { + return { + root: null, + orphans: mergeOrphans(orphans, subtreeToFlatOrphans(root)), + extraRoots, + }; + } + const branchRoot = extraRoots[parentId] ?? null; + if (branchRoot?.id === childId) { + const nextExtra = { ...extraRoots, [parentId]: null }; + return { + root, + orphans: mergeOrphans(orphans, subtreeToFlatOrphans(branchRoot)), + extraRoots: nextExtra, + }; + } + } + + const extractFrom = inPrimary ? root! : extraRoots[inExtraStartId!]!; + const { tree, node } = extractNode(extractFrom, childId); + if (!node) return { root, orphans, extraRoots }; + + if (inPrimary) { return { - root: null, - orphans: mergeOrphans(orphans, subtreeToFlatOrphans(root)), + root: pruneEmptyGates(tree), + orphans: mergeOrphans(orphans, subtreeToFlatOrphans(node)), + extraRoots, }; } - const { tree, node } = extractNode(root, childId); - if (!node) return { root, orphans }; - return { - root: pruneEmptyGates(tree), + root, orphans: mergeOrphans(orphans, subtreeToFlatOrphans(node)), + extraRoots: { + ...extraRoots, + [inExtraStartId!]: pruneEmptyGates(tree), + }, }; } diff --git a/frontend/src/utils/strategyStartNodes.ts b/frontend/src/utils/strategyStartNodes.ts new file mode 100644 index 0000000..862776c --- /dev/null +++ b/frontend/src/utils/strategyStartNodes.ts @@ -0,0 +1,41 @@ +import { generateNodeId } from './strategyTypes'; + +export const START_NODE_ID = '__strategy_start__'; +export const DEFAULT_START_CANDLE = '1m'; + +export const STRATEGY_CANDLE_TYPES = [ + '1m', '3m', '5m', '15m', '30m', '1h', '4h', '1d', +] as const; + +export type StrategyCandleType = (typeof STRATEGY_CANDLE_TYPES)[number]; + +export type StartNodeMeta = { + candleType: string; +}; + +/** START가 2개 이상일 때 분기 간 결합 연산 */ +export type StartCombineOp = 'AND' | 'OR'; + +export const DEFAULT_START_COMBINE_OP: StartCombineOp = 'AND'; + +export function isStartNodeId(id: string): boolean { + return id === START_NODE_ID || id.startsWith('__strategy_start_'); +} + +export function createStartNodeId(): string { + return `__strategy_start_${generateNodeId()}__`; +} + +export function defaultStartMeta(): Record { + return { [START_NODE_ID]: { candleType: DEFAULT_START_CANDLE } }; +} + +export function normalizeStartCandleType(value: string | undefined | null): string { + const raw = (value ?? DEFAULT_START_CANDLE).trim().toLowerCase(); + if (raw === '1d') return '1d'; + return (STRATEGY_CANDLE_TYPES as readonly string[]).includes(raw) ? raw : DEFAULT_START_CANDLE; +} + +export function formatStartCandleLabel(candleType: string): string { + return normalizeStartCandleType(candleType); +} diff --git a/frontend/src/utils/strategyTypes.ts b/frontend/src/utils/strategyTypes.ts index ef42cfd..19229dc 100644 --- a/frontend/src/utils/strategyTypes.ts +++ b/frontend/src/utils/strategyTypes.ts @@ -1,6 +1,6 @@ // ── DSL 트리 타입 ───────────────────────────────────────────────────────────── -export type LogicNodeType = 'AND' | 'OR' | 'NOT' | 'CONDITION'; +export type LogicNodeType = 'AND' | 'OR' | 'NOT' | 'CONDITION' | 'TIMEFRAME'; export interface ConditionDSL { indicatorType: string; @@ -25,6 +25,8 @@ export interface LogicNode { children?: LogicNode[]; condition?: ConditionDSL; description?: string; + /** TIMEFRAME 노드 — 조건 판별 대상 캔들 타입 (1m, 5m, 1h …) */ + candleType?: string; } export interface StrategyDSLDto {