From 76d4de56bdb233ed0cd3a5db5081285ae8651452 Mon Sep 17 00:00:00 2001 From: Macbook Date: Sun, 7 Jun 2026 19:21:54 +0900 Subject: [PATCH] =?UTF-8?q?=EA=B0=80=EC=83=81=EB=A7=A4=EB=A7=A4=20?= =?UTF-8?q?=EA=B8=B0=EB=B3=B8=EC=A0=84=EB=9E=B5=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/VirtualTradingPage.tsx | 30 +++++++----- .../virtual/VirtualLeftTargetPanel.tsx | 5 +- frontend/src/hooks/useVirtualTradingCore.ts | 35 ++++++++----- frontend/src/utils/virtualTargetMutations.ts | 15 ++++-- frontend/src/utils/virtualTargetStrategy.ts | 49 ++++++++++++++++++- 5 files changed, 102 insertions(+), 32 deletions(-) diff --git a/frontend/src/components/VirtualTradingPage.tsx b/frontend/src/components/VirtualTradingPage.tsx index 4040b4d..7e27c7a 100644 --- a/frontend/src/components/VirtualTradingPage.tsx +++ b/frontend/src/components/VirtualTradingPage.tsx @@ -58,6 +58,8 @@ import { import { pinStrategyEvaluationTimeframes } from '../utils/strategyTimeframePin'; import { persistVirtualTargetPinned } from '../utils/virtualTargetMutations'; import { + applyDefaultGlobalStrategyToSession, + ensureDefaultGlobalStrategyId, freezeTargetsBeforeGlobalStrategyChange, resolveVirtualTargetStrategyId, } from '../utils/virtualTargetStrategy'; @@ -166,12 +168,13 @@ const VirtualTradingPage: React.FC = ({ const validIds = new Set( list.map(s => s.id).filter((id): id is number => id != null && id > 0), ); - setSession(prev => { - const next = prev.globalStrategyId != null && !validIds.has(prev.globalStrategyId) - ? { ...prev, globalStrategyId: null } - : prev; - return mergeVirtualSessionWithAppLive(next, appLiveStrategyId); - }); + setSession(prev => + applyDefaultGlobalStrategyToSession( + mergeVirtualSessionWithAppLive(prev, appLiveStrategyId), + list, + appLiveStrategyId, + ), + ); setTargets(prev => prev.map(t => t.strategyId != null && !validIds.has(t.strategyId) @@ -380,20 +383,25 @@ const VirtualTradingPage: React.FC = ({ window.alert('투자대상 종목을 먼저 추가하세요.'); return; } - if (!session.globalStrategyId) { + const globalStrategyId = ensureDefaultGlobalStrategyId(session.globalStrategyId, strategies); + if (!globalStrategyId) { window.alert('투자전략을 선택하세요.'); return; } - const globalStrat = strategies.find(s => s.id === session.globalStrategyId); + const sessionForStart = { ...session, globalStrategyId }; + if (session.globalStrategyId !== globalStrategyId) { + setSession(sessionForStart); + } + const globalStrat = strategies.find(s => s.id === globalStrategyId); const synced = await syncStrategyTimeframesFromLayoutIfNeeded( - session.globalStrategyId, + globalStrategyId, globalStrat, ); if (!synced) return; - const ok = await warnStrategyTimeframeMismatch(session.globalStrategyId, globalStrat); + const ok = await warnStrategyTimeframeMismatch(globalStrategyId, globalStrat); if (!ok) return; try { - await syncVirtualTargetsToBackend(targets, session, true); + await syncVirtualTargetsToBackend(targets, sessionForStart, true); setSession(s => ({ ...s, running: true })); } catch { window.alert('가상투자 시작 설정 저장에 실패했습니다.'); diff --git a/frontend/src/components/virtual/VirtualLeftTargetPanel.tsx b/frontend/src/components/virtual/VirtualLeftTargetPanel.tsx index ba923c0..1ae8fc6 100644 --- a/frontend/src/components/virtual/VirtualLeftTargetPanel.tsx +++ b/frontend/src/components/virtual/VirtualLeftTargetPanel.tsx @@ -12,6 +12,7 @@ import { import { defaultStrategyOptionLabel, parseTargetStrategySelectValue, + resolveStrategyIdForNewTarget, targetStrategySelectValue, } from '../../utils/virtualTargetStrategy'; import { VirtualScroll } from '../common/VirtualScroll'; @@ -78,7 +79,7 @@ const VirtualLeftTargetPanel: React.FC = ({ ...targets, { market, - strategyId: globalStrategyId, + strategyId: resolveStrategyIdForNewTarget(globalStrategyId, strategies), koreanName: names.koreanName, englishName: names.englishName, }, @@ -86,7 +87,7 @@ const VirtualLeftTargetPanel: React.FC = ({ onSelectMarket(market); setQuery(''); setShowDropdown(false); - }, [addedSet, targets, globalStrategyId, onTargetsChange, onSelectMarket, virtualTargetMaxCount]); + }, [addedSet, targets, globalStrategyId, strategies, onTargetsChange, onSelectMarket, virtualTargetMaxCount]); const handleRemove = useCallback((market: string) => { onTargetsChange(targets.filter(t => t.market !== market)); diff --git a/frontend/src/hooks/useVirtualTradingCore.ts b/frontend/src/hooks/useVirtualTradingCore.ts index fbb26ca..497ba06 100644 --- a/frontend/src/hooks/useVirtualTradingCore.ts +++ b/frontend/src/hooks/useVirtualTradingCore.ts @@ -37,8 +37,11 @@ import { import { pinStrategyEvaluationTimeframes } from '../utils/strategyTimeframePin'; import { persistVirtualTargetPinned } from '../utils/virtualTargetMutations'; import { + applyDefaultGlobalStrategyToSession, + ensureDefaultGlobalStrategyId, freezeTargetsBeforeGlobalStrategyChange, resolveVirtualTargetStrategyId, + resolveStrategyIdForNewTarget, } from '../utils/virtualTargetStrategy'; import { isVirtualTargetAddAllowed, @@ -143,12 +146,13 @@ export function useVirtualTradingCore(options: UseVirtualTradingCoreOptions = {} const validIds = new Set( s.map(x => x.id).filter((id): id is number => id != null && id > 0), ); - setSession(prev => { - const next = prev.globalStrategyId != null && !validIds.has(prev.globalStrategyId) - ? { ...prev, globalStrategyId: null } - : prev; - return mergeVirtualSessionWithAppLive(next, appLiveStrategyId); - }); + setSession(prev => + applyDefaultGlobalStrategyToSession( + mergeVirtualSessionWithAppLive(prev, appLiveStrategyId), + s, + appLiveStrategyId, + ), + ); setTargets(prev => prev.map(t => t.strategyId != null && !validIds.has(t.strategyId) @@ -285,13 +289,13 @@ export function useVirtualTradingCore(options: UseVirtualTradingCoreOptions = {} const names = resolveVirtualTargetNames(market, meta?.koreanName, meta?.englishName); setTargets(prev => [...prev, { market, - strategyId: session.globalStrategyId, + strategyId: resolveStrategyIdForNewTarget(session.globalStrategyId, strategies), koreanName: names.koreanName, englishName: names.englishName, }]); setSelectedMarket(market); return true; - }, [targets, session.globalStrategyId, virtualTargetMaxCount]); + }, [targets, session.globalStrategyId, strategies, virtualTargetMaxCount]); const handleRemoveTarget = useCallback((market: string) => { setTargets(prev => prev.filter(x => x.market !== market)); @@ -320,20 +324,25 @@ export function useVirtualTradingCore(options: UseVirtualTradingCoreOptions = {} window.alert('투자대상 종목을 먼저 추가하세요.'); return; } - if (!session.globalStrategyId) { + const globalStrategyId = ensureDefaultGlobalStrategyId(session.globalStrategyId, strategies); + if (!globalStrategyId) { window.alert('투자전략을 선택하세요.'); return; } - const globalStrat = strategies.find(s => s.id === session.globalStrategyId); + const sessionForStart = { ...session, globalStrategyId }; + if (session.globalStrategyId !== globalStrategyId) { + setSession(sessionForStart); + } + const globalStrat = strategies.find(s => s.id === globalStrategyId); const synced = await syncStrategyTimeframesFromLayoutIfNeeded( - session.globalStrategyId, + globalStrategyId, globalStrat, ); if (!synced) return; - const ok = await warnStrategyTimeframeMismatch(session.globalStrategyId, globalStrat); + const ok = await warnStrategyTimeframeMismatch(globalStrategyId, globalStrat); if (!ok) return; try { - await syncVirtualTargetsToBackend(targets, session, true); + await syncVirtualTargetsToBackend(targets, sessionForStart, true); setSession(s => ({ ...s, running: true })); } catch { window.alert('가상투자 시작 설정 저장에 실패했습니다.'); diff --git a/frontend/src/utils/virtualTargetMutations.ts b/frontend/src/utils/virtualTargetMutations.ts index 625b1ae..d5c2bf4 100644 --- a/frontend/src/utils/virtualTargetMutations.ts +++ b/frontend/src/utils/virtualTargetMutations.ts @@ -1,11 +1,14 @@ -import { saveLiveStrategySettings } from './backendApi'; +import { loadStrategies, saveLiveStrategySettings } from './backendApi'; import { getVirtualTargetMaxCount } from '../hooks/useAppSettings'; import { isVirtualTargetAddAllowed, virtualTargetLimitMessage, } from '../utils/virtualTargetLimits'; import { syncVirtualTargetsToBackend } from './virtualLiveStrategySync'; -import { resolveVirtualTargetStrategyId } from './virtualTargetStrategy'; +import { + ensureDefaultGlobalStrategyId, + resolveVirtualTargetStrategyId, +} from './virtualTargetStrategy'; import { loadVirtualSession, loadVirtualTargets, @@ -41,11 +44,13 @@ export async function addVirtualTarget( } const session = loadVirtualSession(); + const strategies = await loadStrategies().catch(() => []); + const strategyId = ensureDefaultGlobalStrategyId(session.globalStrategyId, strategies); const en = meta.englishName ?? market.replace(/^KRW-/, ''); const item: VirtualTargetItem = { market, /** 신규 추가 시점의 상단 기본전략(템플릿)을 종목에 고정 */ - strategyId: session.globalStrategyId, + strategyId, koreanName: meta.koreanName, englishName: en, }; @@ -55,7 +60,7 @@ export async function addVirtualTarget( await saveLiveStrategySettings({ market, - strategyId: session.globalStrategyId, + strategyId, isLiveCheck: session.running, isPinned: false, executionType: session.executionType, @@ -64,7 +69,7 @@ export async function addVirtualTarget( skipGlobalTemplate: true, }); - if (session.running && session.globalStrategyId) { + if (session.running && strategyId) { await syncVirtualTargetsToBackend(next, session, true); } diff --git a/frontend/src/utils/virtualTargetStrategy.ts b/frontend/src/utils/virtualTargetStrategy.ts index 991dc18..0d259e6 100644 --- a/frontend/src/utils/virtualTargetStrategy.ts +++ b/frontend/src/utils/virtualTargetStrategy.ts @@ -1,4 +1,4 @@ -import type { VirtualTargetItem } from './virtualTradingStorage'; +import type { VirtualTargetItem, VirtualSessionConfig } from './virtualTradingStorage'; /** 종목 전략 셀렉트 — 기본(전역) 전략 옵션 값 */ export const VIRTUAL_DEFAULT_STRATEGY_VALUE = '__default__'; @@ -49,6 +49,53 @@ export function parseTargetStrategySelectValue(value: string): number | null { return Number.isFinite(n) ? n : null; } +/** 전략 목록 첫 항목 ID (가상매매 기본 전략) */ +export function pickDefaultGlobalStrategyId( + strategies: Array<{ id?: number | null }>, +): number | null { + for (const s of strategies) { + const id = s.id; + if (id != null && id > 0) return id; + } + return null; +} + +/** 저장된 globalStrategyId 가 유효하면 유지, 아니면 목록 첫 항목 */ +export function ensureDefaultGlobalStrategyId( + globalStrategyId: number | null, + strategies: Array<{ id?: number | null }>, +): number | null { + const validIds = new Set( + strategies.map(s => s.id).filter((id): id is number => id != null && id > 0), + ); + if (globalStrategyId != null && validIds.has(globalStrategyId)) { + return globalStrategyId; + } + return pickDefaultGlobalStrategyId(strategies); +} + +/** 신규 종목 추가·세션 초기화 — 상단 기본전략 (없으면 목록 첫 항목) */ +export function resolveStrategyIdForNewTarget( + globalStrategyId: number | null, + strategies: Array<{ id?: number | null }>, +): number | null { + return ensureDefaultGlobalStrategyId(globalStrategyId, strategies); +} + +/** 전략 목록 로드 후 — 상단 기본전략을 첫 항목으로 자동 설정 */ +export function applyDefaultGlobalStrategyToSession( + session: VirtualSessionConfig, + strategies: Array<{ id?: number | null }>, + appLiveStrategyId: number | null = null, +): VirtualSessionConfig { + let gid = ensureDefaultGlobalStrategyId(session.globalStrategyId, strategies); + if (gid == null && appLiveStrategyId != null && appLiveStrategyId > 0) { + gid = appLiveStrategyId; + } + if (gid === session.globalStrategyId) return session; + return { ...session, globalStrategyId: gid }; +} + /** 종목 셀렉트 — 기본 전략(전역) 옵션 라벨 (신규 추가 시 적용되는 템플릿) */ export function defaultStrategyOptionLabel( globalStrategyId: number | null,