Files
goldenChart/frontend/src/utils/strategyConditionSerde.ts
T
2026-05-31 01:23:47 +09:00

500 lines
16 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];
// 레거시/불일치로 multi 분봉에 stale 1m 이 섞인 경우가 있어,
// multi 인데 non-1m 이 있으면 1m 을 기본적으로 제외한다.
if (out.length > 1 && out.includes('1m') && out.some(ct => ct !== '1m')) {
return out.filter(ct => ct !== '1m');
}
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];
}
/** 매수·매도 START(기본) 평가 분봉을 동일하게 맞춤 — 한쪽만 3m·5m 체크 시 다른 쪽 1m 잔존 방지 */
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 분봉 합집합을 각 탭에 반영 (매수만 설정한 경우 매도 DSL도 동기화) */
export function alignBuySellStartCandleTypesForSave(
buy: EditorConditionState,
sell: EditorConditionState,
): { buy: EditorConditionState; sell: EditorConditionState } {
const merged = normalizeCandleTypesList([
...getStartCandleTypes(buy.startMeta[START_NODE_ID]),
...getStartCandleTypes(sell.startMeta[START_NODE_ID]),
]);
const synced = syncBuySellPrimaryStartCandleTypes(buy, sell, merged);
return {
buy: syncEditorStateCandleTypesForSave(synced.buy),
sell: syncEditorStateCandleTypesForSave(synced.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;
}