/** * 가상투자 자동매매 — 백엔드 BarBuilder/LiveStrategyEvaluator 가 발행한 STOMP BUY/SELL 만 체결. * 프론트 근사 일치율(resolveVirtualTradeTiming) 사용 안 함. */ import { useEffect, useRef } from 'react'; import type { TickerData } from './useMarketTicker'; import type { VirtualSessionConfig, VirtualTargetItem } from '../utils/virtualTradingStorage'; import { loadStrategyTimeframes, placePaperOrder, type PaperSummaryDto } from '../utils/backendApi'; import { parseStompJson } from '../utils/stompMessage'; import { subscribeStompTopic } from '../utils/stompChartBroker'; import { signalBelongsToCurrentSession } from '../utils/tradeSignalOwnership'; import { resolveVirtualTargetStrategyId } from '../utils/virtualTargetStrategy'; import { coerceFiniteNumber } from '../utils/safeFormat'; const EXEC_COOLDOWN_MS = 15_000; interface Options { enabled: boolean; session: VirtualSessionConfig; targets: VirtualTargetItem[]; tickers?: Map; paperAutoTradeBudgetPct: number; positions?: PaperSummaryDto['positions']; onFilled?: () => void; } export function useVirtualBackendTradeSignals({ enabled, session, targets, tickers, paperAutoTradeBudgetPct, positions = [], onFilled, }: Options): void { const inflightRef = useRef>(new Set()); const lastExecRef = useRef>({}); const seenSignalRef = useRef>(new Set()); useEffect(() => { if (!enabled || !session.running) { seenSignalRef.current.clear(); return; } const activeTargets = targets.filter( t => resolveVirtualTargetStrategyId(t, session.globalStrategyId) != null, ); if (activeTargets.length === 0) return; const unsubs: Array<() => void> = []; let cancelled = false; void (async () => { for (const target of activeTargets) { if (cancelled) break; const strategyId = resolveVirtualTargetStrategyId(target, session.globalStrategyId)!; let timeframes: string[] = []; try { timeframes = await loadStrategyTimeframes(strategyId); } catch { timeframes = ['1m']; } if (timeframes.length === 0) timeframes = ['1m']; for (const candleType of timeframes) { const topic = `/sub/charts/${target.market}/${candleType}`; const unsub = subscribeStompTopic(topic, msg => { const data = parseStompJson<{ time: number; close: number; signal?: string; candleType?: string; strategyId?: number | null; userId?: number | null; deviceId?: string | null; }>(msg); if (!data?.signal || (data.signal !== 'BUY' && data.signal !== 'SELL')) return; if (data.strategyId != null && data.strategyId !== strategyId) return; if (!signalBelongsToCurrentSession({ userId: data.userId, deviceId: data.deviceId })) { return; } const dedup = `${target.market}:${data.candleType ?? candleType}:${data.time}:${data.signal}:${strategyId}`; if (seenSignalRef.current.has(dedup)) return; seenSignalRef.current.add(dedup); if (seenSignalRef.current.size > 5000) { seenSignalRef.current.clear(); } const side = data.signal as 'BUY' | 'SELL'; const price = coerceFiniteNumber(tickers?.get(target.market)?.tradePrice) ?? coerceFiniteNumber(data.close); if (price == null || price <= 0) return; const heldQty = coerceFiniteNumber( positions.find(p => p.symbol === target.market)?.quantity, ) ?? 0; if (session.positionMode === 'LONG_ONLY') { if (side === 'BUY' && heldQty > 0) return; if (side === 'SELL' && heldQty <= 0) return; } const execKey = `${target.market}:${side}`; if (inflightRef.current.has(execKey)) return; if (Date.now() - (lastExecRef.current[execKey] ?? 0) < EXEC_COOLDOWN_MS) return; inflightRef.current.add(execKey); void placePaperOrder({ market: target.market, side, orderKind: 'market', price, quantity: 0, budgetPct: side === 'BUY' ? paperAutoTradeBudgetPct : 100, source: 'STRATEGY', strategyId, }) .then(result => { if (result?.trade) { lastExecRef.current[execKey] = Date.now(); onFilled?.(); } }) .catch(err => { console.warn('[VirtualBackendTrade]', target.market, side, err); }) .finally(() => { inflightRef.current.delete(execKey); }); }); unsubs.push(unsub); } } })(); return () => { cancelled = true; unsubs.forEach(u => u()); }; }, [ enabled, session.running, session.globalStrategyId, session.positionMode, targets, tickers, paperAutoTradeBudgetPct, positions, onFilled, ]); }