153 lines
5.2 KiB
TypeScript
153 lines
5.2 KiB
TypeScript
/**
|
|
* 가상투자 자동매매 — 백엔드 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<string, TickerData>;
|
|
paperAutoTradeBudgetPct: number;
|
|
positions?: PaperSummaryDto['positions'];
|
|
onFilled?: () => void;
|
|
}
|
|
|
|
export function useVirtualBackendTradeSignals({
|
|
enabled,
|
|
session,
|
|
targets,
|
|
tickers,
|
|
paperAutoTradeBudgetPct,
|
|
positions = [],
|
|
onFilled,
|
|
}: Options): void {
|
|
const inflightRef = useRef<Set<string>>(new Set());
|
|
const lastExecRef = useRef<Record<string, number>>({});
|
|
const seenSignalRef = useRef<Set<string>>(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,
|
|
]);
|
|
}
|