가상투자 로직 변경

This commit is contained in:
Macbook
2026-05-31 05:29:17 +09:00
parent 6da4821d9d
commit 85d7fea457
9 changed files with 39 additions and 182 deletions
@@ -263,12 +263,26 @@ public class PaperTradingService {
GcAppSettings app = appSettingsService.getEntity(userId, deviceId); GcAppSettings app = appSettingsService.getEntity(userId, deviceId);
if (!Boolean.TRUE.equals(app.getPaperTradingEnabled())) return; if (!Boolean.TRUE.equals(app.getPaperTradingEnabled())) return;
if (!Boolean.TRUE.equals(app.getPaperAutoTradeEnabled())) return; if (!Boolean.TRUE.equals(app.getPaperAutoTradeEnabled())) return;
boolean marketActive = liveSettingsRepo.findActiveByMarket(market).stream() List<GcLiveStrategySettings> liveForMarket = liveSettingsRepo.findActiveByMarket(market).stream()
.anyMatch(s -> Boolean.TRUE.equals(s.getIsLiveCheck())); .filter(s -> Boolean.TRUE.equals(s.getIsLiveCheck()))
.toList();
boolean marketActive = !liveForMarket.isEmpty();
if (!Boolean.TRUE.equals(app.getLiveStrategyCheck()) && !marketActive) return; if (!Boolean.TRUE.equals(app.getLiveStrategyCheck()) && !marketActive) return;
try { try {
GcPaperAccount account = getOrCreateAccount(userId, deviceId, app); GcPaperAccount account = getOrCreateAccount(userId, deviceId, app);
String positionMode = liveForMarket.stream()
.map(GcLiveStrategySettings::getPositionMode)
.filter(m -> m != null && !m.isBlank())
.findFirst()
.orElse("LONG_ONLY");
if ("LONG_ONLY".equals(positionMode)) {
GcPaperPosition posNow = positionRepo.findByAccountIdAndSymbol(account.getId(), market)
.orElse(null);
double held = posNow != null ? posNow.getQuantity().doubleValue() : 0;
if ("BUY".equals(signalType) && held > 0) return;
if ("SELL".equals(signalType) && held <= 0) return;
}
double feeRate = pct(app.getPaperFeeRatePct(), 0.05); double feeRate = pct(app.getPaperFeeRatePct(), 0.05);
double slip = pct(app.getPaperSlippagePct(), 0); double slip = pct(app.getPaperSlippagePct(), 0);
double minOrder = app.getPaperMinOrderKrw() != null double minOrder = app.getPaperMinOrderKrw() != null
+1 -2
View File
@@ -117,9 +117,8 @@ const PaperTradingPage: React.FC<Props> = ({
} finally { } finally {
setInitialLoading(false); setInitialLoading(false);
} }
onPaperOrderFilled?.();
if (opts?.focusHistory) setRightTab('history'); if (opts?.focusHistory) setRightTab('history');
}, [onPaperOrderFilled]); }, []);
useEffect(() => { useEffect(() => {
void Promise.all([ void Promise.all([
+12 -10
View File
@@ -28,7 +28,7 @@ import {
} from './virtual/VirtualGridUnifiedHeader'; } from './virtual/VirtualGridUnifiedHeader';
import type { VirtualCardDisplayMode } from './virtual/VirtualTargetCard'; import type { VirtualCardDisplayMode } from './virtual/VirtualTargetCard';
import { useVirtualIndicatorSnapshots } from '../hooks/useVirtualIndicatorSnapshots'; import { useVirtualIndicatorSnapshots } from '../hooks/useVirtualIndicatorSnapshots';
import { useVirtualBackendTradeSignals } from '../hooks/useVirtualBackendTradeSignals'; import { PAPER_TRADES_CHANGED_EVENT } from '../utils/paperTradeEvents';
import { useVirtualTargetLiveStatus } from '../hooks/useVirtualTargetLiveStatus'; import { useVirtualTargetLiveStatus } from '../hooks/useVirtualTargetLiveStatus';
import type { ChartRealtimeSource } from '../hooks/useChartRealtimeData'; import type { ChartRealtimeSource } from '../hooks/useChartRealtimeData';
import { import {
@@ -195,15 +195,17 @@ const VirtualTradingPage: React.FC<Props> = ({
session.running, session.running,
); );
useVirtualBackendTradeSignals({ /** 백엔드 자동 체결 후 UI 갱신(프론트는 주문 실행 안 함) */
enabled: paperTradingEnabled && paperAutoTradeEnabled, useEffect(() => {
session, if (!session.running) return;
targets, const onTradesChanged = () => { void refreshPaperData(); };
tickers, window.addEventListener(PAPER_TRADES_CHANGED_EVENT, onTradesChanged);
paperAutoTradeBudgetPct, const pollId = window.setInterval(onTradesChanged, 20_000);
positions: summary?.positions, return () => {
onFilled: () => void refreshPaperData({ focusHistory: true }), window.removeEventListener(PAPER_TRADES_CHANGED_EVENT, onTradesChanged);
}); window.clearInterval(pollId);
};
}, [session.running, refreshPaperData]);
const mergedLiveStatus = useMemo(() => { const mergedLiveStatus = useMemo(() => {
if (!session.running) return liveStatusByMarket; if (!session.running) return liveStatusByMarket;
@@ -21,7 +21,7 @@ const PaperLedgerTab: React.FC<Props> = ({ refreshKey = 0 }) => {
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
setLoading(true); if (entries.length === 0) setLoading(true);
void loadPaperLedger({ page: 0, size: 50 }).then(page => { void loadPaperLedger({ page: 0, size: 50 }).then(page => {
if (!cancelled) { if (!cancelled) {
setEntries(page.content); setEntries(page.content);
@@ -29,6 +29,8 @@ const PaperLedgerTab: React.FC<Props> = ({ refreshKey = 0 }) => {
} }
}); });
return () => { cancelled = true; }; return () => { cancelled = true; };
// entries.length: 최초 로드만 스피너 — 갱신 시 목록 유지(깜빡임 방지)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [refreshKey]); }, [refreshKey]);
if (loading) return <p className="ptd-muted"> </p>; if (loading) return <p className="ptd-muted"> </p>;
@@ -85,8 +85,8 @@ const PaperLeftStrategyTab: React.FC<Props> = ({
</div> </div>
<p className={`ptd-left-status ${paperAutoTradeEnabled ? 'ptd-left-status--on' : ''}`}> <p className={`ptd-left-status ${paperAutoTradeEnabled ? 'ptd-left-status--on' : ''}`}>
{paperAutoTradeEnabled {paperAutoTradeEnabled
? '자동매매 ON — 가상투자 Match 시 가상 계좌 자동 체결' ? '자동매매 ON — 가상 Match 후 서버가 전략 시그널·모의 체결(화면 꺼져도 동작)'
: '자동매매 OFF — 가상투자 타이틀바에서 변경'} : '자동매매 OFF — 수동 주문만 모의 계좌에 체결'}
</p> </p>
</> </>
); );
@@ -110,7 +110,7 @@ export const VirtualSessionHeaderControls: React.FC<SessionProps> = ({
!paperTradingEnabled !paperTradingEnabled
? '설정 › 가상투자에서 가상매매를 활성화하세요' ? '설정 › 가상투자에서 가상매매를 활성화하세요'
: paperAutoTradeEnabled : paperAutoTradeEnabled
? 'Match 충족 시 자동 매수·매도' ? 'Match 시 서버 자동 체결(모의·자동매매 ON, 화면 불필요)'
: '수동 주문만 가능' : '수동 주문만 가능'
} }
> >
@@ -1,152 +0,0 @@
/**
* 가상투자 자동매매 — 백엔드 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,
]);
}
@@ -14,7 +14,6 @@ import {
type StrategyDto, type StrategyDto,
} from '../utils/backendApi'; } from '../utils/backendApi';
import { useVirtualIndicatorSnapshots } from './useVirtualIndicatorSnapshots'; import { useVirtualIndicatorSnapshots } from './useVirtualIndicatorSnapshots';
import { useVirtualBackendTradeSignals } from './useVirtualBackendTradeSignals';
import { useVirtualTargetLiveStatus } from './useVirtualTargetLiveStatus'; import { useVirtualTargetLiveStatus } from './useVirtualTargetLiveStatus';
import { import {
loadVirtualSession, loadVirtualSession,
@@ -206,16 +205,6 @@ export function useVirtualTradingCore(options: UseVirtualTradingCoreOptions = {}
session.running, session.running,
); );
useVirtualBackendTradeSignals({
enabled: paperTradingEnabled && paperAutoTradeEnabled,
session,
targets,
tickers,
paperAutoTradeBudgetPct,
positions: summary?.positions,
onFilled: () => void refreshPaperData(),
});
const mergedLiveStatus = useMemo(() => { const mergedLiveStatus = useMemo(() => {
if (!session.running) return liveStatusByMarket; if (!session.running) return liveStatusByMarket;
const merged = { ...liveStatusByMarket }; const merged = { ...liveStatusByMarket };
@@ -3,7 +3,10 @@ import { pinStrategyEvaluationTimeframes } from './strategyTimeframePin';
import type { VirtualSessionConfig, VirtualTargetItem } from './virtualTradingStorage'; import type { VirtualSessionConfig, VirtualTargetItem } from './virtualTradingStorage';
import { resolveVirtualTargetStrategyId } from './virtualTargetStrategy'; import { resolveVirtualTargetStrategyId } from './virtualTargetStrategy';
/** 가상투자 대상 종목을 백엔드 live strategy 설정과 동기화 */ /**
* 가상투자 대상 → 백엔드 live strategy·모의 한도 동기화.
* 전략 평가·모의 자동 체결은 BarClose/LiveStrategyScheduler → OrderExecutionQueue 에서만 수행.
*/
export async function syncVirtualTargetsToBackend( export async function syncVirtualTargetsToBackend(
targets: VirtualTargetItem[], targets: VirtualTargetItem[],
session: VirtualSessionConfig, session: VirtualSessionConfig,