/** * 가상투자 Match(전략 조건 100% 충족) 시 자동 모의 체결 * 설정 · 가상투자 · 자동매매 ON + 세션 running 일 때만 동작 */ import { useEffect, useRef } from 'react'; import type { TickerData } from './useMarketTicker'; import type { VirtualIndicatorSnapshot } from './useVirtualIndicatorSnapshots'; import type { VirtualSessionConfig, VirtualTargetItem } from '../utils/virtualTradingStorage'; import { placePaperOrder, type PaperSummaryDto } from '../utils/backendApi'; import { buildConditionMetrics, resolveVirtualTradeTiming, type VirtualTradeTiming, } from '../utils/virtualSignalMetrics'; import { coerceFiniteNumber } from '../utils/safeFormat'; const EXEC_COOLDOWN_MS = 15_000; interface Options { enabled: boolean; session: VirtualSessionConfig; targets: VirtualTargetItem[]; snapshots: Record; tickers?: Map; paperAutoTradeBudgetPct: number; positions?: PaperSummaryDto['positions']; onFilled?: () => void; } export function useVirtualAutoTrade({ enabled, session, targets, snapshots, tickers, paperAutoTradeBudgetPct, positions = [], onFilled, }: Options): void { const prevTimingRef = useRef>({}); const inflightRef = useRef>(new Set()); const lastExecRef = useRef>({}); useEffect(() => { if (!enabled || !session.running) { prevTimingRef.current = {}; return; } for (const target of targets) { const snap = snapshots[target.market]; if (!snap?.rows?.length) continue; const metrics = buildConditionMetrics(snap.rows); const timing = resolveVirtualTradeTiming(metrics); const prev = prevTimingRef.current[target.market] ?? 'neutral'; prevTimingRef.current[target.market] = timing; if (timing !== 'buy' && timing !== 'sell') continue; if (timing === prev) continue; const price = coerceFiniteNumber(tickers?.get(target.market)?.tradePrice); if (price == null || price <= 0) continue; const heldQty = coerceFiniteNumber( positions.find(p => p.symbol === target.market)?.quantity, ) ?? 0; if (session.positionMode === 'LONG_ONLY') { if (timing === 'buy' && heldQty > 0) continue; if (timing === 'sell' && heldQty <= 0) continue; } const side = timing === 'buy' ? 'BUY' : 'SELL'; const execKey = `${target.market}:${side}`; if (inflightRef.current.has(execKey)) continue; if (Date.now() - (lastExecRef.current[execKey] ?? 0) < EXEC_COOLDOWN_MS) continue; const strategyId = target.strategyId ?? session.globalStrategyId ?? null; inflightRef.current.add(execKey); void placePaperOrder({ market: target.market, side, orderKind: 'market', price, quantity: 0, budgetPct: timing === 'buy' ? paperAutoTradeBudgetPct : 100, source: 'STRATEGY', strategyId, }) .then(trade => { if (trade) { lastExecRef.current[execKey] = Date.now(); onFilled?.(); } }) .catch(err => { console.warn('[VirtualAutoTrade]', target.market, side, err); }) .finally(() => { inflightRef.current.delete(execKey); }); } }, [ enabled, session.running, session.globalStrategyId, session.positionMode, targets, snapshots, tickers, paperAutoTradeBudgetPct, positions, onFilled, ]); }