241 lines
8.0 KiB
TypeScript
241 lines
8.0 KiB
TypeScript
import {
|
|
isStrategyMissingOnServer,
|
|
loadStrategy,
|
|
loadStrategyTimeframes,
|
|
repairStrategyTimeframes,
|
|
saveStrategy,
|
|
type StrategyDto,
|
|
} from './backendApi';
|
|
import {
|
|
alignBuySellStartCandleTypesForSave,
|
|
collectDslBranches,
|
|
collectTimeframesFromEditorState,
|
|
decodeConditionForEditor,
|
|
encodeConditionForSave,
|
|
mergeStartMetaForLoad,
|
|
normalizeStartCombineOp,
|
|
syncEditorStateCandleTypesForSave,
|
|
type EditorConditionState,
|
|
} from './strategyConditionSerde';
|
|
import { getStartCandleTypes, normalizeStartCandleType } from './strategyStartNodes';
|
|
import type { LogicNode } from './strategyTypes';
|
|
import type { StrategyFlowLayoutStore } from './strategyEditorLayoutStorage';
|
|
|
|
function collectFromFlowLayout(layout: StrategyFlowLayoutStore | null | undefined): string[] {
|
|
if (!layout) return [];
|
|
const set = new Set<string>();
|
|
for (const side of ['buy', 'sell'] as const) {
|
|
const metaMap = layout[side]?.startMeta;
|
|
if (!metaMap) continue;
|
|
for (const meta of Object.values(metaMap)) {
|
|
for (const ct of getStartCandleTypes(meta)) set.add(normalizeStartCandleType(ct));
|
|
}
|
|
}
|
|
return [...set];
|
|
}
|
|
|
|
function collectFromStrategyDto(strategy: StrategyDto | null | undefined): string[] {
|
|
if (!strategy) return [];
|
|
const set = new Set<string>();
|
|
const buy = strategy.buyCondition as LogicNode | null | undefined;
|
|
const sell = strategy.sellCondition as LogicNode | null | undefined;
|
|
for (const b of [
|
|
...collectDslBranches(buy ?? null),
|
|
...collectDslBranches(sell ?? null),
|
|
]) {
|
|
const types = b.candleTypes?.length ? b.candleTypes : [b.candleType];
|
|
for (const ct of types) set.add(normalizeStartCandleType(ct));
|
|
}
|
|
return [...set];
|
|
}
|
|
|
|
/** 편집기·flow layout·전략 DTO에서 UI 평가 분봉 수집 */
|
|
export function collectUiEvaluationTimeframes(
|
|
_strategyId: number,
|
|
strategy?: StrategyDto | null,
|
|
editorState?: { buy: EditorConditionState; sell: EditorConditionState },
|
|
): string[] {
|
|
if (editorState) {
|
|
const fromEditor = [
|
|
...collectTimeframesFromEditorState(editorState.buy),
|
|
...collectTimeframesFromEditorState(editorState.sell),
|
|
].map(normalizeStartCandleType);
|
|
if (fromEditor.length > 0) return [...new Set(fromEditor)];
|
|
}
|
|
const fromLayout = collectFromFlowLayout(strategy?.flowLayout);
|
|
if (fromLayout.length > 0) return fromLayout;
|
|
return collectFromStrategyDto(strategy);
|
|
}
|
|
|
|
function evaluationTimeframesMatch(uiTfs: string[], serverTfs: string[]): boolean {
|
|
const uiSet = new Set(uiTfs.map(tf => normalizeStartCandleType(tf)));
|
|
const serverSet = new Set(serverTfs.map(tf => normalizeStartCandleType(tf)));
|
|
const missingOnServer = uiTfs.some(tf => !serverSet.has(normalizeStartCandleType(tf)));
|
|
const extraOnServer = [...serverSet].some(tf => !uiSet.has(tf));
|
|
if (!missingOnServer && !extraOnServer) return true;
|
|
if (uiSet.size === 1 && uiSet.has('1m') && serverSet.size === 1 && serverSet.has('1m')) {
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* UI 분봉과 서버 DSL 불일치 시 자동 동기화 시도 후, 여전히 다르면 안내.
|
|
* @returns false — 진행 불가(저장 필요)
|
|
*/
|
|
export async function warnStrategyTimeframeMismatch(
|
|
strategyId: number,
|
|
strategy?: StrategyDto | null,
|
|
editorState?: { buy: EditorConditionState; sell: EditorConditionState },
|
|
): Promise<boolean> {
|
|
const strat = strategy ?? await loadStrategy(strategyId);
|
|
const uiTfs = collectUiEvaluationTimeframes(strategyId, strat, editorState);
|
|
if (uiTfs.length === 0) return true;
|
|
|
|
let serverTfs: string[];
|
|
try {
|
|
serverTfs = await loadStrategyTimeframes(strategyId);
|
|
} catch {
|
|
return true;
|
|
}
|
|
|
|
if (evaluationTimeframesMatch(uiTfs, serverTfs)) return true;
|
|
|
|
const synced = await syncStrategyTimeframesFromLayoutIfNeeded(strategyId, strat);
|
|
if (synced) {
|
|
try {
|
|
serverTfs = await loadStrategyTimeframes(strategyId);
|
|
if (evaluationTimeframesMatch(uiTfs, serverTfs)) return true;
|
|
} catch {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
const uiSet = new Set(uiTfs.map(tf => normalizeStartCandleType(tf)));
|
|
const serverSet = new Set(serverTfs.map(tf => normalizeStartCandleType(tf)));
|
|
const uiLabel = uiTfs.map(tf => normalizeStartCandleType(tf)).join(', ') || '1m';
|
|
const serverLabel = [...serverSet].join(', ') || '1m';
|
|
if (uiSet.size === serverSet.size && [...uiSet].every(tf => serverSet.has(tf))) {
|
|
return true;
|
|
}
|
|
|
|
window.alert(
|
|
`전략 화면에는 ${uiLabel} 봉으로 설정되어 있지만, 서버에는 ${serverLabel} 만 저장되어 있습니다.\n\n`
|
|
+ '전략편집기에서 「저장」을 누르거나 START 분봉을 다시 선택해 DB에 반영한 뒤 실시간 체크를 켜 주세요.',
|
|
);
|
|
return false;
|
|
}
|
|
|
|
function buildEditorStateFromStrategy(
|
|
strategy: StrategyDto,
|
|
layout: StrategyFlowLayoutStore | null | undefined,
|
|
side: 'buy' | 'sell',
|
|
): EditorConditionState {
|
|
const decoded = decodeConditionForEditor(
|
|
(side === 'buy' ? strategy.buyCondition : strategy.sellCondition) as LogicNode | null,
|
|
);
|
|
const snap = layout?.[side];
|
|
const merged: EditorConditionState = {
|
|
root: decoded.root,
|
|
startMeta: mergeStartMetaForLoad(decoded.startMeta, snap?.startMeta),
|
|
extraStartIds: snap?.extraStartIds?.length ? snap.extraStartIds : decoded.extraStartIds,
|
|
extraRoots: snap?.extraRoots ?? decoded.extraRoots,
|
|
startCombineOp: normalizeStartCombineOp(snap?.startCombineOp ?? decoded.startCombineOp),
|
|
};
|
|
return syncEditorStateCandleTypesForSave(merged);
|
|
}
|
|
|
|
/**
|
|
* DB flow layout의 START 분봉이 서버 DSL과 다르면 자동 동기화.
|
|
*/
|
|
export async function syncStrategyTimeframesFromLayoutIfNeeded(
|
|
strategyId: number,
|
|
_strategy?: StrategyDto | null,
|
|
): Promise<boolean> {
|
|
if (isStrategyMissingOnServer(strategyId)) return false;
|
|
const strat = await loadStrategy(strategyId);
|
|
if (!strat) return false;
|
|
|
|
const uiTfs = collectUiEvaluationTimeframes(strategyId, strat);
|
|
if (uiTfs.length === 0) return true;
|
|
|
|
let serverTfs: string[];
|
|
try {
|
|
serverTfs = await loadStrategyTimeframes(strategyId);
|
|
} catch {
|
|
return true;
|
|
}
|
|
|
|
if (evaluationTimeframesMatch(uiTfs, serverTfs)) return true;
|
|
|
|
const layout = strat.flowLayout;
|
|
if (!layout) {
|
|
const repaired = await repairStrategyTimeframes(strategyId);
|
|
if (!repaired) return false;
|
|
const tfs = await loadStrategyTimeframes(strategyId);
|
|
return evaluationTimeframesMatch(uiTfs, tfs);
|
|
}
|
|
|
|
const buyState = buildEditorStateFromStrategy(strat, layout, 'buy');
|
|
const sellState = buildEditorStateFromStrategy(strat, layout, 'sell');
|
|
|
|
await persistStrategyEvaluationTimeframes(
|
|
strategyId,
|
|
strat.name,
|
|
strat.description ?? '',
|
|
buyState,
|
|
sellState,
|
|
layout,
|
|
);
|
|
try {
|
|
await repairStrategyTimeframes(strategyId);
|
|
} catch {
|
|
/* repair optional */
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/** 편집기 상태(DSL + flow layout)를 DB에 저장 — 실시간 평가 캐시는 서버에서 무효화됨 */
|
|
export async function persistStrategyEditorState(
|
|
strategyId: number,
|
|
name: string,
|
|
description: string,
|
|
buy: EditorConditionState,
|
|
sell: EditorConditionState,
|
|
flowLayout: StrategyFlowLayoutStore,
|
|
): Promise<StrategyDto> {
|
|
const aligned = alignBuySellStartCandleTypesForSave(buy, sell);
|
|
const encodedBuy = encodeConditionForSave(aligned.buy);
|
|
const encodedSell = encodeConditionForSave(aligned.sell);
|
|
const saved = await saveStrategy({
|
|
id: strategyId,
|
|
name,
|
|
description,
|
|
buyCondition: encodedBuy,
|
|
sellCondition: encodedSell,
|
|
flowLayout,
|
|
enabled: true,
|
|
});
|
|
if (typeof window !== 'undefined') {
|
|
window.dispatchEvent(new CustomEvent('gc:strategy-saved', {
|
|
detail: { strategyId, buyCondition: encodedBuy, sellCondition: encodedSell, flowLayout },
|
|
}));
|
|
}
|
|
return saved;
|
|
}
|
|
|
|
/** @deprecated persistStrategyEditorState 사용 */
|
|
export async function persistStrategyEvaluationTimeframes(
|
|
strategyId: number,
|
|
name: string,
|
|
description: string,
|
|
buy: EditorConditionState,
|
|
sell: EditorConditionState,
|
|
flowLayout?: StrategyFlowLayoutStore,
|
|
): Promise<void> {
|
|
if (!flowLayout) return;
|
|
await persistStrategyEditorState(strategyId, name, description, buy, sell, flowLayout);
|
|
}
|
|
|
|
export type { StrategyFlowLayoutStore };
|