186 lines
6.0 KiB
TypeScript
186 lines
6.0 KiB
TypeScript
/**
|
|
* 가상투자 — 종목별 STOMP 실시간 데이터 수신 상태
|
|
*/
|
|
import { useEffect, useRef, useState } from 'react';
|
|
import { pinCandleWatch } from '../utils/backendApi';
|
|
import { parseStompJson } from '../utils/stompMessage';
|
|
import {
|
|
isStompChartConnected,
|
|
subscribeStompConnection,
|
|
subscribeStompTopic,
|
|
} from '../utils/stompChartBroker';
|
|
|
|
/** idle=미시작, connecting=연결 중, live=틱 수신 중, disconnected=끊김 */
|
|
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 {
|
|
market: string;
|
|
strategyId: number | null;
|
|
}
|
|
|
|
interface MarketLiveState {
|
|
status: VirtualLiveStatus;
|
|
lastTickAt: number | null;
|
|
}
|
|
|
|
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<string, VirtualLiveStatus>,
|
|
running: boolean,
|
|
): VirtualLiveStatus {
|
|
const known = statusByMarket[market];
|
|
if (known) return known;
|
|
if (!strategyId) return 'idle';
|
|
return running ? 'connecting' : 'idle';
|
|
}
|
|
|
|
export interface VirtualTargetLiveState {
|
|
statusByMarket: Record<string, VirtualLiveStatus>;
|
|
/** STOMP 틱 수신 시각 (ms) — 카드 형광 플래시 트리거 */
|
|
lastTickAtByMarket: Record<string, number>;
|
|
}
|
|
|
|
export function useVirtualTargetLiveStatus(
|
|
targets: TargetRef[],
|
|
running: boolean,
|
|
/** 전략 지정만 된 상태에서도 STOMP·pin으로 업비트 틱 수신 (지표 warm-up 보조) */
|
|
eagerConnect = true,
|
|
): VirtualTargetLiveState {
|
|
const [byMarket, setByMarket] = useState<Record<string, VirtualLiveStatus>>({});
|
|
const [lastTickAtByMarket, setLastTickAtByMarket] = useState<Record<string, number>>({});
|
|
const stateRef = useRef<Record<string, MarketLiveState>>({});
|
|
const stompConnectedRef = useRef(isStompChartConnected());
|
|
const stompConnectedAtRef = useRef<number | null>(
|
|
isStompChartConnected() ? Date.now() : null,
|
|
);
|
|
|
|
const patchMarket = (market: string, patch: Partial<MarketLiveState>) => {
|
|
const prev = stateRef.current[market] ?? { status: 'idle' as VirtualLiveStatus, lastTickAt: null };
|
|
const next: MarketLiveState = { ...prev, ...patch };
|
|
stateRef.current[market] = next;
|
|
setByMarket(cur => ({ ...cur, [market]: next.status }));
|
|
if (patch.lastTickAt != null) {
|
|
setLastTickAtByMarket(cur => ({ ...cur, [market]: patch.lastTickAt! }));
|
|
}
|
|
};
|
|
|
|
const recomputeStatus = (market: string) => {
|
|
const s = stateRef.current[market];
|
|
if (!s) return;
|
|
const now = Date.now();
|
|
if (s.lastTickAt != null && now - s.lastTickAt <= STALE_MS) {
|
|
if (s.status !== 'live') patchMarket(market, { status: 'live' });
|
|
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)
|
|
.map(t => t.market);
|
|
|
|
const active = running || (eagerConnect && activeMarkets.length > 0);
|
|
if (!active) {
|
|
stateRef.current = {};
|
|
setByMarket({});
|
|
setLastTickAtByMarket({});
|
|
return;
|
|
}
|
|
|
|
for (const market of activeMarkets) {
|
|
if (!stateRef.current[market]) {
|
|
stateRef.current[market] = { status: 'connecting', lastTickAt: null };
|
|
setByMarket(cur => ({ ...cur, [market]: 'connecting' }));
|
|
}
|
|
void pinCandleWatch(market, TICK_CANDLE);
|
|
}
|
|
|
|
for (const key of Object.keys(stateRef.current)) {
|
|
if (!activeMarkets.includes(key)) {
|
|
delete stateRef.current[key];
|
|
setByMarket(cur => {
|
|
const next = { ...cur };
|
|
delete next[key];
|
|
return next;
|
|
});
|
|
setLastTickAtByMarket(cur => {
|
|
const next = { ...cur };
|
|
delete next[key];
|
|
return next;
|
|
});
|
|
}
|
|
}
|
|
|
|
const unsubs = activeMarkets.map(market => {
|
|
const topic = topicFor(market);
|
|
return subscribeStompTopic(topic, msg => {
|
|
const data = parseStompJson<{ time?: number; close?: number }>(msg);
|
|
if (!isCandlePayload(data)) return;
|
|
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 => {
|
|
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);
|
|
|
|
return () => {
|
|
unsubs.forEach(u => u());
|
|
offConn();
|
|
clearInterval(staleId);
|
|
if (graceTimer) clearTimeout(graceTimer);
|
|
};
|
|
}, [targetsKey, running, eagerConnect]);
|
|
|
|
return { statusByMarket: byMarket, lastTickAtByMarket };
|
|
}
|