가상투자 로직 변경
This commit is contained in:
@@ -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<GcLiveStrategySettings> 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
|
||||
|
||||
@@ -117,9 +117,8 @@ const PaperTradingPage: React.FC<Props> = ({
|
||||
} finally {
|
||||
setInitialLoading(false);
|
||||
}
|
||||
onPaperOrderFilled?.();
|
||||
if (opts?.focusHistory) setRightTab('history');
|
||||
}, [onPaperOrderFilled]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void Promise.all([
|
||||
|
||||
@@ -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<Props> = ({
|
||||
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;
|
||||
|
||||
@@ -21,7 +21,7 @@ const PaperLedgerTab: React.FC<Props> = ({ 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<Props> = ({ refreshKey = 0 }) => {
|
||||
}
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
// entries.length: 최초 로드만 스피너 — 갱신 시 목록 유지(깜빡임 방지)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [refreshKey]);
|
||||
|
||||
if (loading) return <p className="ptd-muted">원장 로딩…</p>;
|
||||
|
||||
@@ -85,8 +85,8 @@ const PaperLeftStrategyTab: React.FC<Props> = ({
|
||||
</div>
|
||||
<p className={`ptd-left-status ${paperAutoTradeEnabled ? 'ptd-left-status--on' : ''}`}>
|
||||
{paperAutoTradeEnabled
|
||||
? '자동매매 ON — 가상투자 Match 시 가상 계좌 자동 체결'
|
||||
: '자동매매 OFF — 가상투자 타이틀바에서 변경'}
|
||||
? '자동매매 ON — 가상 Match 후 서버가 전략 시그널·모의 체결(화면 꺼져도 동작)'
|
||||
: '자동매매 OFF — 수동 주문만 모의 계좌에 체결'}
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -110,7 +110,7 @@ export const VirtualSessionHeaderControls: React.FC<SessionProps> = ({
|
||||
!paperTradingEnabled
|
||||
? '설정 › 가상투자에서 가상매매를 활성화하세요'
|
||||
: 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,
|
||||
} 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 };
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user