diff --git a/frontend/src/components/strategyEditor/StartCombineEdge.tsx b/frontend/src/components/strategyEditor/StartCombineEdge.tsx
new file mode 100644
index 0000000..d0cbe77
--- /dev/null
+++ b/frontend/src/components/strategyEditor/StartCombineEdge.tsx
@@ -0,0 +1,70 @@
+import React from 'react';
+import {
+ BaseEdge,
+ EdgeLabelRenderer,
+ getSmoothStepPath,
+ type EdgeProps,
+} from '@xyflow/react';
+import type { StartCombineOp } from '../../utils/strategyStartNodes';
+
+export type StartCombineEdgeData = {
+ op?: StartCombineOp;
+ onToggle?: (op: StartCombineOp) => void;
+};
+
+/**
+ * START 노드끼리 잇는 결합 엣지 — 가운데 AND/OR 토글 배지.
+ * 클릭하면 startCombineOp 를 전환한다. (연결 자체는 START 2개 이상이면 자동 생성)
+ */
+export function StartCombineEdge({
+ id,
+ sourceX,
+ sourceY,
+ targetX,
+ targetY,
+ sourcePosition,
+ targetPosition,
+ data,
+}: EdgeProps) {
+ const op: StartCombineOp = (data as StartCombineEdgeData | undefined)?.op === 'OR' ? 'OR' : 'AND';
+ const onToggle = (data as StartCombineEdgeData | undefined)?.onToggle;
+
+ const [edgePath, labelX, labelY] = getSmoothStepPath({
+ sourceX,
+ sourceY,
+ targetX,
+ targetY,
+ sourcePosition,
+ targetPosition,
+ borderRadius: 10,
+ });
+
+ return (
+ <>
+
+
+
+
+ >
+ );
+}
diff --git a/frontend/src/components/strategyEditor/StrategyEditorCanvas.tsx b/frontend/src/components/strategyEditor/StrategyEditorCanvas.tsx
index 2018fa4..09aa40f 100644
--- a/frontend/src/components/strategyEditor/StrategyEditorCanvas.tsx
+++ b/frontend/src/components/strategyEditor/StrategyEditorCanvas.tsx
@@ -98,9 +98,11 @@ import {
DEFAULT_START_CANDLE,
normalizeStartCandleType,
type StartNodeMeta,
+ type StartCombineOp,
} from '../../utils/strategyStartNodes';
import { ConditionFlowNode, LogicGateNode, StartNode } from './FlowNodes';
import { StrategyFlowEdge } from './StrategyFlowEdge';
+import { StartCombineEdge } from './StartCombineEdge';
import { edgeDisconnectRef, edgeBindingUpdateRef, layoutFlushRef } from './strategyEditorCallbacks';
import { MultiSelectionDeleteButton } from './MultiSelectionDeleteButton';
import {
@@ -124,6 +126,7 @@ const nodeTypes = {
const edgeTypes = {
strategy: StrategyFlowEdge,
+ startCombine: StartCombineEdge,
};
interface Props {
@@ -146,6 +149,9 @@ interface Props {
onStartCandleTypesChange?: (startId: string, candleTypes: string[]) => void;
onExtraStartIdsChange?: (ids: string[]) => void;
onExtraRootsChange?: (roots: Record) => void;
+ /** START 2개 이상일 때 분기 결합 (AND | OR) */
+ startCombineOp?: StartCombineOp;
+ onStartCombineOpChange?: (op: StartCombineOp) => void;
}
export interface StrategyEditorCanvasHandle {
@@ -314,6 +320,8 @@ function StrategyEditorCanvasInner({
onStartCandleTypesChange,
onExtraStartIdsChange,
onExtraRootsChange,
+ startCombineOp = 'AND',
+ onStartCombineOpChange,
}: Props, ref: React.ForwardedRef) {
const { fitView } = useReactFlow();
const canvasWrapRef = useRef(null);
@@ -949,7 +957,9 @@ function StrategyEditorCanvasInner({
startMeta,
extraStartIds,
extraRoots,
- ), [root, def, signalTab, flowCallbacks, orphans, startMeta, extraStartIds, extraRoots]);
+ startCombineOp,
+ onStartCombineOpChange,
+ ), [root, def, signalTab, flowCallbacks, orphans, startMeta, extraStartIds, extraRoots, startCombineOp, onStartCombineOpChange]);
// 트리 구조 변경 → 전체 재배치 / 조건 편집 → 라벨만 갱신(위치 유지)
useEffect(() => {
diff --git a/frontend/src/styles/strategyEditor.css b/frontend/src/styles/strategyEditor.css
index 75f8738..7bb4474 100644
--- a/frontend/src/styles/strategyEditor.css
+++ b/frontend/src/styles/strategyEditor.css
@@ -1128,6 +1128,56 @@
scale: 1.1;
}
+/* START 결합 엣지 — START끼리 자동 연결 (AND/OR) */
+.se-flow-start-combine-edge {
+ stroke: var(--se-edge);
+ opacity: 0.85;
+}
+.se-flow-start-combine-edge--and {
+ stroke: color-mix(in srgb, var(--se-accent) 80%, transparent);
+}
+.se-flow-start-combine-edge--or {
+ stroke: #00d4ff;
+}
+.se-start-combine-edge-badge {
+ z-index: 11;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ min-width: 38px;
+ height: 22px;
+ padding: 0 9px;
+ border-radius: 999px;
+ border: 1px solid color-mix(in srgb, var(--se-accent) 55%, transparent);
+ background: rgba(15, 23, 42, 0.96);
+ color: var(--se-accent);
+ font-size: 0.66rem;
+ font-weight: 800;
+ letter-spacing: 0.04em;
+ cursor: pointer;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.45);
+ transition: background 0.15s, transform 0.12s, border-color 0.15s;
+}
+.se-start-combine-edge-badge:hover {
+ transform: scale(1.08);
+}
+.se-start-combine-edge-badge--or {
+ border-color: rgba(0, 212, 255, 0.6);
+ color: #00d4ff;
+}
+
+/* START 결합 전용 target 핸들 — 비대화형, 시각적으로 최소화 */
+.se-flow-handle--start-combine-tgt {
+ width: 1px;
+ height: 1px;
+ min-width: 1px;
+ min-height: 1px;
+ background: transparent;
+ border: none;
+ opacity: 0;
+ pointer-events: none;
+}
+
.se-flow-edge-hit {
pointer-events: stroke;
}
diff --git a/frontend/src/utils/strategyFlowLayout.ts b/frontend/src/utils/strategyFlowLayout.ts
index eec7921..80d6542 100644
--- a/frontend/src/utils/strategyFlowLayout.ts
+++ b/frontend/src/utils/strategyFlowLayout.ts
@@ -10,11 +10,15 @@ import {
normalizeStartCandleType,
STRATEGY_CANDLE_TYPES,
type StartNodeMeta,
+ type StartCombineOp,
} from './strategyStartNodes';
export { START_NODE_ID, isStartNodeId, STRATEGY_CANDLE_TYPES };
export type { StartNodeMeta };
+/** START 결합 엣지 식별 prefix */
+export const START_COMBINE_EDGE_PREFIX = 'start-combine:';
+
const H_GAP = 280;
const V_GAP = 130;
@@ -436,6 +440,8 @@ export function applyEdgeHandles(
saved: Map,
): Edge[] {
return edges.map(edge => {
+ // START 결합 엣지는 고정 핸들(s-bottom→t-top) 유지 — 핸들 재계산/바인딩 제외
+ if (edge.type === 'startCombine') return edge;
const binding = saved.get(edge.id) ?? {};
const picked = pickHandleSides(edge.source, edge.target, positions);
let sourceHandle = picked.sourceHandle;
@@ -716,6 +722,8 @@ export function logicNodeToFlow(
startMeta: Record = defaultStartMeta(),
extraStartIds: string[] = [],
extraRoots: Record = {},
+ startCombineOp: StartCombineOp = 'AND',
+ onStartCombineOpChange?: (op: StartCombineOp) => void,
): { nodes: Node[]; edges: Edge[] } {
const nodes: Node[] = [];
const edges: Edge[] = [];
@@ -766,6 +774,28 @@ export function logicNodeToFlow(
appendStart(startId, extraRoots[startId] ?? null, 80 + index * 140);
});
+ // START 2개 이상이면 START끼리 자동 결합 엣지(체인)로 연결 — AND/OR 토글 배지
+ if (extraStartIds.length >= 1) {
+ const chain = [START_NODE_ID, ...extraStartIds];
+ for (let i = 1; i < chain.length; i += 1) {
+ const sourceId = chain[i - 1];
+ const targetId = chain[i];
+ edges.push({
+ id: `${START_COMBINE_EDGE_PREFIX}${sourceId}->${targetId}`,
+ source: sourceId,
+ sourceHandle: 's-bottom',
+ target: targetId,
+ targetHandle: 't-top',
+ type: 'startCombine',
+ selectable: false,
+ deletable: false,
+ reconnectable: false,
+ focusable: false,
+ data: { op: startCombineOp, onToggle: onStartCombineOpChange },
+ });
+ }
+ }
+
appendOrphanFlowNodes(orphans, nodes, positions, def, signalTab, callbacks);
return { nodes, edges };