118 lines
3.5 KiB
TypeScript
118 lines
3.5 KiB
TypeScript
/**
|
|
* 가상투자 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<string, VirtualIndicatorSnapshot | undefined>;
|
|
tickers?: Map<string, TickerData>;
|
|
paperAutoTradeBudgetPct: number;
|
|
positions?: PaperSummaryDto['positions'];
|
|
onFilled?: () => void;
|
|
}
|
|
|
|
export function useVirtualAutoTrade({
|
|
enabled,
|
|
session,
|
|
targets,
|
|
snapshots,
|
|
tickers,
|
|
paperAutoTradeBudgetPct,
|
|
positions = [],
|
|
onFilled,
|
|
}: Options): void {
|
|
const prevTimingRef = useRef<Record<string, VirtualTradeTiming>>({});
|
|
const inflightRef = useRef<Set<string>>(new Set());
|
|
const lastExecRef = useRef<Record<string, number>>({});
|
|
|
|
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,
|
|
]);
|
|
}
|