diff --git a/frontend/src/components/StrategyEditorPage.tsx b/frontend/src/components/StrategyEditorPage.tsx
index 3ef5aca..8b05494 100644
--- a/frontend/src/components/StrategyEditorPage.tsx
+++ b/frontend/src/components/StrategyEditorPage.tsx
@@ -45,6 +45,8 @@ import {
isStartNodeId,
type StartCombineOp,
} from '../utils/strategyStartNodes';
+import SavedStrategyDragBadge from './strategyEditor/SavedStrategyDragBadge';
+import type { PaletteDragPayload } from '../utils/paletteDragSession';
import IndicatorPaletteTab from './strategyEditor/IndicatorPaletteTab';
import TemplatePaletteTab, { type TemplatePaletteRow } from './strategyEditor/TemplatePaletteTab';
import AiStrategyPanel, { type AiStrategyContext } from './strategyEditor/AiStrategyPanel';
@@ -1448,6 +1450,21 @@ export default function StrategyEditorPage({
});
}, [signalTab, DEF, handleEditorStateChange]);
+ const buildSavedStrategyDragPayload = useCallback((s: StrategyDto): PaletteDragPayload | null => {
+ const side = buySellTab(signalTab);
+ const primaryRaw = side === 'sell' ? s.sellCondition : s.buyCondition;
+ const fallbackRaw = side === 'sell' ? s.buyCondition : s.sellCondition;
+ const primary = decodeConditionForEditor((primaryRaw ?? null) as LogicNode | null).root;
+ const condition = primary ?? decodeConditionForEditor((fallbackRaw ?? null) as LogicNode | null).root;
+ if (!condition) return null;
+ return {
+ type: 'savedStrategy',
+ value: String(s.id),
+ label: s.name ?? `전략 #${s.id}`,
+ condition,
+ };
+ }, [signalTab]);
+
const templateRows = useMemo(
() => buildStrategyTemplatePaletteRows({
def: DEF,
@@ -2089,9 +2106,10 @@ export default function StrategyEditorPage({
/>
-
- {s.enabled ? '활성' : '비활성'}
-
+ buildSavedStrategyDragPayload(s)}
+ />
);
diff --git a/frontend/src/components/strategyEditor/SavedStrategyDragBadge.tsx b/frontend/src/components/strategyEditor/SavedStrategyDragBadge.tsx
new file mode 100644
index 0000000..cf8dba1
--- /dev/null
+++ b/frontend/src/components/strategyEditor/SavedStrategyDragBadge.tsx
@@ -0,0 +1,81 @@
+import React from 'react';
+import {
+ handlePaletteMouseDown,
+ handlePalettePointerDown,
+ handlePaletteTouchStart,
+ endHtmlDragMouseTracking,
+ needsPointerPaletteDrag,
+ startHtmlDragMouseTracking,
+ writePaletteHtmlDragData,
+ type PaletteDragPayload,
+} from '../../utils/paletteDragSession';
+
+interface Props {
+ enabled: boolean;
+ /** 드래그 시작 시점에 현재 탭(매수/매도)에 맞는 조건 payload 생성. 없으면 드래그 불가 */
+ buildPayload: () => PaletteDragPayload | null;
+}
+
+/**
+ * 좌측 전략 목록 아이템의 활성 배지 — 드래그하여 전략빌더(캔버스)에 조건 추가.
+ * 팔레트 칩과 동일한 DnD 세션(HTML5 + pointer)을 사용한다.
+ */
+export default function SavedStrategyDragBadge({ enabled, buildPayload }: Props) {
+ const pointerMode = needsPointerPaletteDrag();
+
+ const onDragStart = (e: React.DragEvent) => {
+ if (pointerMode) {
+ e.preventDefault();
+ return;
+ }
+ const payload = buildPayload();
+ if (!payload) {
+ e.preventDefault();
+ return;
+ }
+ e.stopPropagation();
+ writePaletteHtmlDragData(e, payload);
+ startHtmlDragMouseTracking(e.clientX, e.clientY, payload);
+ };
+
+ const onDragEnd = () => {
+ endHtmlDragMouseTracking();
+ };
+
+ const onPointerDown = (e: React.PointerEvent) => {
+ if (!pointerMode) return;
+ const payload = buildPayload();
+ if (!payload) return;
+ handlePalettePointerDown(e, payload, e.currentTarget as HTMLElement);
+ };
+
+ const onMouseDown = (e: React.MouseEvent) => {
+ if (!pointerMode) return;
+ const payload = buildPayload();
+ if (!payload) return;
+ handlePaletteMouseDown(e, payload, e.currentTarget as HTMLElement);
+ };
+
+ const onTouchStart = (e: React.TouchEvent) => {
+ if (!pointerMode) return;
+ const payload = buildPayload();
+ if (!payload) return;
+ handlePaletteTouchStart(e, payload, e.currentTarget as HTMLElement);
+ };
+
+ return (
+ e.stopPropagation()}
+ title="드래그하여 전략빌더에 이 전략의 조건을 추가/연결"
+ >
+ {enabled ? '활성' : '비활성'}
+
+ );
+}
diff --git a/frontend/src/components/strategyEditor/StrategyEditorCanvas.tsx b/frontend/src/components/strategyEditor/StrategyEditorCanvas.tsx
index b435db1..d34d648 100644
--- a/frontend/src/components/strategyEditor/StrategyEditorCanvas.tsx
+++ b/frontend/src/components/strategyEditor/StrategyEditorCanvas.tsx
@@ -20,6 +20,7 @@ import {
import '@xyflow/react/dist/style.css';
import type { Theme } from '../../types/index';
import type { LogicNode, ConditionDSL } from '../../utils/strategyTypes';
+import { cloneLogicNodeWithNewIds } from '../../utils/strategyTypes';
import { normalizeConditionForPersistence } from '../../utils/strategyConditionNormalize';
import {
addChild,
@@ -609,7 +610,7 @@ function StrategyEditorCanvasInner({
}, [setNodes]);
const resolvePaletteDropNode = useCallback((
- data: { type: string; value: string; label: string; composite?: boolean; lookback?: number; indicatorType?: string; leftField?: string },
+ data: { type: string; value: string; label: string; composite?: boolean; lookback?: number; indicatorType?: string; leftField?: string; condition?: LogicNode },
): LogicNode | null => {
if (data.type === 'sidewaysFilter') {
return buildSidewaysFilterNode(data.value, def);
@@ -617,6 +618,9 @@ function StrategyEditorCanvasInner({
if (data.type === 'trendLine') {
return buildTrendLineConditionNodeFromDrag(data, signalTab, def);
}
+ if (data.type === 'savedStrategy') {
+ return data.condition ? cloneLogicNodeWithNewIds(data.condition) : null;
+ }
return makeNode(data.type, data.value, signalTab, def, makeNodeOptionsFromPalette(data));
}, [signalTab, def]);
@@ -634,6 +638,20 @@ function StrategyEditorCanvasInner({
scheduleLayoutEmit();
}, [orphans, onOrphansChange, resolvePaletteDropNode, scheduleLayoutEmit]);
+ /**
+ * 저장 전략(서브트리)은 단일 고아 노드로 추가하면 자식이 캔버스에 표시되지 않으므로
+ * 항상 루트에 AND 병합(빈 경우 루트로 설정)한다.
+ */
+ const addSavedStrategyAtRoot = useCallback((
+ data: { type: string; value: string; label: string; condition?: LogicNode },
+ ): boolean => {
+ const newNode = resolvePaletteDropNode(data);
+ if (!newNode) return false;
+ onChange(root ? mergeAtRoot(root, newNode, false) : newNode);
+ scheduleLayoutEmit();
+ return true;
+ }, [resolvePaletteDropNode, onChange, root, scheduleLayoutEmit]);
+
const applyDropWithAttach = useCallback((
anchorId: string,
data: { type: string; value: string; label: string; composite?: boolean },
@@ -717,17 +735,21 @@ function StrategyEditorCanvasInner({
const handleDropTarget = useCallback((
anchorId: string,
- data: { type: string; value: string; label: string },
+ data: { type: string; value: string; label: string; condition?: LogicNode },
flowPos: { x: number; y: number },
) => {
clearDropPreview();
+ const fallback = () => {
+ if (data.type === 'savedStrategy') addSavedStrategyAtRoot(data);
+ else addOrphanAt(data, flowPos);
+ };
if (isPaletteConnectTarget(anchorId, root, orphans)) {
const attached = applyDropWithAttach(anchorId, data, flowPos);
- if (!attached) addOrphanAt(data, flowPos);
+ if (!attached) fallback();
} else {
- addOrphanAt(data, flowPos);
+ fallback();
}
- }, [clearDropPreview, applyDropWithAttach, addOrphanAt, root, orphans]);
+ }, [clearDropPreview, applyDropWithAttach, addOrphanAt, addSavedStrategyAtRoot, root, orphans]);
const handleUpdateCondition = useCallback((id: string, condition: ConditionDSL) => {
const next = normalizeConditionForPersistence(condition);
@@ -1295,7 +1317,7 @@ function StrategyEditorCanvasInner({
);
const applyPaletteDropAt = useCallback((
- data: { type: string; value: string; label: string; composite?: boolean },
+ data: { type: string; value: string; label: string; composite?: boolean; condition?: LogicNode },
clientX: number,
clientY: number,
) => {
@@ -1312,12 +1334,16 @@ function StrategyEditorCanvasInner({
return;
}
- addOrphanAt(data, flowPos);
+ if (data.type === 'savedStrategy') {
+ addSavedStrategyAtRoot(data);
+ } else {
+ addOrphanAt(data, flowPos);
+ }
requestAnimationFrame(() => {
reactFlowRef.current?.fitView({ padding: 0.35, duration: 180 });
});
- }, [resolveDropFromClient, addStartAt, handleDropTarget, addOrphanAt]);
+ }, [resolveDropFromClient, addStartAt, handleDropTarget, addOrphanAt, addSavedStrategyAtRoot]);
const onPaneDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
diff --git a/frontend/src/components/strategyEditor/StrategyListEditor.tsx b/frontend/src/components/strategyEditor/StrategyListEditor.tsx
index b3538df..4a85bf9 100644
--- a/frontend/src/components/strategyEditor/StrategyListEditor.tsx
+++ b/frontend/src/components/strategyEditor/StrategyListEditor.tsx
@@ -1,5 +1,6 @@
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import type { LogicNode } from '../../utils/strategyTypes';
+import { cloneLogicNodeWithNewIds } from '../../utils/strategyTypes';
import {
CondEditor,
addChild,
@@ -404,7 +405,7 @@ export default function StrategyListEditor({
}, [onEditorStateChange, onOrphansChange, orphans]);
const resolvePaletteNode = useCallback((
- data: { type: string; value: string; label: string; composite?: boolean; lookback?: number; indicatorType?: string; leftField?: string },
+ data: { type: string; value: string; label: string; composite?: boolean; lookback?: number; indicatorType?: string; leftField?: string; condition?: LogicNode },
) => {
if (data.type === 'sidewaysFilter') {
return buildSidewaysFilterNode(data.value, def);
@@ -412,6 +413,9 @@ export default function StrategyListEditor({
if (data.type === 'trendLine') {
return buildTrendLineConditionNodeFromDrag(data, signalTab, def);
}
+ if (data.type === 'savedStrategy') {
+ return data.condition ? cloneLogicNodeWithNewIds(data.condition) : null;
+ }
return makeNode(data.type, data.value, signalTab, def, makeNodeOptionsFromPalette(data));
}, [signalTab, def]);
diff --git a/frontend/src/styles/strategyEditor.css b/frontend/src/styles/strategyEditor.css
index 5f063aa..75f8738 100644
--- a/frontend/src/styles/strategyEditor.css
+++ b/frontend/src/styles/strategyEditor.css
@@ -713,6 +713,19 @@
background: color-mix(in srgb, var(--se-gold) 15%, transparent);
border-color: color-mix(in srgb, var(--se-gold) 35%, transparent);
}
+.se-strat-status--drag {
+ cursor: grab;
+ touch-action: none;
+ user-select: none;
+ transition: transform 0.12s ease, box-shadow 0.12s ease;
+}
+.se-strat-status--drag:hover {
+ transform: translateY(-1px);
+ box-shadow: 0 2px 6px rgba(0, 0, 0, 0.18);
+}
+.se-strat-status--drag:active {
+ cursor: grabbing;
+}
.se-empty {
font-size: 0.78rem;
diff --git a/frontend/src/utils/strategyTypes.ts b/frontend/src/utils/strategyTypes.ts
index 442a39d..93faff2 100644
--- a/frontend/src/utils/strategyTypes.ts
+++ b/frontend/src/utils/strategyTypes.ts
@@ -358,6 +358,21 @@ export function generateNodeId(): string {
return `node_${++_nodeCounter}_${Date.now()}`;
}
+/** 서브트리 깊은 복제 + 모든 노드 id 재생성 (저장 전략 드롭 시 id 충돌 방지) */
+export function cloneLogicNodeWithNewIds(node: LogicNode): LogicNode {
+ return {
+ ...node,
+ id: generateNodeId(),
+ condition: node.condition
+ ? {
+ ...node.condition,
+ params: node.condition.params ? { ...node.condition.params } : node.condition.params,
+ }
+ : node.condition,
+ children: node.children ? node.children.map(cloneLogicNodeWithNewIds) : undefined,
+ };
+}
+
/** 자연어 설명 변환 */
export function nodeToNaturalLanguage(node: LogicNode): string {
if (!node) return '';