가상투자 메뉴 기능 구현

This commit is contained in:
Macbook
2026-05-25 06:41:45 +09:00
parent 0cfe7fc84c
commit 8b373b11e3
33 changed files with 3897 additions and 99 deletions
@@ -0,0 +1,213 @@
/**
* 가상투자 카드 — 종목×전략별 지표·조건 스냅샷
* running 시: candles/watch pin + 3초마다 백엔드 live-conditions API (종목별 독립)
*/
import { useCallback, useEffect, useRef, useState } from 'react';
import type { StrategyDto } from '../utils/backendApi';
import {
fetchLiveConditionStatus,
pinCandleWatch,
type LiveConditionRowDto,
} from '../utils/backendApi';
import {
extractVirtualConditions,
type VirtualConditionRow,
} from '../utils/virtualStrategyConditions';
export interface VirtualIndicatorSnapshot {
market: string;
strategyId: number;
timeframe: string;
rows: Array<VirtualConditionRow & { currentValue: number | null }>;
updatedAt: number;
/** 백엔드 Ta4j 집계 일치율 (0~100) */
matchRate?: number;
}
function rowFallbackKey(row: Pick<VirtualConditionRow, 'side' | 'timeframe' | 'indicatorType' | 'conditionType' | 'targetValue' | 'plotKey'>): string {
return `${row.side}:${row.timeframe}:${row.indicatorType}:${row.conditionType}:${row.targetValue ?? ''}:${row.plotKey}`;
}
function liveFallbackKey(r: LiveConditionRowDto): string {
return `${r.side}:${r.timeframe}:${r.indicatorType}:${r.conditionType}:${r.targetValue ?? ''}:${r.indicatorType}`;
}
function mergeRows(
base: VirtualConditionRow[],
live: LiveConditionRowDto[],
): Array<VirtualConditionRow & { currentValue: number | null }> {
const liveById = new Map<string, LiveConditionRowDto>();
const liveByKey = new Map<string, LiveConditionRowDto>();
for (const r of live) {
liveById.set(r.id, r);
liveByKey.set(liveFallbackKey(r), r);
}
if (base.length === 0) {
return live.map(r => ({
id: r.id,
indicatorType: r.indicatorType,
displayName: r.displayName,
conditionType: r.conditionType,
conditionLabel: r.conditionLabel,
targetValue: r.targetValue,
timeframe: r.timeframe,
side: r.side,
plotKey: r.indicatorType,
satisfied: r.satisfied,
thresholdLabel: r.thresholdLabel,
currentValue: r.currentValue,
}));
}
return base.map(row => {
const liveRow = liveById.get(row.id) ?? liveByKey.get(rowFallbackKey(row));
if (!liveRow) {
return { ...row, currentValue: null as number | null };
}
return {
...row,
satisfied: liveRow.satisfied,
thresholdLabel: liveRow.thresholdLabel,
currentValue: liveRow.currentValue,
targetValue: liveRow.targetValue ?? row.targetValue,
};
});
}
async function buildSnapshot(
market: string,
strategyId: number,
strategy: StrategyDto | undefined,
running: boolean,
): Promise<VirtualIndicatorSnapshot | null> {
const baseRows = strategy ? extractVirtualConditions(strategy) : [];
if (!running) {
if (baseRows.length === 0) return null;
return {
market,
strategyId,
timeframe: [...new Set(baseRows.map(r => r.timeframe))].join(', '),
rows: baseRows.map(r => ({ ...r, currentValue: null })),
updatedAt: Date.now(),
matchRate: 0,
};
}
const status = await fetchLiveConditionStatus(market, strategyId);
if (!status || status.market !== market) {
if (baseRows.length === 0) return null;
return {
market,
strategyId,
timeframe: [...new Set(baseRows.map(r => r.timeframe))].join(', '),
rows: baseRows.map(r => ({ ...r, currentValue: null })),
updatedAt: Date.now(),
matchRate: 0,
};
}
return {
market,
strategyId,
timeframe: status.timeframe || [...new Set(baseRows.map(r => r.timeframe))].join(', '),
rows: mergeRows(baseRows, status.rows),
updatedAt: status.updatedAt || Date.now(),
matchRate: status.matchRate,
};
}
interface TargetRef {
market: string;
strategyId: number | null;
}
export function useVirtualIndicatorSnapshots(
targets: TargetRef[],
strategies: StrategyDto[],
running: boolean,
pollMs = 3000,
): Record<string, VirtualIndicatorSnapshot> {
const [snapshots, setSnapshots] = useState<Record<string, VirtualIndicatorSnapshot>>({});
const strategiesRef = useRef(strategies);
strategiesRef.current = strategies;
const pinnedRef = useRef<Set<string>>(new Set());
const targetsKey = targets.map(t => `${t.market}:${t.strategyId ?? ''}`).join('|');
const pinTargets = useCallback(async () => {
const strats = strategiesRef.current;
const pins = new Set<string>();
for (const t of targets) {
if (t.strategyId == null) continue;
const strat = strats.find(s => s.id === t.strategyId);
const conditions = strat ? extractVirtualConditions(strat) : [];
const tfs = conditions.length > 0
? [...new Set(conditions.map(c => c.timeframe))]
: ['1m'];
for (const tf of tfs) {
const key = `${t.market}:${tf}`;
pins.add(key);
if (!pinnedRef.current.has(key)) {
await pinCandleWatch(t.market, tf);
pinnedRef.current.add(key);
}
}
const k1 = `${t.market}:1m`;
if (!pins.has(k1) || !tfs.includes('1m')) {
if (!pinnedRef.current.has(k1)) {
await pinCandleWatch(t.market, '1m');
pinnedRef.current.add(k1);
}
}
}
for (const k of pinnedRef.current) {
if (!pins.has(k)) pinnedRef.current.delete(k);
}
}, [targets]);
const refresh = useCallback(async () => {
const strats = strategiesRef.current;
if (running) await pinTargets();
const jobs = targets
.filter(t => t.strategyId != null)
.map(async t => {
const strat = strats.find(s => s.id === t.strategyId);
const snap = await buildSnapshot(t.market, t.strategyId!, strat, running);
if (snap) {
setSnapshots(prev => ({ ...prev, [snap.market]: snap }));
}
return snap;
});
await Promise.all(jobs);
// 제거된 종목 스냅샷 정리
const activeMarkets = new Set(targets.map(t => t.market));
setSnapshots(prev => {
const next = { ...prev };
let changed = false;
for (const key of Object.keys(next)) {
if (!activeMarkets.has(key)) {
delete next[key];
changed = true;
}
}
return changed ? next : prev;
});
}, [targets, running, pinTargets]);
useEffect(() => {
if (targets.length === 0) {
setSnapshots({});
pinnedRef.current.clear();
return;
}
void refresh();
if (!running) return;
const id = window.setInterval(() => void refresh(), pollMs);
return () => clearInterval(id);
}, [targetsKey, running, pollMs, refresh]);
return snapshots;
}
@@ -0,0 +1,119 @@
/**
* 가상투자 — 종목별 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;
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}`;
}
export function useVirtualTargetLiveStatus(
targets: TargetRef[],
running: boolean,
): Record<string, VirtualLiveStatus> {
const [byMarket, setByMarket] = useState<Record<string, VirtualLiveStatus>>({});
const stateRef = useRef<Record<string, MarketLiveState>>({});
const stompConnectedRef = useRef(isStompChartConnected());
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 }));
};
const recomputeStatus = (market: string) => {
const s = stateRef.current[market];
if (!s || !running) 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) {
patchMarket(market, { status: 'connecting' });
} else {
patchMarket(market, { status: 'disconnected' });
}
};
useEffect(() => {
if (!running) {
stateRef.current = {};
setByMarket({});
return;
}
const activeMarkets = targets
.filter(t => t.strategyId != null)
.map(t => t.market);
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;
});
}
}
const unsubs = activeMarkets.map(market => {
const topic = topicFor(market);
return subscribeStompTopic(topic, msg => {
const data = parseStompJson<{ time?: number }>(msg);
if (data?.time == null) return;
patchMarket(market, { status: 'live', lastTickAt: Date.now() });
});
});
const offConn = subscribeStompConnection(next => {
stompConnectedRef.current = next;
for (const market of activeMarkets) recomputeStatus(market);
});
const staleId = window.setInterval(() => {
for (const market of activeMarkets) recomputeStatus(market);
}, 3000);
return () => {
unsubs.forEach(u => u());
offConn();
clearInterval(staleId);
};
}, [targets, running]);
return byMarket;
}