From b294c2be5deb10a00b07f8bc4ee8ca4109ce607c Mon Sep 17 00:00:00 2001 From: Macbook Date: Sun, 7 Jun 2026 19:06:59 +0900 Subject: [PATCH] =?UTF-8?q?=EA=B0=80=EC=83=81=EB=A7=A4=EB=A7=A4=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=20=EC=8B=A4=EC=8B=9C=EA=B0=84=20=EC=97=B0?= =?UTF-8?q?=EA=B2=B0=EC=95=88=EB=90=A8=20=EB=AC=B8=EC=A0=9C=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../screens/virtual/VirtualTradingScreen.tsx | 8 ++- .../src/components/VirtualTradingPage.tsx | 7 +-- .../components/virtual/VirtualTargetGrid.tsx | 22 ++++++-- .../src/hooks/useVirtualTargetLiveStatus.ts | 53 +++++++++++++++++-- frontend/src/hooks/useVirtualTradingCore.ts | 6 +-- 5 files changed, 82 insertions(+), 14 deletions(-) diff --git a/app/src/screens/virtual/VirtualTradingScreen.tsx b/app/src/screens/virtual/VirtualTradingScreen.tsx index 42c8941..dd682e0 100644 --- a/app/src/screens/virtual/VirtualTradingScreen.tsx +++ b/app/src/screens/virtual/VirtualTradingScreen.tsx @@ -2,6 +2,7 @@ import React, { useState } from 'react'; import { useAuth } from '../../contexts/AuthContext'; import { useAppSettings } from '../../hooks/useAppSettings'; import { useVirtualTradingCore } from '@frontend/hooks/useVirtualTradingCore'; +import { resolveVirtualLiveStatusFallback } from '@frontend/hooks/useVirtualTargetLiveStatus'; import { resolveVirtualTargetStrategyId } from '@frontend/utils/virtualTargetStrategy'; import { useNavigation } from '../../contexts/NavigationContext'; import BottomSheet from '../../components/BottomSheet'; @@ -195,7 +196,12 @@ export default function VirtualTradingScreen() { strategies={strategies} snapshot={snap} signalLoading={signalLoading} - liveStatus={liveStatusByMarket[t.market]} + liveStatus={resolveVirtualLiveStatusFallback( + t.market, + strategyId, + liveStatusByMarket, + session.running, + )} onDetail={() => goVirtualDetail(t.market)} onTrade={side => goVirtualTrade(t.market, side)} onTogglePin={() => void handleTogglePin(t.market)} diff --git a/frontend/src/components/VirtualTradingPage.tsx b/frontend/src/components/VirtualTradingPage.tsx index ed25f67..183a954 100644 --- a/frontend/src/components/VirtualTradingPage.tsx +++ b/frontend/src/components/VirtualTradingPage.tsx @@ -221,16 +221,17 @@ const VirtualTradingPage: React.FC = ({ }, [session.running, refreshPaperData]); const mergedLiveStatus = useMemo(() => { - if (!session.running) return liveStatusByMarket; const merged = { ...liveStatusByMarket }; const now = Date.now(); + /** 폴링 주기(3~5s) + 지연 여유 — 지표 스냅샷 갱신 시 STOMP 틱 없어도 실시간 표시 */ + const SNAP_LIVE_MS = 12_000; for (const [market, snap] of Object.entries(snapshots)) { - if (snap?.updatedAt && now - snap.updatedAt < 8000) { + if (snap?.updatedAt && now - snap.updatedAt < SNAP_LIVE_MS) { merged[market] = 'live'; } } return merged; - }, [liveStatusByMarket, snapshots, session.running]); + }, [liveStatusByMarket, snapshots]); const posQty = useMemo(() => { const p = summary?.positions?.find(x => x.symbol === selectedMarket); diff --git a/frontend/src/components/virtual/VirtualTargetGrid.tsx b/frontend/src/components/virtual/VirtualTargetGrid.tsx index 0a46717..3906672 100644 --- a/frontend/src/components/virtual/VirtualTargetGrid.tsx +++ b/frontend/src/components/virtual/VirtualTargetGrid.tsx @@ -9,7 +9,10 @@ import { type VirtualCardViewMode, type VirtualSessionConfig, } from '../../utils/virtualTradingStorage'; -import type { VirtualLiveStatus } from '../../hooks/useVirtualTargetLiveStatus'; +import { + resolveVirtualLiveStatusFallback, + type VirtualLiveStatus, +} from '../../hooks/useVirtualTargetLiveStatus'; import type { VirtualIndicatorSnapshot } from '../../hooks/useVirtualIndicatorSnapshots'; import type { TickerData } from '../../hooks/useMarketTicker'; import { resolveVirtualTargetStrategyId } from '../../utils/virtualTargetStrategy'; @@ -141,7 +144,12 @@ const VirtualTargetGrid: React.FC = ({ snapshot={snapshots[focusTarget.market]} signalLoading={snapshotLoadingByMarket[focusTarget.market]} running={session.running} - liveStatus={liveStatusByMarket[focusTarget.market] ?? (session.running ? 'connecting' : 'idle')} + liveStatus={resolveVirtualLiveStatusFallback( + focusTarget.market, + resolveVirtualTargetStrategyId(focusTarget, session.globalStrategyId), + liveStatusByMarket, + session.running, + )} lastTickAt={lastTickAtByMarket[focusTarget.market]} viewMode={viewMode} onExit={() => setFocusMarket(null)} @@ -176,7 +184,8 @@ const VirtualTargetGrid: React.FC = ({ aria-label="가상매매 종목 그리드" > {t => { - const strat = strategies.find(s => s.id === resolveVirtualTargetStrategyId(t, session.globalStrategyId)); + const stratId = resolveVirtualTargetStrategyId(t, session.globalStrategyId); + const strat = strategies.find(s => s.id === stratId); return ( = ({ snapshot={snapshots[t.market]} signalLoading={snapshotLoadingByMarket[t.market]} running={session.running} - liveStatus={liveStatusByMarket[t.market] ?? (session.running ? 'connecting' : 'idle')} + liveStatus={resolveVirtualLiveStatusFallback( + t.market, + stratId, + liveStatusByMarket, + session.running, + )} lastTickAt={lastTickAtByMarket[t.market]} viewMode={viewMode} displayMode={resolveCardDisplayMode(t.market)} diff --git a/frontend/src/hooks/useVirtualTargetLiveStatus.ts b/frontend/src/hooks/useVirtualTargetLiveStatus.ts index 5204e90..2f89152 100644 --- a/frontend/src/hooks/useVirtualTargetLiveStatus.ts +++ b/frontend/src/hooks/useVirtualTargetLiveStatus.ts @@ -14,6 +14,8 @@ import { export type VirtualLiveStatus = 'idle' | 'connecting' | 'live' | 'disconnected'; const STALE_MS = 20_000; +/** useStompChartData 와 동일 — STOMP 수립 후 틱 없어도 실시간 파이프라인 준비 완료로 간주 */ +const CONN_GRACE_MS = 800; const TICK_CANDLE = '1m'; interface TargetRef { @@ -30,6 +32,24 @@ function topicFor(market: string): string { return `/sub/charts/${market}/${TICK_CANDLE}`; } +function isCandlePayload(data: { time?: unknown; close?: unknown } | null): boolean { + if (!data) return false; + return typeof data.time === 'number' || typeof data.close === 'number'; +} + +/** 목록·카드에서 liveStatusByMarket 미등록 시 fallback */ +export function resolveVirtualLiveStatusFallback( + market: string, + strategyId: number | null, + statusByMarket: Record, + running: boolean, +): VirtualLiveStatus { + const known = statusByMarket[market]; + if (known) return known; + if (!strategyId) return 'idle'; + return running ? 'connecting' : 'idle'; +} + export interface VirtualTargetLiveState { statusByMarket: Record; /** STOMP 틱 수신 시각 (ms) — 카드 형광 플래시 트리거 */ @@ -46,6 +66,9 @@ export function useVirtualTargetLiveStatus( const [lastTickAtByMarket, setLastTickAtByMarket] = useState>({}); const stateRef = useRef>({}); const stompConnectedRef = useRef(isStompChartConnected()); + const stompConnectedAtRef = useRef( + isStompChartConnected() ? Date.now() : null, + ); const patchMarket = (market: string, patch: Partial) => { const prev = stateRef.current[market] ?? { status: 'idle' as VirtualLiveStatus, lastTickAt: null }; @@ -66,12 +89,22 @@ export function useVirtualTargetLiveStatus( return; } if (stompConnectedRef.current) { + const connectedAt = stompConnectedAtRef.current; + if (connectedAt != null && now - connectedAt >= CONN_GRACE_MS) { + patchMarket(market, { status: 'live' }); + return; + } patchMarket(market, { status: 'connecting' }); } else { patchMarket(market, { status: 'disconnected' }); } }; + const targetsKey = targets + .filter(t => t.strategyId != null) + .map(t => `${t.market}:${t.strategyId}`) + .join('|'); + useEffect(() => { const activeMarkets = targets .filter(t => t.strategyId != null) @@ -112,17 +145,30 @@ export function useVirtualTargetLiveStatus( const unsubs = activeMarkets.map(market => { const topic = topicFor(market); return subscribeStompTopic(topic, msg => { - const data = parseStompJson<{ time?: number }>(msg); - if (data?.time == null) return; + const data = parseStompJson<{ time?: number; close?: number }>(msg); + if (!isCandlePayload(data)) return; patchMarket(market, { status: 'live', lastTickAt: Date.now() }); }); }); + let graceTimer: ReturnType | null = null; + const scheduleGraceRecompute = () => { + if (graceTimer) clearTimeout(graceTimer); + if (!stompConnectedRef.current) return; + graceTimer = setTimeout(() => { + for (const market of activeMarkets) recomputeStatus(market); + }, CONN_GRACE_MS); + }; + const offConn = subscribeStompConnection(next => { stompConnectedRef.current = next; + stompConnectedAtRef.current = next ? Date.now() : null; for (const market of activeMarkets) recomputeStatus(market); + if (next) scheduleGraceRecompute(); }); + if (stompConnectedRef.current) scheduleGraceRecompute(); + const staleId = window.setInterval(() => { for (const market of activeMarkets) recomputeStatus(market); }, 3000); @@ -131,8 +177,9 @@ export function useVirtualTargetLiveStatus( unsubs.forEach(u => u()); offConn(); clearInterval(staleId); + if (graceTimer) clearTimeout(graceTimer); }; - }, [targets, running, eagerConnect]); + }, [targetsKey, running, eagerConnect]); return { statusByMarket: byMarket, lastTickAtByMarket }; } diff --git a/frontend/src/hooks/useVirtualTradingCore.ts b/frontend/src/hooks/useVirtualTradingCore.ts index 8230ccb..73e9bdb 100644 --- a/frontend/src/hooks/useVirtualTradingCore.ts +++ b/frontend/src/hooks/useVirtualTradingCore.ts @@ -219,16 +219,16 @@ export function useVirtualTradingCore(options: UseVirtualTradingCoreOptions = {} ); const mergedLiveStatus = useMemo(() => { - if (!session.running) return liveStatusByMarket; const merged = { ...liveStatusByMarket }; const now = Date.now(); + const SNAP_LIVE_MS = 12_000; for (const [market, snap] of Object.entries(snapshots)) { - if (snap?.updatedAt && now - snap.updatedAt < 8000) { + if (snap?.updatedAt && now - snap.updatedAt < SNAP_LIVE_MS) { merged[market] = 'live'; } } return merged; - }, [liveStatusByMarket, snapshots, session.running]); + }, [liveStatusByMarket, snapshots]); const handleTargetsChange = useCallback((items: VirtualTargetItem[]) => { if (items.length > virtualTargetMaxCount) {