diff --git a/frontend/src/components/VirtualTradingPage.tsx b/frontend/src/components/VirtualTradingPage.tsx index fe31ced..96b179c 100644 --- a/frontend/src/components/VirtualTradingPage.tsx +++ b/frontend/src/components/VirtualTradingPage.tsx @@ -51,7 +51,9 @@ import { coerceFiniteNumber } from '../utils/safeFormat'; import { syncVirtualTargetsToBackend, stopVirtualLiveOnBackend, + warnStrategyTimeframeMismatch, } from '../utils/virtualLiveStrategySync'; +import { pinStrategyEvaluationTimeframes } from '../utils/strategyTimeframePin'; import { persistVirtualTargetPinned } from '../utils/virtualTargetMutations'; import { virtualTargetLimitMessage } from '../utils/virtualTargetLimits'; import '../styles/virtualTradingDashboard.css'; @@ -269,13 +271,16 @@ const VirtualTradingPage: React.FC = ({ window.alert('투자전략을 선택하세요.'); return; } + const globalStrat = strategies.find(s => s.id === session.globalStrategyId); + const ok = await warnStrategyTimeframeMismatch(globalStrat, session.globalStrategyId); + if (!ok) return; try { await syncVirtualTargetsToBackend(targets, session, true); setSession(s => ({ ...s, running: true })); } catch { window.alert('가상투자 시작 설정 저장에 실패했습니다.'); } - }, [targets, session]); + }, [targets, session, strategies]); const handleStop = useCallback(async () => { try { @@ -298,24 +303,21 @@ const VirtualTradingPage: React.FC = ({ t.market === market ? { ...t, strategyId } : t, )); if (!session.running || !strategyId) return; - const target = targets.find(t => t.market === market); - const candleType = normalizeStartCandleType( - resolveTargetCandleType(target ?? { candleType: undefined }, snapshots[market]?.timeframe), - ); try { await saveLiveStrategySettings({ market, strategyId, - candleType, isLiveCheck: true, executionType: session.executionType, positionMode: session.positionMode, skipWatchlistSync: true, + skipGlobalTemplate: true, }); + await pinStrategyEvaluationTimeframes(market, strategyId); } catch { window.alert('종목 전략 변경 저장에 실패했습니다.'); } - }, [session, targets, snapshots]); + }, [session, targets]); const handleTargetCandleTypeChange = useCallback(async (market: string, candleType: string) => { const normalized = normalizeStartCandleType(candleType); @@ -330,15 +332,14 @@ const VirtualTradingPage: React.FC = ({ await saveLiveStrategySettings({ market, strategyId, - candleType: normalized, isLiveCheck: true, executionType: session.executionType, positionMode: session.positionMode, skipWatchlistSync: true, + skipGlobalTemplate: true, }); - await pinCandleWatch(market, normalized); - if (normalized !== '1m') { - await pinCandleWatch(market, '1m'); + if (strategyId) { + await pinStrategyEvaluationTimeframes(market, strategyId); } } catch { window.alert('종목 평가 분봉 변경 저장에 실패했습니다.'); diff --git a/frontend/src/utils/backendApi.ts b/frontend/src/utils/backendApi.ts index d7067b5..2279134 100644 --- a/frontend/src/utils/backendApi.ts +++ b/frontend/src/utils/backendApi.ts @@ -1123,6 +1123,12 @@ export async function fetchLiveConditionStatus( }); } +/** 전략 DSL에 포함된 평가 시간봉 목록 (실시간 체크·STOMP 구독용) */ +export async function loadStrategyTimeframes(strategyId: number): Promise { + const list = await request(`/strategies/${strategyId}/timeframes`); + return list?.length ? list : ['1m']; +} + /** 차트 실시간 파이프라인 pin (STOMP warm-up) */ export async function pinCandleWatch(market: string, candleType: string): Promise { try { diff --git a/frontend/src/utils/strategyTimeframePin.ts b/frontend/src/utils/strategyTimeframePin.ts new file mode 100644 index 0000000..c18ed02 --- /dev/null +++ b/frontend/src/utils/strategyTimeframePin.ts @@ -0,0 +1,20 @@ +import { loadStrategyTimeframes, pinCandleWatch } from './backendApi'; +import { normalizeStartCandleType } from './strategyStartNodes'; + +/** 전략 DSL 평가 분봉 전체 pin — 1m 집계·상위 봉 warm-up */ +export async function pinStrategyEvaluationTimeframes( + market: string, + strategyId: number, +): Promise { + const raw = await loadStrategyTimeframes(strategyId); + const timeframes = [...new Set(raw.map(tf => normalizeStartCandleType(tf)))]; + if (timeframes.length === 0) timeframes.push('1m'); + + for (const tf of timeframes) { + await pinCandleWatch(market, tf); + } + if (!timeframes.includes('1m')) { + await pinCandleWatch(market, '1m'); + } + return timeframes; +} diff --git a/frontend/src/utils/virtualLiveStrategySync.ts b/frontend/src/utils/virtualLiveStrategySync.ts index 351429a..23dd2c3 100644 --- a/frontend/src/utils/virtualLiveStrategySync.ts +++ b/frontend/src/utils/virtualLiveStrategySync.ts @@ -1,7 +1,53 @@ -import { saveLiveStrategySettings } from './backendApi'; +import { loadStrategyTimeframes, saveLiveStrategySettings } from './backendApi'; +import { collectDslBranches } from './strategyConditionSerde'; +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 { resolveTargetCandleType } from './virtualTradingStorage'; + +function collectUiTimeframes(strategy: StrategyDto | null | undefined): string[] { + if (!strategy) return []; + const set = new Set(); + 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), + ]) { + set.add(normalizeStartCandleType(b.candleType)); + } + return [...set]; +} + +/** + * UI(편집기)와 서버 DSL의 평가 분봉이 다르면 안내. + * @returns false — 계속 진행 불가(재저장 필요) + */ +export async function warnStrategyTimeframeMismatch( + strategy: StrategyDto | null | undefined, + strategyId: number, +): Promise { + 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( @@ -18,23 +64,35 @@ export async function syncVirtualTargetsToBackend( skipWatchlistSync: true, } as const; + if (isLiveCheck && session.globalStrategyId != null) { + await saveLiveStrategySettings({ + market: targets[0].market, + strategyId: session.globalStrategyId, + isLiveCheck: true, + executionType: session.executionType, + positionMode: session.positionMode, + skipWatchlistSync: true, + }); + } + + const pinJobs = targets.map(async t => { + const strategyId = t.strategyId ?? session.globalStrategyId; + if (strategyId == null) return; + await pinStrategyEvaluationTimeframes(t.market, strategyId); + }); + await Promise.all(pinJobs); + await Promise.all( targets.map(t => saveLiveStrategySettings({ market: t.market, strategyId: t.strategyId ?? session.globalStrategyId, - candleType: normalizeStartCandleType(resolveTargetCandleType(t)), isPinned: !!t.pinned, + skipGlobalTemplate: true, ...shared, }), ), ); - - await saveLiveStrategySettings({ - market: targets[0].market, - strategyId: session.globalStrategyId, - ...shared, - }); } /** 가상투자 중지 — 대상 종목 체크 해제 + 전역 liveStrategyCheck OFF */