매수, 매도 색상 수정

This commit is contained in:
Macbook
2026-05-31 01:23:47 +09:00
parent ba4ef5e230
commit 9d7dddfa57
24 changed files with 573 additions and 441 deletions
@@ -0,0 +1,152 @@
/**
* 가상투자 자동매매 — 백엔드 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(trade => {
if (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,
]);
}