가상매매 목록 실시간 연결안됨 문제수정
This commit is contained in:
@@ -2,6 +2,7 @@ import React, { useState } from 'react';
|
|||||||
import { useAuth } from '../../contexts/AuthContext';
|
import { useAuth } from '../../contexts/AuthContext';
|
||||||
import { useAppSettings } from '../../hooks/useAppSettings';
|
import { useAppSettings } from '../../hooks/useAppSettings';
|
||||||
import { useVirtualTradingCore } from '@frontend/hooks/useVirtualTradingCore';
|
import { useVirtualTradingCore } from '@frontend/hooks/useVirtualTradingCore';
|
||||||
|
import { resolveVirtualLiveStatusFallback } from '@frontend/hooks/useVirtualTargetLiveStatus';
|
||||||
import { resolveVirtualTargetStrategyId } from '@frontend/utils/virtualTargetStrategy';
|
import { resolveVirtualTargetStrategyId } from '@frontend/utils/virtualTargetStrategy';
|
||||||
import { useNavigation } from '../../contexts/NavigationContext';
|
import { useNavigation } from '../../contexts/NavigationContext';
|
||||||
import BottomSheet from '../../components/BottomSheet';
|
import BottomSheet from '../../components/BottomSheet';
|
||||||
@@ -195,7 +196,12 @@ export default function VirtualTradingScreen() {
|
|||||||
strategies={strategies}
|
strategies={strategies}
|
||||||
snapshot={snap}
|
snapshot={snap}
|
||||||
signalLoading={signalLoading}
|
signalLoading={signalLoading}
|
||||||
liveStatus={liveStatusByMarket[t.market]}
|
liveStatus={resolveVirtualLiveStatusFallback(
|
||||||
|
t.market,
|
||||||
|
strategyId,
|
||||||
|
liveStatusByMarket,
|
||||||
|
session.running,
|
||||||
|
)}
|
||||||
onDetail={() => goVirtualDetail(t.market)}
|
onDetail={() => goVirtualDetail(t.market)}
|
||||||
onTrade={side => goVirtualTrade(t.market, side)}
|
onTrade={side => goVirtualTrade(t.market, side)}
|
||||||
onTogglePin={() => void handleTogglePin(t.market)}
|
onTogglePin={() => void handleTogglePin(t.market)}
|
||||||
|
|||||||
@@ -221,16 +221,17 @@ const VirtualTradingPage: React.FC<Props> = ({
|
|||||||
}, [session.running, refreshPaperData]);
|
}, [session.running, refreshPaperData]);
|
||||||
|
|
||||||
const mergedLiveStatus = useMemo(() => {
|
const mergedLiveStatus = useMemo(() => {
|
||||||
if (!session.running) return liveStatusByMarket;
|
|
||||||
const merged = { ...liveStatusByMarket };
|
const merged = { ...liveStatusByMarket };
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
/** 폴링 주기(3~5s) + 지연 여유 — 지표 스냅샷 갱신 시 STOMP 틱 없어도 실시간 표시 */
|
||||||
|
const SNAP_LIVE_MS = 12_000;
|
||||||
for (const [market, snap] of Object.entries(snapshots)) {
|
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';
|
merged[market] = 'live';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return merged;
|
return merged;
|
||||||
}, [liveStatusByMarket, snapshots, session.running]);
|
}, [liveStatusByMarket, snapshots]);
|
||||||
|
|
||||||
const posQty = useMemo(() => {
|
const posQty = useMemo(() => {
|
||||||
const p = summary?.positions?.find(x => x.symbol === selectedMarket);
|
const p = summary?.positions?.find(x => x.symbol === selectedMarket);
|
||||||
|
|||||||
@@ -9,7 +9,10 @@ import {
|
|||||||
type VirtualCardViewMode,
|
type VirtualCardViewMode,
|
||||||
type VirtualSessionConfig,
|
type VirtualSessionConfig,
|
||||||
} from '../../utils/virtualTradingStorage';
|
} from '../../utils/virtualTradingStorage';
|
||||||
import type { VirtualLiveStatus } from '../../hooks/useVirtualTargetLiveStatus';
|
import {
|
||||||
|
resolveVirtualLiveStatusFallback,
|
||||||
|
type VirtualLiveStatus,
|
||||||
|
} from '../../hooks/useVirtualTargetLiveStatus';
|
||||||
import type { VirtualIndicatorSnapshot } from '../../hooks/useVirtualIndicatorSnapshots';
|
import type { VirtualIndicatorSnapshot } from '../../hooks/useVirtualIndicatorSnapshots';
|
||||||
import type { TickerData } from '../../hooks/useMarketTicker';
|
import type { TickerData } from '../../hooks/useMarketTicker';
|
||||||
import { resolveVirtualTargetStrategyId } from '../../utils/virtualTargetStrategy';
|
import { resolveVirtualTargetStrategyId } from '../../utils/virtualTargetStrategy';
|
||||||
@@ -141,7 +144,12 @@ const VirtualTargetGrid: React.FC<Props> = ({
|
|||||||
snapshot={snapshots[focusTarget.market]}
|
snapshot={snapshots[focusTarget.market]}
|
||||||
signalLoading={snapshotLoadingByMarket[focusTarget.market]}
|
signalLoading={snapshotLoadingByMarket[focusTarget.market]}
|
||||||
running={session.running}
|
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]}
|
lastTickAt={lastTickAtByMarket[focusTarget.market]}
|
||||||
viewMode={viewMode}
|
viewMode={viewMode}
|
||||||
onExit={() => setFocusMarket(null)}
|
onExit={() => setFocusMarket(null)}
|
||||||
@@ -176,7 +184,8 @@ const VirtualTargetGrid: React.FC<Props> = ({
|
|||||||
aria-label="가상매매 종목 그리드"
|
aria-label="가상매매 종목 그리드"
|
||||||
>
|
>
|
||||||
{t => {
|
{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 (
|
return (
|
||||||
<VirtualTargetCard
|
<VirtualTargetCard
|
||||||
market={t.market}
|
market={t.market}
|
||||||
@@ -188,7 +197,12 @@ const VirtualTargetGrid: React.FC<Props> = ({
|
|||||||
snapshot={snapshots[t.market]}
|
snapshot={snapshots[t.market]}
|
||||||
signalLoading={snapshotLoadingByMarket[t.market]}
|
signalLoading={snapshotLoadingByMarket[t.market]}
|
||||||
running={session.running}
|
running={session.running}
|
||||||
liveStatus={liveStatusByMarket[t.market] ?? (session.running ? 'connecting' : 'idle')}
|
liveStatus={resolveVirtualLiveStatusFallback(
|
||||||
|
t.market,
|
||||||
|
stratId,
|
||||||
|
liveStatusByMarket,
|
||||||
|
session.running,
|
||||||
|
)}
|
||||||
lastTickAt={lastTickAtByMarket[t.market]}
|
lastTickAt={lastTickAtByMarket[t.market]}
|
||||||
viewMode={viewMode}
|
viewMode={viewMode}
|
||||||
displayMode={resolveCardDisplayMode(t.market)}
|
displayMode={resolveCardDisplayMode(t.market)}
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ import {
|
|||||||
export type VirtualLiveStatus = 'idle' | 'connecting' | 'live' | 'disconnected';
|
export type VirtualLiveStatus = 'idle' | 'connecting' | 'live' | 'disconnected';
|
||||||
|
|
||||||
const STALE_MS = 20_000;
|
const STALE_MS = 20_000;
|
||||||
|
/** useStompChartData 와 동일 — STOMP 수립 후 틱 없어도 실시간 파이프라인 준비 완료로 간주 */
|
||||||
|
const CONN_GRACE_MS = 800;
|
||||||
const TICK_CANDLE = '1m';
|
const TICK_CANDLE = '1m';
|
||||||
|
|
||||||
interface TargetRef {
|
interface TargetRef {
|
||||||
@@ -30,6 +32,24 @@ function topicFor(market: string): string {
|
|||||||
return `/sub/charts/${market}/${TICK_CANDLE}`;
|
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<string, VirtualLiveStatus>,
|
||||||
|
running: boolean,
|
||||||
|
): VirtualLiveStatus {
|
||||||
|
const known = statusByMarket[market];
|
||||||
|
if (known) return known;
|
||||||
|
if (!strategyId) return 'idle';
|
||||||
|
return running ? 'connecting' : 'idle';
|
||||||
|
}
|
||||||
|
|
||||||
export interface VirtualTargetLiveState {
|
export interface VirtualTargetLiveState {
|
||||||
statusByMarket: Record<string, VirtualLiveStatus>;
|
statusByMarket: Record<string, VirtualLiveStatus>;
|
||||||
/** STOMP 틱 수신 시각 (ms) — 카드 형광 플래시 트리거 */
|
/** STOMP 틱 수신 시각 (ms) — 카드 형광 플래시 트리거 */
|
||||||
@@ -46,6 +66,9 @@ export function useVirtualTargetLiveStatus(
|
|||||||
const [lastTickAtByMarket, setLastTickAtByMarket] = useState<Record<string, number>>({});
|
const [lastTickAtByMarket, setLastTickAtByMarket] = useState<Record<string, number>>({});
|
||||||
const stateRef = useRef<Record<string, MarketLiveState>>({});
|
const stateRef = useRef<Record<string, MarketLiveState>>({});
|
||||||
const stompConnectedRef = useRef(isStompChartConnected());
|
const stompConnectedRef = useRef(isStompChartConnected());
|
||||||
|
const stompConnectedAtRef = useRef<number | null>(
|
||||||
|
isStompChartConnected() ? Date.now() : null,
|
||||||
|
);
|
||||||
|
|
||||||
const patchMarket = (market: string, patch: Partial<MarketLiveState>) => {
|
const patchMarket = (market: string, patch: Partial<MarketLiveState>) => {
|
||||||
const prev = stateRef.current[market] ?? { status: 'idle' as VirtualLiveStatus, lastTickAt: null };
|
const prev = stateRef.current[market] ?? { status: 'idle' as VirtualLiveStatus, lastTickAt: null };
|
||||||
@@ -66,12 +89,22 @@ export function useVirtualTargetLiveStatus(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (stompConnectedRef.current) {
|
if (stompConnectedRef.current) {
|
||||||
|
const connectedAt = stompConnectedAtRef.current;
|
||||||
|
if (connectedAt != null && now - connectedAt >= CONN_GRACE_MS) {
|
||||||
|
patchMarket(market, { status: 'live' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
patchMarket(market, { status: 'connecting' });
|
patchMarket(market, { status: 'connecting' });
|
||||||
} else {
|
} else {
|
||||||
patchMarket(market, { status: 'disconnected' });
|
patchMarket(market, { status: 'disconnected' });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const targetsKey = targets
|
||||||
|
.filter(t => t.strategyId != null)
|
||||||
|
.map(t => `${t.market}:${t.strategyId}`)
|
||||||
|
.join('|');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const activeMarkets = targets
|
const activeMarkets = targets
|
||||||
.filter(t => t.strategyId != null)
|
.filter(t => t.strategyId != null)
|
||||||
@@ -112,17 +145,30 @@ export function useVirtualTargetLiveStatus(
|
|||||||
const unsubs = activeMarkets.map(market => {
|
const unsubs = activeMarkets.map(market => {
|
||||||
const topic = topicFor(market);
|
const topic = topicFor(market);
|
||||||
return subscribeStompTopic(topic, msg => {
|
return subscribeStompTopic(topic, msg => {
|
||||||
const data = parseStompJson<{ time?: number }>(msg);
|
const data = parseStompJson<{ time?: number; close?: number }>(msg);
|
||||||
if (data?.time == null) return;
|
if (!isCandlePayload(data)) return;
|
||||||
patchMarket(market, { status: 'live', lastTickAt: Date.now() });
|
patchMarket(market, { status: 'live', lastTickAt: Date.now() });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let graceTimer: ReturnType<typeof setTimeout> | 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 => {
|
const offConn = subscribeStompConnection(next => {
|
||||||
stompConnectedRef.current = next;
|
stompConnectedRef.current = next;
|
||||||
|
stompConnectedAtRef.current = next ? Date.now() : null;
|
||||||
for (const market of activeMarkets) recomputeStatus(market);
|
for (const market of activeMarkets) recomputeStatus(market);
|
||||||
|
if (next) scheduleGraceRecompute();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (stompConnectedRef.current) scheduleGraceRecompute();
|
||||||
|
|
||||||
const staleId = window.setInterval(() => {
|
const staleId = window.setInterval(() => {
|
||||||
for (const market of activeMarkets) recomputeStatus(market);
|
for (const market of activeMarkets) recomputeStatus(market);
|
||||||
}, 3000);
|
}, 3000);
|
||||||
@@ -131,8 +177,9 @@ export function useVirtualTargetLiveStatus(
|
|||||||
unsubs.forEach(u => u());
|
unsubs.forEach(u => u());
|
||||||
offConn();
|
offConn();
|
||||||
clearInterval(staleId);
|
clearInterval(staleId);
|
||||||
|
if (graceTimer) clearTimeout(graceTimer);
|
||||||
};
|
};
|
||||||
}, [targets, running, eagerConnect]);
|
}, [targetsKey, running, eagerConnect]);
|
||||||
|
|
||||||
return { statusByMarket: byMarket, lastTickAtByMarket };
|
return { statusByMarket: byMarket, lastTickAtByMarket };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -219,16 +219,16 @@ export function useVirtualTradingCore(options: UseVirtualTradingCoreOptions = {}
|
|||||||
);
|
);
|
||||||
|
|
||||||
const mergedLiveStatus = useMemo(() => {
|
const mergedLiveStatus = useMemo(() => {
|
||||||
if (!session.running) return liveStatusByMarket;
|
|
||||||
const merged = { ...liveStatusByMarket };
|
const merged = { ...liveStatusByMarket };
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
const SNAP_LIVE_MS = 12_000;
|
||||||
for (const [market, snap] of Object.entries(snapshots)) {
|
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';
|
merged[market] = 'live';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return merged;
|
return merged;
|
||||||
}, [liveStatusByMarket, snapshots, session.running]);
|
}, [liveStatusByMarket, snapshots]);
|
||||||
|
|
||||||
const handleTargetsChange = useCallback((items: VirtualTargetItem[]) => {
|
const handleTargetsChange = useCallback((items: VirtualTargetItem[]) => {
|
||||||
if (items.length > virtualTargetMaxCount) {
|
if (items.length > virtualTargetMaxCount) {
|
||||||
|
|||||||
Reference in New Issue
Block a user