전략 시간봉 오류 수정
This commit is contained in:
@@ -9,9 +9,11 @@ import { createPortal } from 'react-dom';
|
||||
import type { Theme } from '../types';
|
||||
import { useDraggablePanel } from '../hooks/useDraggablePanel';
|
||||
import {
|
||||
repairStrategyTimeframes,
|
||||
saveLiveStrategySettings,
|
||||
type LiveStrategySettingsDto,
|
||||
} from '../utils/backendApi';
|
||||
import { warnStrategyTimeframeMismatch } from '../utils/strategyTimeframeSync';
|
||||
import { getKoreanName } from '../utils/marketNameCache';
|
||||
|
||||
interface Strategy {
|
||||
@@ -70,6 +72,15 @@ const LiveStrategyPanel: React.FC<LiveStrategyPanelProps> = ({
|
||||
if (virtualDriven) return;
|
||||
const next: LiveStrategySettingsDto = { ...settings, ...patch, market };
|
||||
const prev = settings;
|
||||
if (next.isLiveCheck && next.strategyId != null) {
|
||||
const ok = await warnStrategyTimeframeMismatch(next.strategyId);
|
||||
if (!ok) return;
|
||||
try {
|
||||
await repairStrategyTimeframes(next.strategyId);
|
||||
} catch {
|
||||
/* repair optional */
|
||||
}
|
||||
}
|
||||
onSettingsChange?.(next);
|
||||
setSaving(true);
|
||||
try {
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
updateStartCandleTypes,
|
||||
type EditorConditionState,
|
||||
} from '../utils/strategyConditionSerde';
|
||||
import { persistStrategyEvaluationTimeframes } from '../utils/strategyTimeframeSync';
|
||||
import {
|
||||
START_NODE_ID,
|
||||
defaultStartMeta,
|
||||
@@ -190,8 +191,13 @@ export default function StrategyEditorPage({ theme }: Props) {
|
||||
}));
|
||||
const buyLayoutRef = useRef(buyLayout);
|
||||
const sellLayoutRef = useRef(sellLayout);
|
||||
const buyConditionRef = useRef(buyCondition);
|
||||
const sellConditionRef = useRef(sellCondition);
|
||||
buyLayoutRef.current = buyLayout;
|
||||
sellLayoutRef.current = sellLayout;
|
||||
buyConditionRef.current = buyCondition;
|
||||
sellConditionRef.current = sellCondition;
|
||||
const timeframeAutosaveRef = useRef<number | null>(null);
|
||||
const layoutRevisionRef = useRef(0);
|
||||
const persistLayoutTimerRef = useRef<number | null>(null);
|
||||
const layoutPersistReadyRef = useRef(false);
|
||||
@@ -395,6 +401,22 @@ export default function StrategyEditorPage({ theme }: Props) {
|
||||
schedulePersistFlowLayout(layoutStrategyKey);
|
||||
}, [setCurrentRoot, setCurrentLayout, layoutStrategyKey, schedulePersistFlowLayout]);
|
||||
|
||||
const scheduleTimeframePersist = useCallback(() => {
|
||||
if (selectedId == null || !stratName.trim()) return;
|
||||
if (timeframeAutosaveRef.current != null) window.clearTimeout(timeframeAutosaveRef.current);
|
||||
const id = selectedId;
|
||||
const name = stratName;
|
||||
const desc = stratDesc;
|
||||
timeframeAutosaveRef.current = window.setTimeout(() => {
|
||||
timeframeAutosaveRef.current = null;
|
||||
const buy = toEditorState(buyConditionRef.current, buyLayoutRef.current);
|
||||
const sell = toEditorState(sellConditionRef.current, sellLayoutRef.current);
|
||||
void persistStrategyEvaluationTimeframes(id, name, desc, buy, sell).catch(err => {
|
||||
console.warn('[StrategyEditor] START 분봉 자동저장 실패:', err);
|
||||
});
|
||||
}, 700);
|
||||
}, [selectedId, stratName, stratDesc]);
|
||||
|
||||
const handleStartCandleTypesChange = useCallback((startId: string, candleTypes: string[]) => {
|
||||
if (startId === START_NODE_ID) {
|
||||
const synced = syncBuySellPrimaryStartCandleTypes(buyEditorState, sellEditorState, candleTypes);
|
||||
@@ -415,16 +437,20 @@ export default function StrategyEditorPage({ theme }: Props) {
|
||||
startCombineOp: normalizeStartCombineOp(synced.sell.startCombineOp),
|
||||
}));
|
||||
schedulePersistFlowLayout(layoutStrategyKey);
|
||||
scheduleTimeframePersist();
|
||||
return;
|
||||
}
|
||||
const state = signalTab === 'buy' ? buyEditorState : sellEditorState;
|
||||
handleEditorStateChange(updateStartCandleTypes(state, startId, candleTypes));
|
||||
scheduleTimeframePersist();
|
||||
}, [
|
||||
buyEditorState,
|
||||
sellEditorState,
|
||||
handleEditorStateChange,
|
||||
layoutStrategyKey,
|
||||
schedulePersistFlowLayout,
|
||||
scheduleTimeframePersist,
|
||||
signalTab,
|
||||
]);
|
||||
|
||||
const handleStartCombineOpChange = useCallback((op: StartCombineOp) => {
|
||||
|
||||
@@ -51,8 +51,8 @@ import { coerceFiniteNumber } from '../utils/safeFormat';
|
||||
import {
|
||||
syncVirtualTargetsToBackend,
|
||||
stopVirtualLiveOnBackend,
|
||||
warnStrategyTimeframeMismatch,
|
||||
} from '../utils/virtualLiveStrategySync';
|
||||
import { warnStrategyTimeframeMismatch } from '../utils/strategyTimeframeSync';
|
||||
import { pinStrategyEvaluationTimeframes } from '../utils/strategyTimeframePin';
|
||||
import { persistVirtualTargetPinned } from '../utils/virtualTargetMutations';
|
||||
import {
|
||||
@@ -284,7 +284,7 @@ const VirtualTradingPage: React.FC<Props> = ({
|
||||
return;
|
||||
}
|
||||
const globalStrat = strategies.find(s => s.id === session.globalStrategyId);
|
||||
const ok = await warnStrategyTimeframeMismatch(globalStrat, session.globalStrategyId);
|
||||
const ok = await warnStrategyTimeframeMismatch(session.globalStrategyId, globalStrat);
|
||||
if (!ok) return;
|
||||
try {
|
||||
await syncVirtualTargetsToBackend(targets, session, true);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,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[],
|
||||
|
||||
Reference in New Issue
Block a user