490 lines
15 KiB
TypeScript
490 lines
15 KiB
TypeScript
/**
|
|
* 전략 편집기 DSL 직렬화 — DB 평가 원천은 buy/sell condition JSON 만.
|
|
* flow_layout 은 UI(좌표·START 메타); Logic Expression 은 저장하지 않음.
|
|
*/
|
|
import { normalizeLogicRootForPersistence } from './strategyConditionNormalize';
|
|
import type { LogicNode, ConditionDSL } from './strategyTypes';
|
|
import { generateNodeId } from './strategyTypes';
|
|
import { subtreeToFlatOrphans } from './strategyFlowLayout';
|
|
import {
|
|
DEFAULT_START_CANDLE,
|
|
DEFAULT_START_COMBINE_OP,
|
|
START_NODE_ID,
|
|
STRATEGY_CANDLE_TYPES,
|
|
createStartNodeId,
|
|
defaultStartMeta,
|
|
getStartCandleTypes,
|
|
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;
|
|
candleTypes?: string[];
|
|
root: LogicNode | null;
|
|
};
|
|
|
|
export type StartSection = {
|
|
startId: string;
|
|
candleType: string;
|
|
candleTypes: string[];
|
|
root: LogicNode | null;
|
|
canDelete: boolean;
|
|
};
|
|
|
|
function metaToStartSection(
|
|
startId: string,
|
|
meta: Record<string, StartNodeMeta>,
|
|
root: LogicNode | null,
|
|
canDelete: boolean,
|
|
): StartSection {
|
|
const candleTypes = getStartCandleTypes(meta[startId]);
|
|
return {
|
|
startId,
|
|
candleType: candleTypes[0],
|
|
candleTypes,
|
|
root,
|
|
canDelete,
|
|
};
|
|
}
|
|
|
|
export function collectStartSections(state: EditorConditionState): StartSection[] {
|
|
const sections: StartSection[] = [
|
|
metaToStartSection(START_NODE_ID, state.startMeta, state.root, false),
|
|
];
|
|
for (const startId of state.extraStartIds) {
|
|
sections.push(
|
|
metaToStartSection(startId, state.startMeta, state.extraRoots[startId] ?? null, 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 },
|
|
};
|
|
}
|
|
|
|
/** START 분봉 변경 시 조건 노드의 left/rightCandleType 동기화 */
|
|
function syncSubtreeCandleTypes(node: LogicNode | null, candleType: string): LogicNode | null {
|
|
if (!node) return null;
|
|
const ct = normalizeStartCandleType(candleType);
|
|
|
|
if (node.type === 'CONDITION' && node.condition) {
|
|
const cond = node.condition as ConditionDSL;
|
|
return {
|
|
...node,
|
|
condition: {
|
|
...cond,
|
|
leftCandleType: ct,
|
|
rightCandleType: ct,
|
|
},
|
|
};
|
|
}
|
|
|
|
if (node.children?.length) {
|
|
return {
|
|
...node,
|
|
children: node.children.map(c => syncSubtreeCandleTypes(c, ct)).filter(Boolean) as LogicNode[],
|
|
};
|
|
}
|
|
return node;
|
|
}
|
|
|
|
function collectExplicitCandleTypesFromTree(node: LogicNode | null, out: Set<string>): void {
|
|
if (!node) return;
|
|
if (node.type === 'TIMEFRAME') {
|
|
if (node.candleTypes?.length) {
|
|
for (const ct of node.candleTypes) out.add(normalizeStartCandleType(ct));
|
|
} else if (node.candleType) {
|
|
out.add(normalizeStartCandleType(node.candleType));
|
|
}
|
|
}
|
|
if (node.type === 'CONDITION' && node.condition) {
|
|
const c = node.condition as ConditionDSL;
|
|
if (c.leftCandleType) out.add(normalizeStartCandleType(c.leftCandleType));
|
|
if (c.rightCandleType) out.add(normalizeStartCandleType(c.rightCandleType));
|
|
}
|
|
node.children?.forEach(ch => collectExplicitCandleTypesFromTree(ch, out));
|
|
}
|
|
|
|
function inferPrimaryCandleFromTree(root: LogicNode | null): string {
|
|
const set = new Set<string>();
|
|
collectExplicitCandleTypesFromTree(root, set);
|
|
const non1m = [...set].filter(ct => ct !== '1m');
|
|
if (non1m.length >= 1) return non1m[0];
|
|
if (set.size >= 1) return [...set][0];
|
|
return DEFAULT_START_CANDLE;
|
|
}
|
|
|
|
export function updateStartCandleTypes(
|
|
state: EditorConditionState,
|
|
startId: string,
|
|
candleTypes: string[],
|
|
): EditorConditionState {
|
|
const types = normalizeCandleTypesList(candleTypes);
|
|
// 조건 노드 left/rightCandleType 동기화는 "대표 분봉"에만 사용되므로,
|
|
// multi 분봉에서 stale 1m 이 앞에 오는 경우 1m 로 동기화되지 않도록 non-1m 을 우선한다.
|
|
const primary = types.find(ct => normalizeStartCandleType(ct) !== '1m') ?? types[0];
|
|
const nextMeta = {
|
|
...state.startMeta,
|
|
[startId]: { candleTypes: types, candleType: primary },
|
|
};
|
|
|
|
if (startId === START_NODE_ID) {
|
|
return {
|
|
...state,
|
|
startMeta: nextMeta,
|
|
root: syncSubtreeCandleTypes(state.root, primary),
|
|
};
|
|
}
|
|
|
|
return {
|
|
...state,
|
|
startMeta: nextMeta,
|
|
extraRoots: {
|
|
...state.extraRoots,
|
|
[startId]: syncSubtreeCandleTypes(state.extraRoots[startId] ?? null, primary),
|
|
},
|
|
};
|
|
}
|
|
|
|
/** @deprecated 단일 분봉 — updateStartCandleTypes 사용 */
|
|
export function updateStartCandleType(
|
|
state: EditorConditionState,
|
|
startId: string,
|
|
candleType: string,
|
|
): EditorConditionState {
|
|
return updateStartCandleTypes(state, startId, [candleType]);
|
|
}
|
|
|
|
function normalizeCandleTypesList(types: string[]): string[] {
|
|
const seen = new Set<string>();
|
|
const out: string[] = [];
|
|
for (const ct of STRATEGY_CANDLE_TYPES) {
|
|
if (types.some(t => normalizeStartCandleType(t) === ct) && !seen.has(ct)) {
|
|
seen.add(ct);
|
|
out.push(ct);
|
|
}
|
|
}
|
|
if (out.length === 0) return [DEFAULT_START_CANDLE];
|
|
return out;
|
|
}
|
|
|
|
function readTimeframeCandleTypes(node: LogicNode): string[] {
|
|
if (node.candleTypes?.length) {
|
|
return normalizeCandleTypesList(node.candleTypes);
|
|
}
|
|
return [normalizeStartCandleType(node.candleType)];
|
|
}
|
|
|
|
export function addExtraStartSection(state: EditorConditionState): EditorConditionState {
|
|
const startId = createStartNodeId();
|
|
return {
|
|
...state,
|
|
extraStartIds: [...state.extraStartIds, startId],
|
|
startMeta: {
|
|
...state.startMeta,
|
|
[startId]: { candleTypes: [DEFAULT_START_CANDLE], 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 {
|
|
const ct = normalizeStartCandleType(candleType);
|
|
return {
|
|
id: generateNodeId(),
|
|
type: 'TIMEFRAME',
|
|
candleType: ct,
|
|
children: [root],
|
|
};
|
|
}
|
|
|
|
function wrapTimeframes(candleTypes: string[], root: LogicNode): LogicNode {
|
|
const types = normalizeCandleTypesList(candleTypes);
|
|
return {
|
|
id: generateNodeId(),
|
|
type: 'TIMEFRAME',
|
|
candleType: types[0],
|
|
candleTypes: types,
|
|
children: [root],
|
|
};
|
|
}
|
|
|
|
/** 편집기 상태 → 저장/평가용 DSL (TIMEFRAME 래핑) */
|
|
export function encodeConditionForSave(state: EditorConditionState): LogicNode | null {
|
|
const synced = syncEditorStateCandleTypesForSave(state);
|
|
const branches = collectEditorBranches(synced).filter(
|
|
(b): b is ConditionBranch & { root: LogicNode } => b.root != null,
|
|
);
|
|
|
|
if (branches.length === 0) return null;
|
|
if (branches.length === 1) {
|
|
const { candleType, candleTypes, root } = branches[0];
|
|
const types = candleTypes?.length ? candleTypes : [candleType];
|
|
return wrapTimeframes(types, normalizeLogicRootForPersistence(root)!);
|
|
}
|
|
|
|
const combineOp = normalizeStartCombineOp(state.startCombineOp);
|
|
return {
|
|
id: generateNodeId(),
|
|
type: combineOp,
|
|
children: branches.map(b => {
|
|
const types = b.candleTypes?.length ? b.candleTypes : [b.candleType];
|
|
return wrapTimeframes(types, 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 candleTypes = readTimeframeCandleTypes(tf as LogicNode);
|
|
const candleType = candleTypes[0];
|
|
const branchRoot = syncSubtreeCandleTypes(tf.children?.[0] ?? null, candleType);
|
|
if (index === 0) {
|
|
startMeta[START_NODE_ID] = { candleTypes, candleType };
|
|
primaryRoot = branchRoot;
|
|
return;
|
|
}
|
|
const startId = createStartNodeId();
|
|
startMeta[startId] = { candleTypes, 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') {
|
|
const candleTypes = readTimeframeCandleTypes(dsl);
|
|
const primary = candleTypes[0];
|
|
return {
|
|
root: syncSubtreeCandleTypes(dsl.children?.[0] ?? null, primary),
|
|
startMeta: {
|
|
[START_NODE_ID]: { candleTypes, candleType: primary },
|
|
},
|
|
extraStartIds: [],
|
|
extraRoots: {},
|
|
startCombineOp: DEFAULT_START_COMBINE_OP,
|
|
};
|
|
}
|
|
|
|
const inferred = inferPrimaryCandleFromTree(dsl);
|
|
return {
|
|
root: syncSubtreeCandleTypes(dsl, inferred),
|
|
startMeta: {
|
|
[START_NODE_ID]: { candleTypes: [inferred], candleType: inferred },
|
|
},
|
|
extraStartIds: [],
|
|
extraRoots: {},
|
|
startCombineOp: DEFAULT_START_COMBINE_OP,
|
|
};
|
|
}
|
|
|
|
/** Logic Expression / 미리보기용 — START별 평가 분봉과 서브트리 */
|
|
export function collectEditorBranches(state: EditorConditionState): ConditionBranch[] {
|
|
const branches: ConditionBranch[] = [];
|
|
const primaryTypes = getStartCandleTypes(state.startMeta[START_NODE_ID]);
|
|
if (state.root) {
|
|
branches.push({
|
|
candleType: primaryTypes[0],
|
|
candleTypes: primaryTypes.length > 1 ? primaryTypes : undefined,
|
|
root: state.root,
|
|
});
|
|
}
|
|
|
|
for (const startId of state.extraStartIds) {
|
|
const root = state.extraRoots[startId] ?? null;
|
|
if (root) {
|
|
const types = getStartCandleTypes(state.startMeta[startId]);
|
|
branches.push({
|
|
candleType: types[0],
|
|
candleTypes: types.length > 1 ? types : undefined,
|
|
root,
|
|
});
|
|
}
|
|
}
|
|
return branches;
|
|
}
|
|
|
|
/** DSL·UI에서 사용하는 전략 평가 분봉 목록 */
|
|
export function collectTimeframesFromEditorState(state: EditorConditionState): string[] {
|
|
const set = new Set<string>();
|
|
for (const section of collectStartSections(state)) {
|
|
for (const ct of section.candleTypes) set.add(ct);
|
|
}
|
|
return [...set];
|
|
}
|
|
|
|
/** @deprecated 매수·매도 시간봉은 각각 독립 설정 — updateStartCandleTypes 사용 */
|
|
export function syncBuySellPrimaryStartCandleTypes(
|
|
buy: EditorConditionState,
|
|
sell: EditorConditionState,
|
|
candleTypes: string[],
|
|
): { buy: EditorConditionState; sell: EditorConditionState } {
|
|
const types = normalizeCandleTypesList(candleTypes);
|
|
return {
|
|
buy: updateStartCandleTypes(buy, START_NODE_ID, types),
|
|
sell: updateStartCandleTypes(sell, START_NODE_ID, types),
|
|
};
|
|
}
|
|
|
|
/** 저장 직전 — 각 START 분봉을 조건 트리 left/rightCandleType 에 반영 */
|
|
export function syncEditorStateCandleTypesForSave(state: EditorConditionState): EditorConditionState {
|
|
let next = state;
|
|
for (const section of collectStartSections(state)) {
|
|
next = updateStartCandleTypes(next, section.startId, section.candleTypes);
|
|
}
|
|
return next;
|
|
}
|
|
|
|
/** 저장 직전 — 각 탭 START 분봉을 해당 조건 트리 left/rightCandleType 에 반영 (매수·매도 독립) */
|
|
export function alignBuySellStartCandleTypesForSave(
|
|
buy: EditorConditionState,
|
|
sell: EditorConditionState,
|
|
): { buy: EditorConditionState; sell: EditorConditionState } {
|
|
return {
|
|
buy: syncEditorStateCandleTypesForSave(buy),
|
|
sell: syncEditorStateCandleTypesForSave(sell),
|
|
};
|
|
}
|
|
|
|
/** 저장된 DSL에서 Logic Expression용 분기 목록 */
|
|
export function collectDslBranches(dsl: LogicNode | null): ConditionBranch[] {
|
|
return collectEditorBranches(decodeConditionForEditor(dsl));
|
|
}
|
|
|
|
/** DB가 1m 기본값만 갖고 편집기 localStorage에 다른 분봉이 있을 때 localStorage 우선 */
|
|
/**
|
|
* 전략 로드 시 startMeta 병합 — flow_layout_json(편집기)이 있으면 DSL TIMEFRAME보다 우선.
|
|
*/
|
|
export function mergeStartMetaForLoad(
|
|
decoded: Record<string, StartNodeMeta>,
|
|
stored?: Record<string, StartNodeMeta> | null,
|
|
): Record<string, StartNodeMeta> {
|
|
if (!stored || Object.keys(stored).length === 0) {
|
|
const fromDsl: Record<string, StartNodeMeta> = { ...defaultStartMeta(), ...decoded };
|
|
for (const [startId, meta] of Object.entries(decoded)) {
|
|
const types = normalizeCandleTypesList(getStartCandleTypes(meta));
|
|
fromDsl[startId] = { candleTypes: types, candleType: types[0] };
|
|
}
|
|
return fromDsl;
|
|
}
|
|
const merged: Record<string, StartNodeMeta> = {
|
|
...defaultStartMeta(),
|
|
...stored,
|
|
};
|
|
for (const [startId, meta] of Object.entries(decoded)) {
|
|
if (stored[startId]) {
|
|
const storedTypes = normalizeCandleTypesList(getStartCandleTypes(stored[startId]));
|
|
const decodedTypes = normalizeCandleTypesList(getStartCandleTypes(meta));
|
|
// flow_layout_json 에 stale 1m 이 남아 있는 경우가 있어,
|
|
// DSL 에 1m 이 없고 non-1m 이 있으면 DSL 쪽을 우선한다.
|
|
const shouldPreferDecoded =
|
|
storedTypes.includes('1m')
|
|
&& storedTypes.some(ct => ct !== '1m')
|
|
&& !decodedTypes.includes('1m')
|
|
&& decodedTypes.some(ct => ct !== '1m');
|
|
const types = shouldPreferDecoded ? decodedTypes : storedTypes;
|
|
merged[startId] = { candleTypes: types, candleType: types[0] };
|
|
} else {
|
|
const types = normalizeCandleTypesList(getStartCandleTypes(meta));
|
|
merged[startId] = { candleTypes: types, candleType: types[0] };
|
|
}
|
|
}
|
|
return merged;
|
|
}
|