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

This commit is contained in:
Macbook
2026-05-25 02:49:39 +09:00
parent cc01f311bc
commit 30dedc4abc
17 changed files with 1903 additions and 249 deletions
+282 -66
View File
@@ -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<string, LogicNode | null> = {},
): 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<string, LogicNode | null> = {},
): 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<string, LogicNode | null> = {},
): 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<string, LogicNode | null> = {},
): { 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<string, LogicNode | null> = {},
): { 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<string, { x: number; y: number }>,
): { 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<string, { x: number; y: number }>,
): 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<string, { x: number; y: number }>): 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<string, LogicNode | null>,
orphans: LogicNode[],
): Record<string, { x: number; y: number }> {
const positions = new Map<string, { x: number; y: number }>();
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<string, { x: number; y: number }>,
callbacks: Pick<StrategyFlowNodeData, 'onDelete' | 'onUpdateCondition' | 'onDropTarget' | 'onDragOverTarget' | 'onDragLeaveTarget'>,
callbacks: Pick<
StrategyFlowNodeData,
| 'onDelete'
| 'onUpdateCondition'
| 'onDropTarget'
| 'onDragOverTarget'
| 'onDragLeaveTarget'
| 'onStartCandleTypeChange'
| 'onDeleteStart'
>,
dragOverId: string | null = null,
orphans: LogicNode[] = [],
startMeta: Record<string, StartNodeMeta> = { [START_NODE_ID]: { candleType: '1m' } },
extraStartIds: string[] = [],
extraRoots: Record<string, LogicNode | null> = {},
): { 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<string, LogicNode | null>,
): { 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<string, LogicNode | null> = {},
): {
root: LogicNode | null;
orphans: LogicNode[];
extraRoots: Record<string, LogicNode | null>;
} {
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),
},
};
}