전략 시간봉 오류 수정

This commit is contained in:
Macbook
2026-05-28 02:01:36 +09:00
parent 01218b99c9
commit 5f9b20d907
9 changed files with 362 additions and 82 deletions
+23 -6
View File
@@ -311,7 +311,7 @@ function parseMultiStartWrapper(dsl: LogicNode): EditorConditionState | null {
children.forEach((tf, index) => {
const candleTypes = readTimeframeCandleTypes(tf as LogicNode);
const candleType = candleTypes[0];
const branchRoot = tf.children?.[0] ?? null;
const branchRoot = syncSubtreeCandleTypes(tf.children?.[0] ?? null, candleType);
if (index === 0) {
startMeta[START_NODE_ID] = { candleTypes, candleType };
primaryRoot = branchRoot;
@@ -341,10 +341,11 @@ export function decodeConditionForEditor(dsl: LogicNode | null): EditorCondition
if (dsl.type === 'TIMEFRAME') {
const candleTypes = readTimeframeCandleTypes(dsl);
const primary = candleTypes[0];
return {
root: dsl.children?.[0] ?? null,
root: syncSubtreeCandleTypes(dsl.children?.[0] ?? null, primary),
startMeta: {
[START_NODE_ID]: { candleTypes, candleType: candleTypes[0] },
[START_NODE_ID]: { candleTypes, candleType: primary },
},
extraStartIds: [],
extraRoots: {},
@@ -354,7 +355,7 @@ export function decodeConditionForEditor(dsl: LogicNode | null): EditorCondition
const inferred = inferPrimaryCandleFromTree(dsl);
return {
root: dsl,
root: syncSubtreeCandleTypes(dsl, inferred),
startMeta: {
[START_NODE_ID]: { candleTypes: [inferred], candleType: inferred },
},
@@ -429,8 +430,21 @@ export function collectDslBranches(dsl: LogicNode | null): ConditionBranch[] {
return collectEditorBranches(decodeConditionForEditor(dsl));
}
/** DB가 1m 기본값만 갖고 편집기 localStorage에 다른 분봉이 있을 때 localStorage 우선 */
function pickMergedStartCandleTypes(decoded: string[], stored?: string[]): string[] {
const fromDb = normalizeCandleTypesList(decoded);
const fromStored = stored?.length ? normalizeCandleTypesList(stored) : [];
const dbIsLegacyDefault = fromDb.length === 1 && fromDb[0] === '1m';
const storedDiffers = fromStored.length > 0
&& (fromStored.length !== fromDb.length || fromStored.some((ct, i) => ct !== fromDb[i]));
if (storedDiffers && (dbIsLegacyDefault || fromDb.length === 0)) {
return fromStored;
}
return fromDb;
}
/**
* 전략 로드 시 startMeta 병합 — DB DSL에서 복원한 시간봉이 localStorage보다 우선.
* 전략 로드 시 startMeta 병합 — DB가 1m 기본값만 있고 localStorage에 다른 분봉이 있으면 localStorage 우선.
*/
export function mergeStartMetaForLoad(
decoded: Record<string, StartNodeMeta>,
@@ -441,7 +455,10 @@ export function mergeStartMetaForLoad(
...stored,
};
for (const [startId, meta] of Object.entries(decoded)) {
const types = getStartCandleTypes(meta);
const types = pickMergedStartCandleTypes(
getStartCandleTypes(meta),
stored?.[startId] ? getStartCandleTypes(stored[startId]) : undefined,
);
merged[startId] = { candleTypes: types, candleType: types[0] };
}
return merged;
+118
View File
@@ -0,0 +1,118 @@
import { loadStrategyTimeframes, saveStrategy, type StrategyDto } from './backendApi';
import { loadStrategyFlowLayout } from './strategyEditorLayoutStorage';
import {
alignBuySellStartCandleTypesForSave,
collectDslBranches,
collectTimeframesFromEditorState,
encodeConditionForSave,
type EditorConditionState,
} from './strategyConditionSerde';
import { START_NODE_ID, getStartCandleTypes, normalizeStartCandleType } from './strategyStartNodes';
import type { LogicNode } from './strategyTypes';
import type { StrategyFlowLayoutStore } from './strategyEditorLayoutStorage';
function collectFromFlowLayout(strategyId: number): string[] {
const layout = loadStrategyFlowLayout(String(strategyId));
if (!layout) return [];
const set = new Set<string>();
for (const side of ['buy', 'sell'] as const) {
const meta = layout[side]?.startMeta?.[START_NODE_ID];
if (!meta) continue;
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 평가 분봉 수집 (서버 DSL보다 우선) */
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(strategyId);
if (fromLayout.length > 0) return fromLayout;
return collectFromStrategyDto(strategy);
}
/**
* UI 분봉과 서버 DSL 불일치 시 안내.
* @returns false — 진행 불가(저장 필요)
*/
export async function warnStrategyTimeframeMismatch(
strategyId: number,
strategy?: StrategyDto | null,
editorState?: { buy: EditorConditionState; sell: EditorConditionState },
): Promise<boolean> {
const uiTfs = collectUiEvaluationTimeframes(strategyId, strategy, editorState);
if (uiTfs.length === 0) return true;
let serverTfs: string[];
try {
serverTfs = await loadStrategyTimeframes(strategyId);
} catch {
return true;
}
const uiSet = new Set(uiTfs.map(tf => normalizeStartCandleType(tf)));
const serverSet = new Set(serverTfs.map(tf => normalizeStartCandleType(tf)));
const missingOnServer = uiTfs.filter(tf => !serverSet.has(normalizeStartCandleType(tf)));
const extraOnServer = [...serverSet].filter(tf => !uiSet.has(tf));
if (missingOnServer.length === 0 && extraOnServer.length === 0) return true;
// 양쪽 모두 1m만 — 일치로 간주
if (uiSet.size === 1 && uiSet.has('1m') && serverSet.size === 1 && serverSet.has('1m')) {
return true;
}
const uiLabel = uiTfs.map(tf => normalizeStartCandleType(tf)).join(', ') || '1m';
const serverLabel = [...serverSet].join(', ') || '1m';
window.alert(
`전략 화면에는 ${uiLabel} 봉으로 설정되어 있지만, 서버에는 ${serverLabel} 만 저장되어 있습니다.\n\n`
+ '전략편집기에서 「저장」을 누르거나 START 분봉을 다시 선택해 자동 저장된 뒤 실시간 체크를 켜 주세요.',
);
return false;
}
/** START 분봉 변경 시 전략 DSL을 서버에 즉시 반영 */
export async function persistStrategyEvaluationTimeframes(
strategyId: number,
name: string,
description: string,
buy: EditorConditionState,
sell: EditorConditionState,
): Promise<void> {
const aligned = alignBuySellStartCandleTypesForSave(buy, sell);
const encodedBuy = encodeConditionForSave(aligned.buy);
const encodedSell = encodeConditionForSave(aligned.sell);
await saveStrategy({
id: strategyId,
name,
description,
buyCondition: encodedBuy,
sellCondition: encodedSell,
enabled: true,
});
}
export type { StrategyFlowLayoutStore };
+1 -49
View File
@@ -1,56 +1,8 @@
import { loadStrategyTimeframes, saveLiveStrategySettings } from './backendApi';
import { collectDslBranches } from './strategyConditionSerde';
import { saveLiveStrategySettings } from './backendApi';
import { pinStrategyEvaluationTimeframes } from './strategyTimeframePin';
import { normalizeStartCandleType } from './strategyStartNodes';
import type { StrategyDto } from './backendApi';
import type { LogicNode } from './strategyTypes';
import type { VirtualSessionConfig, VirtualTargetItem } from './virtualTradingStorage';
import { resolveVirtualTargetStrategyId } from './virtualTargetStrategy';
function collectUiTimeframes(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];
}
/**
* UI(편집기)와 서버 DSL의 평가 분봉이 다르면 안내.
* @returns false — 계속 진행 불가(재저장 필요)
*/
export async function warnStrategyTimeframeMismatch(
strategy: StrategyDto | null | undefined,
strategyId: number,
): Promise<boolean> {
if (!strategy) return true;
const uiTfs = collectUiTimeframes(strategy);
if (uiTfs.length === 0 || (uiTfs.length === 1 && uiTfs[0] === '1m')) return true;
let serverTfs: string[];
try {
serverTfs = await loadStrategyTimeframes(strategyId);
} catch {
return true;
}
const serverSet = new Set(serverTfs.map(tf => normalizeStartCandleType(tf)));
const missingOnServer = uiTfs.filter(tf => !serverSet.has(tf));
if (missingOnServer.length === 0) return true;
window.alert(
`전략 화면에는 ${uiTfs.join(', ')} 봉으로 보이지만, 서버에는 ${[...serverSet].join(', ') || '1m'} 만 저장되어 있습니다.\n\n`
+ '전략편집기에서 「저장」을 눌러 다시 저장한 뒤 가상매매를 시작하세요.',
);
return false;
}
/** 가상투자 대상 종목을 백엔드 live strategy 설정과 동기화 */
export async function syncVirtualTargetsToBackend(
targets: VirtualTargetItem[],