다중 시간봉 전략 설정기능 추가
This commit is contained in:
@@ -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<string, StartNodeMeta>;
|
||||
extraStartIds: string[];
|
||||
extraRoots: Record<string, LogicNode | null>;
|
||||
/** 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<string, StartNodeMeta> = {};
|
||||
const extraStartIds: string[] = [];
|
||||
const extraRoots: Record<string, LogicNode | null> = {};
|
||||
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));
|
||||
}
|
||||
@@ -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<string, { x: number; y: number }>;
|
||||
edgeHandles: Record<string, EdgeHandleBinding>;
|
||||
orphans?: LogicNode[];
|
||||
/** START 노드별 조건 판별 시간봉 */
|
||||
startMeta?: Record<string, StartNodeMeta>;
|
||||
/** 기본 START 외 추가 START 노드 ID (순서 유지) */
|
||||
extraStartIds?: string[];
|
||||
/** 추가 START에 연결된 서브트리 루트 */
|
||||
extraRoots?: Record<string, LogicNode | null>;
|
||||
/** START 2개 이상일 때 분기 결합 (AND | OR) */
|
||||
startCombineOp?: StartCombineOp;
|
||||
};
|
||||
|
||||
export type FlowLayoutChangePayload = Omit<SignalFlowLayoutSnapshot, 'orphans'> & {
|
||||
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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<string, StartNodeMeta> {
|
||||
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);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user