diff --git a/backend/src/main/java/com/goldenchart/dto/LiveStrategySettingsDto.java b/backend/src/main/java/com/goldenchart/dto/LiveStrategySettingsDto.java index 107769b..335fdaf 100644 --- a/backend/src/main/java/com/goldenchart/dto/LiveStrategySettingsDto.java +++ b/backend/src/main/java/com/goldenchart/dto/LiveStrategySettingsDto.java @@ -44,4 +44,10 @@ public class LiveStrategySettingsDto { /** 매도 시그널 포지션 종속성 모드: "LONG_ONLY" | "SIGNAL_ONLY" */ @Builder.Default private String positionMode = "LONG_ONLY"; + + /** true면 관심종목 전체 동기화 생략 (가상투자 per-market 저장) */ + private Boolean skipWatchlistSync; + + /** true면 gc_app_settings 전역 템플릿 갱신 생략 */ + private Boolean skipGlobalTemplate; } diff --git a/backend/src/main/java/com/goldenchart/service/LiveStrategySettingsService.java b/backend/src/main/java/com/goldenchart/service/LiveStrategySettingsService.java index 7d7547d..a876778 100644 --- a/backend/src/main/java/com/goldenchart/service/LiveStrategySettingsService.java +++ b/backend/src/main/java/com/goldenchart/service/LiveStrategySettingsService.java @@ -117,7 +117,9 @@ public class LiveStrategySettingsService { public LiveStrategySettingsDto save(Long userId, String deviceId, LiveStrategySettingsDto dto) { - persistGlobalTemplate(userId, deviceId, dto); + if (!Boolean.TRUE.equals(dto.getSkipGlobalTemplate())) { + persistGlobalTemplate(userId, deviceId, dto); + } if (dto.getMarket() != null && !dto.getMarket().isBlank()) { enrichStrategyTimeframes(dto); @@ -129,7 +131,9 @@ public class LiveStrategySettingsService { } } - syncAllWatchlistMarkets(userId, deviceId, dto); + if (!Boolean.TRUE.equals(dto.getSkipWatchlistSync())) { + syncAllWatchlistMarkets(userId, deviceId, dto); + } String market = dto.getMarket() != null ? dto.getMarket() : "KRW-BTC"; log.info("[LiveStrategySettings] global saved + watchlist sync, template market={}", market); GcAppSettings app = appSettingsService.getEntity(userId, deviceId); diff --git a/backend/src/main/java/com/goldenchart/service/PaperTradingService.java b/backend/src/main/java/com/goldenchart/service/PaperTradingService.java index 59e60e4..9ed667c 100644 --- a/backend/src/main/java/com/goldenchart/service/PaperTradingService.java +++ b/backend/src/main/java/com/goldenchart/service/PaperTradingService.java @@ -5,6 +5,7 @@ import com.goldenchart.entity.GcAppSettings; import com.goldenchart.entity.GcPaperAccount; import com.goldenchart.entity.GcPaperPosition; import com.goldenchart.entity.GcPaperTrade; +import com.goldenchart.repository.GcLiveStrategySettingsRepository; import com.goldenchart.repository.GcPaperAccountRepository; import com.goldenchart.repository.GcPaperPositionRepository; import com.goldenchart.repository.GcPaperTradeRepository; @@ -36,6 +37,7 @@ public class PaperTradingService { private final GcPaperPositionRepository positionRepo; private final GcPaperTradeRepository tradeRepo; private final AppSettingsService appSettingsService; + private final GcLiveStrategySettingsRepository liveSettingsRepo; // ── 조회 ───────────────────────────────────────────────────────────────── @@ -150,7 +152,9 @@ public class PaperTradingService { GcAppSettings app = appSettingsService.getEntity(userId, deviceId); if (!Boolean.TRUE.equals(app.getPaperTradingEnabled())) return; if (!Boolean.TRUE.equals(app.getPaperAutoTradeEnabled())) return; - if (!Boolean.TRUE.equals(app.getLiveStrategyCheck())) return; + boolean marketActive = liveSettingsRepo.findActiveByMarket(market).stream() + .anyMatch(s -> Boolean.TRUE.equals(s.getIsLiveCheck())); + if (!Boolean.TRUE.equals(app.getLiveStrategyCheck()) && !marketActive) return; try { GcPaperAccount account = getOrCreateAccount(userId, deviceId, app); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index cdcb7e3..d624c01 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -84,6 +84,9 @@ import { type LiveStrategySettingsDto, type BacktestSignal, } from './utils/backendApi'; +import { useVirtualLiveStrategy } from './hooks/useVirtualLiveStrategy'; +import { VIRTUAL_SESSION_CHANGED_EVENT } from './utils/virtualTradingStorage'; +import { notifyPaperTradesChanged } from './utils/paperTradeEvents'; import { useIsMobile } from './hooks/useMediaQuery'; import MobileChartDock from './components/MobileChartDock'; import LoginModal from './components/LoginModal'; @@ -583,6 +586,11 @@ function App() { const [paperCoinQty, setPaperCoinQty] = useState(0); const [paperRefreshKey, setPaperRefreshKey] = useState(0); + const handlePaperOrderFilled = useCallback(() => { + setPaperRefreshKey(k => k + 1); + notifyPaperTradesChanged(); + }, []); + const refreshPaperAccount = useCallback(async (forMarket?: string) => { const summary = await loadPaperSummary(); if (!summary) return; @@ -698,55 +706,104 @@ function App() { syncBacktestMarkersRef.current = syncBacktestMarkers; - // ── 실시간 전략 체크 (전역 설정 + DB 관심종목 = 체크 대상) ───────────────── + // ── 실시간 전략 체크 (가상투자 세션 우선, 없으면 전역+관심종목) ───────────── const [showLivePanel, setShowLivePanel] = useState(false); const [liveStrategies, setLiveStrategies] = useState<{id:number;name:string}[]>([]); const [liveStrategyCandleTypes, setLiveStrategyCandleTypes] = useState(); + const [activeLiveSettings, setActiveLiveSettings] = useState([]); + const { session: virtualSession, isVirtualActive, monitoredMarkets: virtualMonitoredMarkets } = useVirtualLiveStrategy(); + + const refreshActiveLiveSettings = useCallback(() => { + loadActiveLiveStrategySettings() + .then(setActiveLiveSettings) + .catch(() => setActiveLiveSettings([])); + }, []); + + useEffect(() => { + refreshActiveLiveSettings(); + }, [refreshActiveLiveSettings, isVirtualActive, virtualMonitoredMarkets.join(','), virtualSession.executionType, virtualSession.positionMode]); + + useEffect(() => { + const onVirtualChanged = () => refreshActiveLiveSettings(); + window.addEventListener(VIRTUAL_SESSION_CHANGED_EVENT, onVirtualChanged); + return () => window.removeEventListener(VIRTUAL_SESSION_CHANGED_EVENT, onVirtualChanged); + }, [refreshActiveLiveSettings]); useEffect(() => { if (!appSettingsLoaded) return; + if (isVirtualActive) { + const active = activeLiveSettings.find(s => s.market === symbol); + setLiveStrategyCandleTypes(active?.strategyCandleTypes); + return; + } loadLiveStrategySettings(symbol) .then(s => setLiveStrategyCandleTypes(s.strategyCandleTypes)) .catch(() => { /* optional */ }); - }, [symbol, appSettingsLoaded, appDefaults.liveStrategyId]); + }, [symbol, appSettingsLoaded, appDefaults.liveStrategyId, isVirtualActive, activeLiveSettings]); - const liveStrategySettings: LiveStrategySettingsDto = useMemo(() => ({ - market: symbol, - strategyId: appDefaults.liveStrategyId ?? null, - isLiveCheck: appDefaults.liveStrategyCheck ?? false, - executionType: appDefaults.liveExecutionType ?? 'CANDLE_CLOSE', - positionMode: appDefaults.livePositionMode ?? 'LONG_ONLY', - strategyCandleTypes: liveStrategyCandleTypes, - }), [symbol, appDefaults, liveStrategyCandleTypes]); + const liveStrategySettings: LiveStrategySettingsDto = useMemo(() => { + if (isVirtualActive) { + const active = activeLiveSettings.find(s => s.market === symbol); + return { + market: symbol, + strategyId: active?.strategyId ?? virtualSession.globalStrategyId, + isLiveCheck: true, + executionType: active?.executionType ?? virtualSession.executionType, + positionMode: active?.positionMode ?? virtualSession.positionMode, + strategyCandleTypes: active?.strategyCandleTypes ?? liveStrategyCandleTypes, + }; + } + return { + market: symbol, + strategyId: appDefaults.liveStrategyId ?? null, + isLiveCheck: appDefaults.liveStrategyCheck ?? false, + executionType: appDefaults.liveExecutionType ?? 'CANDLE_CLOSE', + positionMode: appDefaults.livePositionMode ?? 'LONG_ONLY', + strategyCandleTypes: liveStrategyCandleTypes, + }; + }, [isVirtualActive, activeLiveSettings, symbol, virtualSession, appDefaults, liveStrategyCandleTypes]); - /** 실시간 체크 ON + 전략 선택 시 STOMP 구독 대상 (관심종목 + 현재 차트 종목) */ + /** STOMP 구독 대상 — 가상투자 실행 중이면 투자대상 종목, 아니면 관심종목 */ const monitoredMarkets = useMemo(() => { + if (isVirtualActive) return virtualMonitoredMarkets; if (!appDefaults.liveStrategyCheck || !appDefaults.liveStrategyId) return []; const set = new Set(watchlist); if (symbol) set.add(symbol); return [...set]; - }, [watchlist, symbol, appDefaults.liveStrategyCheck, appDefaults.liveStrategyId]); + }, [isVirtualActive, virtualMonitoredMarkets, watchlist, symbol, appDefaults.liveStrategyCheck, appDefaults.liveStrategyId]); const [marketSubscriptions, setMarketSubscriptions] = useState<{ market: string; candleType: string }[]>([]); - /** STOMP 구독 목록 — API 로드 전에도 관심종목 × 1m 폴백 구독 */ const liveStompSubscriptions = useMemo(() => { - if (!appDefaults.liveStrategyCheck || !appDefaults.liveStrategyId) return []; + if (monitoredMarkets.length === 0) return []; + const filtered = activeLiveSettings.filter(s => monitoredMarkets.includes(s.market)); + if (filtered.length > 0) return expandLiveStrategySubscriptions(filtered); if (marketSubscriptions.length > 0) return marketSubscriptions; return monitoredMarkets.map(m => ({ market: m, candleType: '1m' })); - }, [appDefaults.liveStrategyCheck, appDefaults.liveStrategyId, marketSubscriptions, monitoredMarkets]); + }, [monitoredMarkets, activeLiveSettings, marketSubscriptions]); useEffect(() => { + if (monitoredMarkets.length === 0) { + setMarketSubscriptions([]); + return; + } + if (isVirtualActive) { + setMarketSubscriptions(expandLiveStrategySubscriptions( + activeLiveSettings.filter(s => monitoredMarkets.includes(s.market)), + )); + return; + } if (!appDefaults.liveStrategyCheck) { setMarketSubscriptions([]); return; } loadActiveLiveStrategySettings() .then(list => setMarketSubscriptions(expandLiveStrategySubscriptions(list))) - .catch(() => { /* liveStompSubscriptions 가 monitoredMarkets 폴백 사용 */ }); - }, [appDefaults.liveStrategyCheck, appDefaults.liveStrategyId, monitoredMarkets.join(',')]); + .catch(() => { /* liveStompSubscriptions 폴백 */ }); + }, [isVirtualActive, appDefaults.liveStrategyCheck, appDefaults.liveStrategyId, monitoredMarkets.join(','), activeLiveSettings]); const handleLiveSettingsChange = useCallback((saved: LiveStrategySettingsDto) => { + if (isVirtualActive) return; saveAppDef({ liveStrategyCheck: saved.isLiveCheck, liveStrategyId: saved.strategyId ?? undefined, @@ -757,11 +814,9 @@ function App() { setLiveStrategyCandleTypes(saved.strategyCandleTypes); } if (appDefaults.liveStrategyCheck && saved.isLiveCheck) { - loadActiveLiveStrategySettings() - .then(list => setMarketSubscriptions(expandLiveStrategySubscriptions(list))) - .catch(() => { /* 폴백 유지 */ }); + refreshActiveLiveSettings(); } - }, [saveAppDef, appDefaults.liveStrategyCheck]); + }, [saveAppDef, appDefaults.liveStrategyCheck, isVirtualActive, refreshActiveLiveSettings]); const prevFavoritesRef = useRef(getFavorites()); @@ -808,10 +863,21 @@ function App() { return () => { cancelled = true; }; }, [dbLoaded]); - const liveStrategyName = useMemo( - () => liveStrategies.find(s => s.id === appDefaults.liveStrategyId)?.name ?? null, - [liveStrategies, appDefaults.liveStrategyId], - ); + const liveStrategyName = useMemo(() => { + const sid = liveStrategySettings.strategyId; + return sid != null ? liveStrategies.find(s => s.id === sid)?.name ?? null : null; + }, [liveStrategies, liveStrategySettings.strategyId]); + + const liveStrategyNameByMarket = useMemo(() => { + const map: Record = {}; + for (const s of activeLiveSettings) { + if (s.strategyId != null) { + const name = liveStrategies.find(st => st.id === s.strategyId)?.name; + if (name) map[s.market] = name; + } + } + return map; + }, [activeLiveSettings, liveStrategies]); const goToMarketChart = useCallback((market: string) => { if (layoutDef.count > 1) { @@ -1477,9 +1543,13 @@ function App() { activeMarket={symbol} enabled={monitoredMarkets.length > 0} popupEnabled={appDefaults.tradeAlertPopup ?? true} - strategyId={appDefaults.liveStrategyId ?? undefined} - executionType={appDefaults.liveExecutionType ?? 'CANDLE_CLOSE'} + strategyId={liveStrategySettings.strategyId ?? undefined} + executionType={liveStrategySettings.executionType ?? 'CANDLE_CLOSE'} strategyName={liveStrategyName} + strategyNameByMarket={liveStrategyNameByMarket} + executionTypeByMarket={Object.fromEntries( + activeLiveSettings.map(s => [s.market, s.executionType ?? 'CANDLE_CLOSE']), + )} chartMarkersActive={menuPage === 'chart'} onMarkersChange={markers => { managerRef.current?.setLiveStrategyMarkers(markers, appDefaults.btShowPrice); @@ -1537,7 +1607,7 @@ function App() { defaultMarket={symbol} paperTradingEnabled={paperTradingEnabled} paperAutoTradeEnabled={paperAutoTradeEnabled} - onPaperOrderFilled={() => setPaperRefreshKey(k => k + 1)} + onPaperOrderFilled={handlePaperOrderFilled} onGoChart={m => { goToMarketChart(m); setMenuPage('chart'); @@ -1552,7 +1622,9 @@ function App() { defaultMarket={symbol} paperTradingEnabled={paperTradingEnabled} paperAutoTradeEnabled={paperAutoTradeEnabled} - onPaperOrderFilled={() => setPaperRefreshKey(k => k + 1)} + paperAutoTradeBudgetPct={appDefaults.paperAutoTradeBudgetPct} + onPaperAutoTradeEnabled={v => saveAppDef({ paperAutoTradeEnabled: v })} + onPaperOrderFilled={handlePaperOrderFilled} /> )} @@ -1596,6 +1668,7 @@ function App() { onLiveSettingsChange={handleLiveSettingsChange} watchlistCount={watchlist.length} monitoredMarkets={monitoredMarkets} + virtualDriven={isVirtualActive} getIndicatorParams={getIndicatorParams} saveIndicatorParams={saveIndicatorParams} getIndicatorVisual={getIndicatorVisual} @@ -2080,8 +2153,9 @@ function App() { market={symbol} strategies={liveStrategies} settings={liveStrategySettings} - watchlistCount={watchlist.length} + watchlistCount={isVirtualActive ? monitoredMarkets.length : watchlist.length} monitoredMarkets={monitoredMarkets} + virtualDriven={isVirtualActive} onClearMarkers={() => { clearLiveMarkers(); managerRef.current?.clearLiveStrategyMarkers(); }} onSettingsChange={handleLiveSettingsChange} onClose={() => setShowLivePanel(false)} @@ -2303,7 +2377,7 @@ function App() { paperAutoTradeEnabled={paperAutoTradeEnabled} onPaperOrderFilled={() => { refreshPaperAccount(symbol); - setPaperRefreshKey(k => k + 1); + handlePaperOrderFilled(); }} /> @@ -2407,7 +2481,7 @@ function App() { tradeAlertPopupGridCols={appDefaults.tradeAlertPopupGridCols} onOrderDone={() => { refreshPaperAccount(symbol); - setPaperRefreshKey(k => k + 1); + handlePaperOrderFilled(); }} /> diff --git a/frontend/src/components/BacktestHistoryPage.tsx b/frontend/src/components/BacktestHistoryPage.tsx index 9690445..1cbdb52 100644 --- a/frontend/src/components/BacktestHistoryPage.tsx +++ b/frontend/src/components/BacktestHistoryPage.tsx @@ -20,6 +20,7 @@ import BacktestAnalysisChart from './backtest/BacktestAnalysisChart'; import BacktestKpiPanel from './backtest/BacktestKpiPanel'; import BacktestTradeHistoryCard from './backtest/BacktestTradeHistoryCard'; import { buildLiveExecutionItems, paperTradesToSignals, type LiveExecutionItem } from '../utils/liveExecutionGroups'; +import { PAPER_TRADES_CHANGED_EVENT } from '../utils/paperTradeEvents'; import { readStoredSize, storeSize, @@ -91,6 +92,18 @@ export function BacktestHistoryPage({ theme = 'dark' }: Props) { window.addEventListener('pointerup', onUp); }, []); + const refreshLiveTrades = useCallback(async () => { + const [trades, summary, strategies] = await Promise.all([ + loadPaperTrades(), + loadPaperSummary(), + loadStrategies(), + ]); + const strategyNames = Object.fromEntries(strategies.map(s => [s.id, s.name])); + const live = buildLiveExecutionItems(trades, summary, strategyNames); + setLiveItems(live); + setSelectedLive(prev => (prev && live.some(x => x.id === prev.id) ? prev : live[0] ?? null)); + }, []); + const fetchAll = useCallback(async () => { setLoading(true); const [list, trades, summary, strategies] = await Promise.all([ @@ -110,6 +123,12 @@ export function BacktestHistoryPage({ theme = 'dark' }: Props) { useEffect(() => { void fetchAll(); }, [fetchAll]); + useEffect(() => { + const onTradesChanged = () => { void refreshLiveTrades(); }; + window.addEventListener(PAPER_TRADES_CHANGED_EVENT, onTradesChanged); + return () => window.removeEventListener(PAPER_TRADES_CHANGED_EVENT, onTradesChanged); + }, [refreshLiveTrades]); + const deleteAll = useCallback(async () => { if (records.length === 0) return; if (!window.confirm(`전체 ${records.length}개 백테스팅 결과를 삭제하시겠습니까?`)) return; diff --git a/frontend/src/components/LiveSignalNotifier.tsx b/frontend/src/components/LiveSignalNotifier.tsx index 7c71369..8284a4b 100644 --- a/frontend/src/components/LiveSignalNotifier.tsx +++ b/frontend/src/components/LiveSignalNotifier.tsx @@ -19,6 +19,8 @@ interface Props { strategyId: number | undefined; executionType: string; strategyName: string | null; + strategyNameByMarket?: Record; + executionTypeByMarket?: Record; onMarkersChange: (markers: LiveMarker[]) => void; /** 차트 화면일 때만 마커를 차트에 반영 */ chartMarkersActive: boolean; @@ -32,6 +34,8 @@ export const LiveSignalNotifier = forwardRef(fu popupEnabled: _popupEnabled, strategyName, executionType, + strategyNameByMarket, + executionTypeByMarket, onMarkersChange, chartMarkersActive, }, ref) { @@ -59,8 +63,8 @@ export const LiveSignalNotifier = forwardRef(fu signalType: marker.signal, price: marker.price, candleTime: marker.time, - strategyName, - executionType, + strategyName: strategyNameByMarket?.[marker.market] ?? strategyName, + executionType: executionTypeByMarket?.[marker.market] ?? executionType, candleType: marketSubscriptions?.find(s => s.market === marker.market)?.candleType ?? '1m', }); // STOMP만 수신·DB 저장 누락 시 배지 동기화 diff --git a/frontend/src/components/LiveStrategyPanel.tsx b/frontend/src/components/LiveStrategyPanel.tsx index 950b80f..a4c27da 100644 --- a/frontend/src/components/LiveStrategyPanel.tsx +++ b/frontend/src/components/LiveStrategyPanel.tsx @@ -26,6 +26,8 @@ interface LiveStrategyPanelProps { settings: LiveStrategySettingsDto; watchlistCount?: number; monitoredMarkets?: string[]; + /** 가상투자 세션에서 제어 — 차트 팝업은 읽기 전용 */ + virtualDriven?: boolean; onClearMarkers?: () => void; onClose?: () => void; onSettingsChange?: (settings: LiveStrategySettingsDto) => void; @@ -47,7 +49,7 @@ const CandleIcon: React.FC<{ color: string }> = ({ color }) => ( const LiveStrategyPanel: React.FC = ({ theme, market, strategies, settings, - watchlistCount = 0, monitoredMarkets = [], + watchlistCount = 0, monitoredMarkets = [], virtualDriven = false, onClearMarkers, onClose, onSettingsChange, }) => { const [saving, setSaving] = useState(false); @@ -65,6 +67,7 @@ const LiveStrategyPanel: React.FC = ({ }); const persist = useCallback(async (patch: Partial) => { + if (virtualDriven) return; const next: LiveStrategySettingsDto = { ...settings, ...patch, market }; const prev = settings; onSettingsChange?.(next); @@ -84,7 +87,7 @@ const LiveStrategyPanel: React.FC = ({ } finally { setSaving(false); } - }, [settings, market, onClearMarkers, onSettingsChange]); + }, [settings, market, onClearMarkers, onSettingsChange, virtualDriven]); const isOn = settings.isLiveCheck; const execType = settings.executionType; @@ -190,6 +193,7 @@ const LiveStrategyPanel: React.FC = ({ persist({ isLiveCheck: e.target.checked })} /> @@ -200,7 +204,7 @@ const LiveStrategyPanel: React.FC = ({ 전략 선택 onTradingMode?.(e.target.value)}> - + - - + + - - {paperAutoTradeEnabled ? 'ON' : 'OFF'} + {paperAutoTradeEnabled ? 'ON (타이틀바)' : 'OFF (타이틀바)'} = ({ /> {!paperAutoTradeEnabled && ( -

자동매매가 꺼져 있으면 전략 시그널로는 주문되지 않습니다. 수동 주문만 모의 계좌에 반영됩니다.

+

자동매매 ON/OFF는 가상투자 화면 타이틀바에서 변경하세요. OFF 상태에서는 Match·시그널로 주문되지 않습니다.

)}
@@ -722,6 +716,7 @@ interface StrategyPanelProps { liveSettings?: LiveStrategySettingsDto; watchlistCount?: number; monitoredMarkets?: string[]; + virtualDriven?: boolean; onClearLiveMarkers?: () => void; tradeAlertPopup?: boolean; onTradeAlertPopup?: (v: boolean) => void; @@ -734,6 +729,7 @@ const StrategyPanel: React.FC = ({ liveSettings: liveSettingsProp, watchlistCount = 0, monitoredMarkets = [], + virtualDriven = false, onClearLiveMarkers, tradeAlertPopup = true, onTradeAlertPopup, @@ -763,6 +759,7 @@ const StrategyPanel: React.FC = ({ }, [liveSettingsProp]); const persistLive = useCallback(async (patch: Partial) => { + if (virtualDriven) return; const next: LiveStrategySettingsDto = { ...liveSettings, ...patch, market: liveMarket }; setLiveSaving(true); try { @@ -775,7 +772,7 @@ const StrategyPanel: React.FC = ({ } finally { setLiveSaving(false); } - }, [liveSettings, liveMarket, onClearLiveMarkers, onLiveSettingsChange]); + }, [liveSettings, liveMarket, onClearLiveMarkers, onLiveSettingsChange, virtualDriven]); const isOn = liveSettings.isLiveCheck; const execType = liveSettings.executionType; @@ -786,9 +783,16 @@ const StrategyPanel: React.FC = ({ <> {/* ── 실시간 전략 체크 ─────────────────────────────────────────────── */} + {virtualDriven && ( +

+ 가상투자 세션이 실행 중입니다. 전략·실행방식·시그널 모드는 가상투자 화면에서 변경하세요. +

+ )}
@@ -810,6 +814,7 @@ const StrategyPanel: React.FC = ({ persistLive({ isLiveCheck: e.target.checked })} /> @@ -830,7 +835,7 @@ const StrategyPanel: React.FC = ({ > void persist({ isLiveCheck: e.target.checked })} - /> - - - {selected && ( +

+ 실시간 전략·체크 대상 종목은 가상투자 화면에서 설정합니다. + {virtualRunning ? ' (세션 실행 중)' : ''} +

+ {settings?.isLiveCheck && selected && (

- 적용: {selected.name} - {settings?.strategyCandleTypes?.length - ? ` · ${settings.strategyCandleTypes.join(', ')}` - : ''} + {market.replace(/^KRW-/, '')}: {selected.name} · {execLabel}

)} -

- {paperAutoTradeEnabled ? '자동매매 ON' : '자동매매 OFF — 설정 탭에서 변경'} + {paperAutoTradeEnabled + ? '자동매매 ON — 가상투자 Match 시 가상 계좌 자동 체결' + : '자동매매 OFF — 가상투자 타이틀바에서 변경'}

); diff --git a/frontend/src/components/paper/PaperTradeHistoryList.tsx b/frontend/src/components/paper/PaperTradeHistoryList.tsx new file mode 100644 index 0000000..4542789 --- /dev/null +++ b/frontend/src/components/paper/PaperTradeHistoryList.tsx @@ -0,0 +1,87 @@ +import React from 'react'; +import type { PaperTradeDto } from '../../utils/backendApi'; +import { fmtKrw } from '../TradeOrderPanel'; + +function coinCode(symbol: string): string { + return symbol.replace(/^KRW-/, ''); +} + +function fmtTradeTime(iso: string | null | undefined): string { + if (!iso) return '—'; + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return iso.slice(0, 19).replace('T', ' '); + const pad = (n: number) => String(n).padStart(2, '0'); + return `${pad(d.getMonth() + 1)}/${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`; +} + +function sourceLabel(source: string | null | undefined): string { + if (source === 'STRATEGY') return '자동'; + return '수동'; +} + +interface Props { + trades: PaperTradeDto[]; + strategyNames?: Record; + emptyText?: string; + className?: string; + onSelectMarket?: (market: string) => void; +} + +const PaperTradeHistoryList: React.FC = ({ + trades, + strategyNames = {}, + emptyText = '거래 내역이 없습니다.', + className = '', + onSelectMarket, +}) => ( +
+ {trades.length === 0 ? ( +

{emptyText}

+ ) : ( +
+ + + + + + + + + + + + + + {trades.map(t => { + const strategyName = t.strategyId != null + ? (strategyNames[t.strategyId] ?? `#${t.strategyId}`) + : null; + return ( + onSelectMarket(t.symbol) : undefined} + title={strategyName ? `전략: ${strategyName}` : undefined} + > + + + + + + + + + ); + })} + +
시간종목유형구분체결가수량금액
{fmtTradeTime(t.createdAt)}{coinCode(t.symbol)} + {t.side === 'BUY' ? '매수' : '매도'} + + {sourceLabel(t.source)} + {fmtKrw(t.price)}{t.quantity}{fmtKrw(t.netAmount)}
+
+ )} +
+); + +export default PaperTradeHistoryList; diff --git a/frontend/src/components/virtual/VirtualGridUnifiedHeader.tsx b/frontend/src/components/virtual/VirtualGridUnifiedHeader.tsx index 1a36890..318d542 100644 --- a/frontend/src/components/virtual/VirtualGridUnifiedHeader.tsx +++ b/frontend/src/components/virtual/VirtualGridUnifiedHeader.tsx @@ -3,7 +3,7 @@ import type { StrategyDto } from '../../utils/backendApi'; import type { VirtualSessionConfig, VirtualCardViewMode } from '../../utils/virtualTradingStorage'; import type { VirtualCardDisplayMode } from './VirtualTargetCard'; -interface Props { +interface SessionProps { session: Pick; strategies: StrategyDto[]; onStrategyChange: (strategyId: number | null) => void; @@ -11,15 +11,21 @@ interface Props { onPositionModeChange: (mode: VirtualSessionConfig['positionMode']) => void; onStart: () => void; onStop: () => void; - viewMode?: VirtualCardViewMode; - onViewModeChange?: (mode: VirtualCardViewMode) => void; - globalDisplayMode?: VirtualCardDisplayMode; - onGlobalDisplayModeChange?: (mode: VirtualCardDisplayMode) => void; - /** 우측 뷰 토글: full=신호·차트+요약·상세, display-only=요약·상세만, hidden=숨김 */ - viewControls?: 'full' | 'display-only' | 'hidden'; + paperAutoTradeEnabled?: boolean; + onPaperAutoTradeEnabled?: (enabled: boolean) => void; + paperTradingEnabled?: boolean; } -const VirtualGridUnifiedHeader: React.FC = ({ +interface ViewProps { + session: Pick; + viewMode: VirtualCardViewMode; + onViewModeChange: (mode: VirtualCardViewMode) => void; + globalDisplayMode?: VirtualCardDisplayMode; + onGlobalDisplayModeChange?: (mode: VirtualCardDisplayMode) => void; + showSignalChartToggle?: boolean; +} + +export const VirtualSessionHeaderControls: React.FC = ({ session, strategies, onStrategyChange, @@ -27,155 +33,187 @@ const VirtualGridUnifiedHeader: React.FC = ({ onPositionModeChange, onStart, onStop, - viewMode = 'summary', - onViewModeChange, - globalDisplayMode = 'signal', - onGlobalDisplayModeChange, - viewControls = 'full', + paperAutoTradeEnabled = false, + onPaperAutoTradeEnabled, + paperTradingEnabled = true, }) => ( -
- 종목별 매매시그널 일치 현황 +
+
+ 투자전략 + +
-
-
- 투자전략 - -
- -
- 실행방식 -
- - -
-
- -
- 시그널 모드 -
- - -
-
- -
+
+ 실행방식 +
- {viewControls !== 'hidden' && onViewModeChange && ( -
- {viewControls === 'full' && onGlobalDisplayModeChange && ( - <> -
-
- - -
-
-
); -export default VirtualGridUnifiedHeader; +export const VirtualViewHeaderControls: React.FC = ({ + session, + viewMode, + onViewModeChange, + globalDisplayMode = 'signal', + onGlobalDisplayModeChange, + showSignalChartToggle = true, +}) => ( +
+ {showSignalChartToggle && onGlobalDisplayModeChange && ( + <> +
+
+ + +
+
+
+); diff --git a/frontend/src/components/virtual/VirtualTargetGrid.tsx b/frontend/src/components/virtual/VirtualTargetGrid.tsx index b01e90f..1a0f62c 100644 --- a/frontend/src/components/virtual/VirtualTargetGrid.tsx +++ b/frontend/src/components/virtual/VirtualTargetGrid.tsx @@ -1,12 +1,10 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; import VirtualTargetCard, { type VirtualCardDisplayMode } from './VirtualTargetCard'; -import VirtualGridUnifiedHeader from './VirtualGridUnifiedHeader'; import VirtualTargetFocusView from './VirtualTargetFocusView'; import type { StrategyDto } from '../../utils/backendApi'; import type { Theme } from '../../types'; import type { ChartRealtimeSource } from '../../hooks/useChartRealtimeData'; import type { VirtualTargetItem, VirtualCardViewMode, VirtualSessionConfig } from '../../utils/virtualTradingStorage'; -import { loadVirtualCardViewMode, saveVirtualCardViewMode } from '../../utils/virtualTradingStorage'; import type { VirtualLiveStatus } from '../../hooks/useVirtualTargetLiveStatus'; import type { VirtualIndicatorSnapshot } from '../../hooks/useVirtualIndicatorSnapshots'; import type { TickerData } from '../../hooks/useMarketTicker'; @@ -16,11 +14,6 @@ interface Props { strategies: StrategyDto[]; snapshots: Record; session: Pick; - onStrategyChange: (strategyId: number | null) => void; - onExecutionTypeChange: (type: VirtualSessionConfig['executionType']) => void; - onPositionModeChange: (mode: VirtualSessionConfig['positionMode']) => void; - onStart: () => void; - onStop: () => void; onTargetStrategyChange: (market: string, strategyId: number | null) => void; liveStatusByMarket?: Record; lastTickAtByMarket?: Record; @@ -30,11 +23,13 @@ interface Props { chartRealtimeSource?: ChartRealtimeSource; chartSeriesPriceLabels?: boolean; tickers?: Map; + viewMode: VirtualCardViewMode; + globalDisplayMode: VirtualCardDisplayMode; + onFocusMarketChange?: (market: string | null) => void; } const VirtualTargetGrid: React.FC = ({ targets, strategies, snapshots, session, - onStrategyChange, onExecutionTypeChange, onPositionModeChange, onStart, onStop, onTargetStrategyChange, liveStatusByMarket = {}, lastTickAtByMarket = {}, selectedMarket, onSelectMarket, @@ -42,9 +37,10 @@ const VirtualTargetGrid: React.FC = ({ chartRealtimeSource = 'BACKEND_STOMP', chartSeriesPriceLabels = true, tickers, + viewMode, + globalDisplayMode, + onFocusMarketChange, }) => { - const [viewMode, setViewMode] = useState(() => loadVirtualCardViewMode()); - const [globalDisplayMode, setGlobalDisplayMode] = useState('signal'); const [cardDisplayOverrides, setCardDisplayOverrides] = useState>({}); const [focusMarket, setFocusMarket] = useState(null); @@ -53,14 +49,14 @@ const VirtualTargetGrid: React.FC = ({ useEffect(() => { if (focusMarket && !activeMarkets.has(focusMarket)) { setFocusMarket(null); + onFocusMarketChange?.(null); } - }, [activeMarkets, focusMarket]); + }, [activeMarkets, focusMarket, onFocusMarketChange]); useEffect(() => { - saveVirtualCardViewMode(viewMode); - }, [viewMode]); + onFocusMarketChange?.(focusMarket); + }, [focusMarket, onFocusMarketChange]); - /** 제거된 종목 override 정리 */ useEffect(() => { setCardDisplayOverrides(prev => { const next: Record = {}; @@ -73,10 +69,9 @@ const VirtualTargetGrid: React.FC = ({ }); }, [activeMarkets]); - const handleGlobalDisplayMode = useCallback((mode: VirtualCardDisplayMode) => { - setGlobalDisplayMode(mode); + useEffect(() => { setCardDisplayOverrides({}); - }, []); + }, [globalDisplayMode]); const handleCardDisplayMode = useCallback((market: string, mode: VirtualCardDisplayMode) => { setCardDisplayOverrides(prev => ({ ...prev, [market]: mode })); @@ -92,27 +87,9 @@ const VirtualTargetGrid: React.FC = ({ [focusMarket, targets], ); - const renderUnifiedHeader = (viewControls: 'full' | 'display-only' | 'hidden') => ( - - ); - if (targets.length === 0) { return (
- {renderUnifiedHeader('hidden')}

좌측에서 투자대상 종목을 추가하면 카드가 표시됩니다.

@@ -122,7 +99,6 @@ const VirtualTargetGrid: React.FC = ({ return (
- {renderUnifiedHeader(focusMarket ? 'display-only' : 'full')} {focusMarket && focusTarget ? ( ; + tickers?: Map; + paperAutoTradeBudgetPct: number; + positions?: PaperSummaryDto['positions']; + onFilled?: () => void; +} + +export function useVirtualAutoTrade({ + enabled, + session, + targets, + snapshots, + tickers, + paperAutoTradeBudgetPct, + positions = [], + onFilled, +}: Options): void { + const prevTimingRef = useRef>({}); + const inflightRef = useRef>(new Set()); + const lastExecRef = useRef>({}); + + useEffect(() => { + if (!enabled || !session.running) { + prevTimingRef.current = {}; + return; + } + + for (const target of targets) { + const snap = snapshots[target.market]; + if (!snap?.rows?.length) continue; + + const metrics = buildConditionMetrics(snap.rows); + const timing = resolveVirtualTradeTiming(metrics); + const prev = prevTimingRef.current[target.market] ?? 'neutral'; + prevTimingRef.current[target.market] = timing; + + if (timing !== 'buy' && timing !== 'sell') continue; + if (timing === prev) continue; + + const price = coerceFiniteNumber(tickers?.get(target.market)?.tradePrice); + if (price == null || price <= 0) continue; + + const heldQty = coerceFiniteNumber( + positions.find(p => p.symbol === target.market)?.quantity, + ) ?? 0; + + if (session.positionMode === 'LONG_ONLY') { + if (timing === 'buy' && heldQty > 0) continue; + if (timing === 'sell' && heldQty <= 0) continue; + } + + const side = timing === 'buy' ? 'BUY' : 'SELL'; + const execKey = `${target.market}:${side}`; + if (inflightRef.current.has(execKey)) continue; + if (Date.now() - (lastExecRef.current[execKey] ?? 0) < EXEC_COOLDOWN_MS) continue; + + const strategyId = target.strategyId ?? session.globalStrategyId ?? null; + inflightRef.current.add(execKey); + + void placePaperOrder({ + market: target.market, + side, + orderKind: 'market', + price, + quantity: 0, + budgetPct: timing === 'buy' ? paperAutoTradeBudgetPct : 100, + source: 'STRATEGY', + strategyId, + }) + .then(trade => { + if (trade) { + lastExecRef.current[execKey] = Date.now(); + onFilled?.(); + } + }) + .catch(err => { + console.warn('[VirtualAutoTrade]', target.market, side, err); + }) + .finally(() => { + inflightRef.current.delete(execKey); + }); + } + }, [ + enabled, + session.running, + session.globalStrategyId, + session.positionMode, + targets, + snapshots, + tickers, + paperAutoTradeBudgetPct, + positions, + onFilled, + ]); +} diff --git a/frontend/src/hooks/useVirtualLiveStrategy.ts b/frontend/src/hooks/useVirtualLiveStrategy.ts new file mode 100644 index 0000000..4c21b87 --- /dev/null +++ b/frontend/src/hooks/useVirtualLiveStrategy.ts @@ -0,0 +1,39 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { + loadVirtualSession, + loadVirtualTargets, + VIRTUAL_SESSION_CHANGED_EVENT, + type VirtualSessionConfig, + type VirtualTargetItem, +} from '../utils/virtualTradingStorage'; + +export function useVirtualLiveStrategy() { + const [session, setSession] = useState(() => loadVirtualSession()); + const [targets, setTargets] = useState(() => loadVirtualTargets()); + + const refresh = useCallback(() => { + setSession(loadVirtualSession()); + setTargets(loadVirtualTargets()); + }, []); + + useEffect(() => { + window.addEventListener(VIRTUAL_SESSION_CHANGED_EVENT, refresh); + window.addEventListener('storage', refresh); + return () => { + window.removeEventListener(VIRTUAL_SESSION_CHANGED_EVENT, refresh); + window.removeEventListener('storage', refresh); + }; + }, [refresh]); + + const isVirtualActive = useMemo( + () => session.running && targets.length > 0 && session.globalStrategyId != null, + [session, targets], + ); + + const monitoredMarkets = useMemo( + () => (isVirtualActive ? targets.map(t => t.market) : []), + [isVirtualActive, targets], + ); + + return { session, targets, isVirtualActive, monitoredMarkets, refresh }; +} diff --git a/frontend/src/styles/builderPageShell.css b/frontend/src/styles/builderPageShell.css index 9711d23..967b6e2 100644 --- a/frontend/src/styles/builderPageShell.css +++ b/frontend/src/styles/builderPageShell.css @@ -38,6 +38,36 @@ .bps-header-actions { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } +/* 가상투자 — 타이틀바 3열 (좌측패널폭 | 세션설정 | 뷰옵션) */ +.bps-header--vtd { + display: grid; + grid-template-columns: + calc(var(--bps-left-width, 380px) + var(--bps-left-handle, 16px) + calc(var(--bps-left-open, 1) * var(--bps-splitter-w, 5px))) + minmax(0, 1fr) + auto; + align-items: center; + gap: 8px 12px; + padding: 8px 18px; + min-height: 48px; +} + +.bps-header--vtd .bps-header-left { + min-width: 0; + overflow: hidden; +} + +.bps-header--vtd .bps-header-center { + display: flex; + align-items: center; + min-width: 0; + justify-self: stretch; +} + +.bps-header--vtd .bps-header-actions { + justify-self: end; + flex-shrink: 0; +} + .bps-btn { border: 1px solid var(--se-border); background: var(--se-btn-bg); diff --git a/frontend/src/styles/paperDashboard.css b/frontend/src/styles/paperDashboard.css index 9bedc89..d29a1c5 100644 --- a/frontend/src/styles/paperDashboard.css +++ b/frontend/src/styles/paperDashboard.css @@ -827,6 +827,56 @@ .ptd-table--compact { font-size: 9px; } .ptd-table--compact th, .ptd-table--compact td { padding: 5px 6px; } + +.ptd-trade-history { + display: flex; + flex-direction: column; + min-height: 0; + height: 100%; +} + +.ptd-trade-history--fill { + flex: 1; + padding: 8px 10px; + box-sizing: border-box; +} + +.ptd-trade-history-empty { + margin: 12px 0; + text-align: center; +} + +.ptd-trade-history-scroll { + flex: 1; + min-height: 0; + overflow: auto; +} + +.ptd-table--trades th, +.ptd-table--trades td { + white-space: nowrap; +} + +.ptd-row--click { + cursor: pointer; +} + +.ptd-row--click:hover td { + background: color-mix(in srgb, var(--accent, #3f7ef5) 8%, transparent); +} + +.ptd-source--auto { + color: var(--accent, #3f7ef5); + font-weight: 600; +} + +.ptd-source--manual { + color: var(--se-text-muted, var(--text3)); +} + +.bps-page--vtd .ptd-right-body > .ptd-trade-history--fill { + height: 100%; +} .ptd-status { display: inline-block; padding: 2px 5px; diff --git a/frontend/src/styles/virtualTradingDashboard.css b/frontend/src/styles/virtualTradingDashboard.css index c2113ba..66c0c7b 100644 --- a/frontend/src/styles/virtualTradingDashboard.css +++ b/frontend/src/styles/virtualTradingDashboard.css @@ -1,5 +1,57 @@ /* 가상투자 대시보드 */ +/* 타이틀바 — 세션 설정 (중앙 그리드 시작선) */ +.vtd-header-session { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px 12px; + min-width: 0; +} + +.vtd-header-session .vtd-session-select { + min-width: 120px; + max-width: 160px; +} + +.vtd-header-session .vtd-session-actions { + margin-left: 0; +} + +/* 타이틀바 — 뷰 옵션 (우측) */ +.vtd-header-view { + display: flex; + align-items: center; + flex-wrap: wrap; + justify-content: flex-end; + gap: 0; +} + +.bps-header--vtd .vtd-grid-live { + font-size: 11px; + white-space: nowrap; +} + +.bps-header--vtd .vtd-grid-head-legend { + font-size: 10px; +} + +@media (max-width: 1280px) { + .bps-header--vtd { + grid-template-columns: 1fr; + gap: 8px; + } + + .bps-header--vtd .bps-header-center, + .bps-header--vtd .bps-header-actions { + justify-self: stretch; + } + + .vtd-header-view { + justify-content: flex-start; + } +} + .vtd-unified-head { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; @@ -465,7 +517,7 @@ .vtd-grid-wrap { display: flex; flex-direction: column; - gap: 8px; + gap: 0; flex: 1; min-height: 0; overflow: hidden; @@ -811,6 +863,11 @@ color: var(--accent, #3f7ef5); } +.vtd-view-toggle-btn:disabled { + opacity: 0.45; + cursor: not-allowed; +} + .vtd-grid-head-legend { display: flex; align-items: center; diff --git a/frontend/src/utils/backendApi.ts b/frontend/src/utils/backendApi.ts index 92a5a25..650ba3f 100644 --- a/frontend/src/utils/backendApi.ts +++ b/frontend/src/utils/backendApi.ts @@ -1019,6 +1019,10 @@ export interface LiveStrategySettingsDto { candleType?: string; /** 전략 조건 DSL에 포함된 평가 시간봉 (응답 전용) */ strategyCandleTypes?: string[]; + /** true면 관심종목 동기화 생략 (가상투자 per-market) */ + skipWatchlistSync?: boolean; + /** true면 gc_app_settings 전역 템플릿 갱신 생략 */ + skipGlobalTemplate?: boolean; } /** 실시간 전략 체크 설정 로드 */ diff --git a/frontend/src/utils/paperTradeEvents.ts b/frontend/src/utils/paperTradeEvents.ts new file mode 100644 index 0000000..9a1873f --- /dev/null +++ b/frontend/src/utils/paperTradeEvents.ts @@ -0,0 +1,6 @@ +/** 가상·모의 체결 후 백테스팅 실시간 매매 목록 등 갱신용 */ +export const PAPER_TRADES_CHANGED_EVENT = 'gc_paper_trades_changed'; + +export function notifyPaperTradesChanged(): void { + window.dispatchEvent(new CustomEvent(PAPER_TRADES_CHANGED_EVENT)); +} diff --git a/frontend/src/utils/permissions.ts b/frontend/src/utils/permissions.ts index 176c383..24ae3a6 100644 --- a/frontend/src/utils/permissions.ts +++ b/frontend/src/utils/permissions.ts @@ -41,7 +41,7 @@ export const MENU_LABELS: Record = { settings_indicators: '설정 · 보조지표', settings_backtest: '설정 · 백테스팅', settings_strategy: '설정 · 전략', - settings_paper: '설정 · 모의투자', + settings_paper: '설정 · 가상투자', settings_alert: '설정 · 알림', settings_network: '설정 · 네트워크', settings_admin: '설정 · 관리자', diff --git a/frontend/src/utils/virtualLiveStrategySync.ts b/frontend/src/utils/virtualLiveStrategySync.ts new file mode 100644 index 0000000..30af7ef --- /dev/null +++ b/frontend/src/utils/virtualLiveStrategySync.ts @@ -0,0 +1,54 @@ +import { saveLiveStrategySettings } from './backendApi'; +import type { VirtualSessionConfig, VirtualTargetItem } from './virtualTradingStorage'; + +/** 가상투자 대상 종목을 백엔드 live strategy 설정과 동기화 */ +export async function syncVirtualTargetsToBackend( + targets: VirtualTargetItem[], + session: VirtualSessionConfig, + isLiveCheck: boolean, +): Promise { + if (targets.length === 0) return; + + const shared = { + isLiveCheck, + executionType: session.executionType, + positionMode: session.positionMode, + skipWatchlistSync: true, + } as const; + + await Promise.all( + targets.map(t => + saveLiveStrategySettings({ + market: t.market, + strategyId: t.strategyId ?? session.globalStrategyId, + ...shared, + }), + ), + ); + + await saveLiveStrategySettings({ + market: targets[0].market, + strategyId: session.globalStrategyId, + ...shared, + }); +} + +/** 가상투자 중지 — 대상 종목 체크 해제 + 전역 liveStrategyCheck OFF */ +export async function stopVirtualLiveOnBackend( + targets: VirtualTargetItem[], + session: VirtualSessionConfig, +): Promise { + if (targets.length === 0) { + await saveLiveStrategySettings({ + market: 'KRW-BTC', + strategyId: session.globalStrategyId, + isLiveCheck: false, + executionType: session.executionType, + positionMode: session.positionMode, + skipWatchlistSync: true, + }); + return; + } + + await syncVirtualTargetsToBackend(targets, session, false); +} diff --git a/frontend/src/utils/virtualTradingStorage.ts b/frontend/src/utils/virtualTradingStorage.ts index a9ff7a1..24bb3f1 100644 --- a/frontend/src/utils/virtualTradingStorage.ts +++ b/frontend/src/utils/virtualTradingStorage.ts @@ -36,6 +36,7 @@ export function loadVirtualTargets(): VirtualTargetItem[] { export function saveVirtualTargets(items: VirtualTargetItem[]): void { try { localStorage.setItem(TARGETS_KEY, JSON.stringify(items)); + notifyVirtualSessionChanged(); } catch { /* ignore */ } } @@ -58,9 +59,16 @@ export function loadVirtualSession(): VirtualSessionConfig { export function saveVirtualSession(cfg: VirtualSessionConfig): void { try { localStorage.setItem(SESSION_KEY, JSON.stringify(cfg)); + notifyVirtualSessionChanged(); } catch { /* ignore */ } } +export const VIRTUAL_SESSION_CHANGED_EVENT = 'gc_virtual_session_changed'; + +export function notifyVirtualSessionChanged(): void { + window.dispatchEvent(new CustomEvent(VIRTUAL_SESSION_CHANGED_EVENT)); +} + function defaultSession(): VirtualSessionConfig { return { globalStrategyId: null,