diff --git a/backend/src/main/java/com/goldenchart/service/PaperTradingService.java b/backend/src/main/java/com/goldenchart/service/PaperTradingService.java index 3d2f0eb..0f3dc8b 100644 --- a/backend/src/main/java/com/goldenchart/service/PaperTradingService.java +++ b/backend/src/main/java/com/goldenchart/service/PaperTradingService.java @@ -263,12 +263,26 @@ public class PaperTradingService { GcAppSettings app = appSettingsService.getEntity(userId, deviceId); if (!Boolean.TRUE.equals(app.getPaperTradingEnabled())) return; if (!Boolean.TRUE.equals(app.getPaperAutoTradeEnabled())) return; - boolean marketActive = liveSettingsRepo.findActiveByMarket(market).stream() - .anyMatch(s -> Boolean.TRUE.equals(s.getIsLiveCheck())); + List liveForMarket = liveSettingsRepo.findActiveByMarket(market).stream() + .filter(s -> Boolean.TRUE.equals(s.getIsLiveCheck())) + .toList(); + boolean marketActive = !liveForMarket.isEmpty(); if (!Boolean.TRUE.equals(app.getLiveStrategyCheck()) && !marketActive) return; try { 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 slip = pct(app.getPaperSlippagePct(), 0); double minOrder = app.getPaperMinOrderKrw() != null diff --git a/frontend/src/components/PaperTradingPage.tsx b/frontend/src/components/PaperTradingPage.tsx index 8f33859..871e9b3 100644 --- a/frontend/src/components/PaperTradingPage.tsx +++ b/frontend/src/components/PaperTradingPage.tsx @@ -117,9 +117,8 @@ const PaperTradingPage: React.FC = ({ } finally { setInitialLoading(false); } - onPaperOrderFilled?.(); if (opts?.focusHistory) setRightTab('history'); - }, [onPaperOrderFilled]); + }, []); useEffect(() => { void Promise.all([ diff --git a/frontend/src/components/VirtualTradingPage.tsx b/frontend/src/components/VirtualTradingPage.tsx index ef62132..f7d1f50 100644 --- a/frontend/src/components/VirtualTradingPage.tsx +++ b/frontend/src/components/VirtualTradingPage.tsx @@ -28,7 +28,7 @@ import { } from './virtual/VirtualGridUnifiedHeader'; import type { VirtualCardDisplayMode } from './virtual/VirtualTargetCard'; import { useVirtualIndicatorSnapshots } from '../hooks/useVirtualIndicatorSnapshots'; -import { useVirtualBackendTradeSignals } from '../hooks/useVirtualBackendTradeSignals'; +import { PAPER_TRADES_CHANGED_EVENT } from '../utils/paperTradeEvents'; import { useVirtualTargetLiveStatus } from '../hooks/useVirtualTargetLiveStatus'; import type { ChartRealtimeSource } from '../hooks/useChartRealtimeData'; import { @@ -195,15 +195,17 @@ const VirtualTradingPage: React.FC = ({ session.running, ); - useVirtualBackendTradeSignals({ - enabled: paperTradingEnabled && paperAutoTradeEnabled, - session, - targets, - tickers, - paperAutoTradeBudgetPct, - positions: summary?.positions, - onFilled: () => void refreshPaperData({ focusHistory: true }), - }); + /** 백엔드 자동 체결 후 UI 갱신(프론트는 주문 실행 안 함) */ + useEffect(() => { + if (!session.running) return; + const onTradesChanged = () => { void refreshPaperData(); }; + window.addEventListener(PAPER_TRADES_CHANGED_EVENT, onTradesChanged); + const pollId = window.setInterval(onTradesChanged, 20_000); + return () => { + window.removeEventListener(PAPER_TRADES_CHANGED_EVENT, onTradesChanged); + window.clearInterval(pollId); + }; + }, [session.running, refreshPaperData]); const mergedLiveStatus = useMemo(() => { if (!session.running) return liveStatusByMarket; diff --git a/frontend/src/components/paper/PaperLedgerTab.tsx b/frontend/src/components/paper/PaperLedgerTab.tsx index d06ed31..329747d 100644 --- a/frontend/src/components/paper/PaperLedgerTab.tsx +++ b/frontend/src/components/paper/PaperLedgerTab.tsx @@ -21,7 +21,7 @@ const PaperLedgerTab: React.FC = ({ refreshKey = 0 }) => { useEffect(() => { let cancelled = false; - setLoading(true); + if (entries.length === 0) setLoading(true); void loadPaperLedger({ page: 0, size: 50 }).then(page => { if (!cancelled) { setEntries(page.content); @@ -29,6 +29,8 @@ const PaperLedgerTab: React.FC = ({ refreshKey = 0 }) => { } }); return () => { cancelled = true; }; + // entries.length: 최초 로드만 스피너 — 갱신 시 목록 유지(깜빡임 방지) + // eslint-disable-next-line react-hooks/exhaustive-deps }, [refreshKey]); if (loading) return

원장 로딩…

; diff --git a/frontend/src/components/paper/PaperLeftStrategyTab.tsx b/frontend/src/components/paper/PaperLeftStrategyTab.tsx index 4f8bc0f..698a5af 100644 --- a/frontend/src/components/paper/PaperLeftStrategyTab.tsx +++ b/frontend/src/components/paper/PaperLeftStrategyTab.tsx @@ -85,8 +85,8 @@ const PaperLeftStrategyTab: React.FC = ({

{paperAutoTradeEnabled - ? '자동매매 ON — 가상투자 Match 시 가상 계좌 자동 체결' - : '자동매매 OFF — 가상투자 타이틀바에서 변경'} + ? '자동매매 ON — 가상 Match 후 서버가 전략 시그널·모의 체결(화면 꺼져도 동작)' + : '자동매매 OFF — 수동 주문만 모의 계좌에 체결'}

); diff --git a/frontend/src/components/virtual/VirtualGridUnifiedHeader.tsx b/frontend/src/components/virtual/VirtualGridUnifiedHeader.tsx index 3bdcfc1..7de9a79 100644 --- a/frontend/src/components/virtual/VirtualGridUnifiedHeader.tsx +++ b/frontend/src/components/virtual/VirtualGridUnifiedHeader.tsx @@ -110,7 +110,7 @@ export const VirtualSessionHeaderControls: React.FC = ({ !paperTradingEnabled ? '설정 › 가상투자에서 가상매매를 활성화하세요' : paperAutoTradeEnabled - ? 'Match 충족 시 자동 매수·매도' + ? 'Match 시 서버 자동 체결(모의·자동매매 ON, 화면 불필요)' : '수동 주문만 가능' } > diff --git a/frontend/src/hooks/useVirtualBackendTradeSignals.ts b/frontend/src/hooks/useVirtualBackendTradeSignals.ts deleted file mode 100644 index 410f909..0000000 --- a/frontend/src/hooks/useVirtualBackendTradeSignals.ts +++ /dev/null @@ -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; - paperAutoTradeBudgetPct: number; - positions?: PaperSummaryDto['positions']; - onFilled?: () => void; -} - -export function useVirtualBackendTradeSignals({ - enabled, - session, - targets, - tickers, - paperAutoTradeBudgetPct, - positions = [], - onFilled, -}: Options): void { - const inflightRef = useRef>(new Set()); - const lastExecRef = useRef>({}); - const seenSignalRef = useRef>(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, - ]); -} diff --git a/frontend/src/hooks/useVirtualTradingCore.ts b/frontend/src/hooks/useVirtualTradingCore.ts index 6955566..4dbbafb 100644 --- a/frontend/src/hooks/useVirtualTradingCore.ts +++ b/frontend/src/hooks/useVirtualTradingCore.ts @@ -14,7 +14,6 @@ import { type StrategyDto, } from '../utils/backendApi'; import { useVirtualIndicatorSnapshots } from './useVirtualIndicatorSnapshots'; -import { useVirtualBackendTradeSignals } from './useVirtualBackendTradeSignals'; import { useVirtualTargetLiveStatus } from './useVirtualTargetLiveStatus'; import { loadVirtualSession, @@ -206,16 +205,6 @@ export function useVirtualTradingCore(options: UseVirtualTradingCoreOptions = {} session.running, ); - useVirtualBackendTradeSignals({ - enabled: paperTradingEnabled && paperAutoTradeEnabled, - session, - targets, - tickers, - paperAutoTradeBudgetPct, - positions: summary?.positions, - onFilled: () => void refreshPaperData(), - }); - const mergedLiveStatus = useMemo(() => { if (!session.running) return liveStatusByMarket; const merged = { ...liveStatusByMarket }; diff --git a/frontend/src/utils/virtualLiveStrategySync.ts b/frontend/src/utils/virtualLiveStrategySync.ts index 4e9ada9..f77fd44 100644 --- a/frontend/src/utils/virtualLiveStrategySync.ts +++ b/frontend/src/utils/virtualLiveStrategySync.ts @@ -3,7 +3,10 @@ import { pinStrategyEvaluationTimeframes } from './strategyTimeframePin'; import type { VirtualSessionConfig, VirtualTargetItem } from './virtualTradingStorage'; import { resolveVirtualTargetStrategyId } from './virtualTargetStrategy'; -/** 가상투자 대상 종목을 백엔드 live strategy 설정과 동기화 */ +/** + * 가상투자 대상 → 백엔드 live strategy·모의 한도 동기화. + * 전략 평가·모의 자동 체결은 BarClose/LiveStrategyScheduler → OrderExecutionQueue 에서만 수행. + */ export async function syncVirtualTargetsToBackend( targets: VirtualTargetItem[], session: VirtualSessionConfig,