-
-
{row.displayName}
-
{row.side} · {row.timeframe}
+
+
+
+
{row.displayName}
+
{row.side} · {row.timeframe}
+
+
+ {row.satisfied === true ? '충족' : row.satisfied === false ? '미충족' : '—'}
+
-
- {row.satisfied === true ? '충족' : row.satisfied === false ? '미충족' : '—'}
-
))}
diff --git a/app/src/screens/virtual/VirtualHistoryScreen.tsx b/app/src/screens/virtual/VirtualHistoryScreen.tsx
new file mode 100644
index 0000000..d395623
--- /dev/null
+++ b/app/src/screens/virtual/VirtualHistoryScreen.tsx
@@ -0,0 +1,41 @@
+import React from 'react';
+import type { PaperTradeDto } from '../../lib/shared';
+import MobileStackHeader from '../../components/MobileStackHeader';
+
+interface Props {
+ market: string;
+ trades: PaperTradeDto[];
+ onBack: () => void;
+}
+
+export default function VirtualHistoryScreen({ market, trades, onBack }: Props) {
+ const filtered = trades.filter(t => t.symbol === market).slice(0, 50);
+
+ return (
+
+
+
+ {filtered.length === 0 ? (
+
+
내역 없음
+
이 종목의 모의 거래 내역이 없습니다.
+
+ ) : (
+
+ {filtered.map(t => (
+
+
+
+ {t.side === 'BUY' ? '매수' : '매도'}
+
+ {t.quantity} @ ₩{t.price?.toLocaleString()}
+
+
{t.createdAt ?? ''}
+
+ ))}
+
+ )}
+
+
+ );
+}
diff --git a/app/src/screens/virtual/VirtualTargetCardMobile.tsx b/app/src/screens/virtual/VirtualTargetCardMobile.tsx
deleted file mode 100644
index 92f8b41..0000000
--- a/app/src/screens/virtual/VirtualTargetCardMobile.tsx
+++ /dev/null
@@ -1,130 +0,0 @@
-import React from 'react';
-import type { StrategyDto } from '../../lib/shared';
-import type { VirtualIndicatorSnapshot } from '@frontend/hooks/useVirtualIndicatorSnapshots';
-import type { VirtualLiveStatus } from '@frontend/hooks/useVirtualTargetLiveStatus';
-import type { VirtualCardViewMode, VirtualSessionConfig, VirtualTargetItem } from '@frontend/utils/virtualTradingStorage';
-import { resolveVirtualTargetStrategyId } from '@frontend/utils/virtualTargetStrategy';
-import { buildConditionMetrics, computeMatchRate } from '@frontend/utils/virtualSignalMetrics';
-
-interface Props {
- target: VirtualTargetItem;
- session: VirtualSessionConfig;
- strategies: StrategyDto[];
- snapshot?: VirtualIndicatorSnapshot;
- viewMode: VirtualCardViewMode;
- liveFlash?: boolean;
- liveStatus?: VirtualLiveStatus;
- onFocus: () => void;
- onTrade: () => void;
- onRemove: () => void;
- onTogglePin: () => void;
- onStrategyChange: (id: number | null) => void;
- onCandleTypeChange: (ct: string) => void;
-}
-
-export default function VirtualTargetCardMobile({
- target,
- session,
- strategies,
- snapshot,
- viewMode,
- liveFlash,
- liveStatus,
- onFocus,
- onTrade,
- onRemove,
- onTogglePin,
- onStrategyChange,
-}: Props) {
- const strategyId = resolveVirtualTargetStrategyId(target, session.globalStrategyId);
- const strategyName = strategies.find(s => s.id === strategyId)?.name ?? '전략 없음';
- const metrics = snapshot?.rows?.length ? buildConditionMetrics(snapshot.rows) : [];
- const buyPct = computeMatchRate(metrics.filter(m => m.row.side === 'buy'), snapshot?.matchRate);
- const sellPct = computeMatchRate(metrics.filter(m => m.row.side === 'sell'));
-
- return (
-
-
-
-
{target.koreanName ?? target.market}
-
{target.market}
-
-
-
-
{(snapshot?.matchRate ?? buyPct).toFixed(0)}%
-
-
-
-
-
-
- {strategyName} · {snapshot?.timeframe ?? '—'}
-
-
- {viewMode === 'detail' && snapshot?.rows && (
-
- {snapshot.rows.slice(0, 6).map(row => (
-
- {row.displayName}
-
- {row.satisfied === true ? '✓' : row.satisfied === false ? '✗' : '—'}
-
-
- ))}
-
- )}
-
- e.stopPropagation()}>
-
-
-
- {!target.pinned && (
-
- )}
-
-
-
-
- );
-}
diff --git a/app/src/screens/virtual/VirtualTargetListRow.tsx b/app/src/screens/virtual/VirtualTargetListRow.tsx
new file mode 100644
index 0000000..51d4c7d
--- /dev/null
+++ b/app/src/screens/virtual/VirtualTargetListRow.tsx
@@ -0,0 +1,70 @@
+import React from 'react';
+import type { StrategyDto } from '../../lib/shared';
+import type { VirtualIndicatorSnapshot } from '@frontend/hooks/useVirtualIndicatorSnapshots';
+import type { VirtualLiveStatus } from '@frontend/hooks/useVirtualTargetLiveStatus';
+import type { VirtualTargetItem } from '@frontend/utils/virtualTradingStorage';
+import { resolveVirtualTargetStrategyId } from '@frontend/utils/virtualTargetStrategy';
+import type { TradeSide } from '../../contexts/NavigationContext';
+
+interface Props {
+ target: VirtualTargetItem;
+ globalStrategyId: number | null;
+ strategies: StrategyDto[];
+ snapshot?: VirtualIndicatorSnapshot;
+ liveStatus?: VirtualLiveStatus;
+ onDetail: () => void;
+ onTrade: (side: TradeSide) => void;
+ onTogglePin: () => void;
+ onRemove: () => void;
+}
+
+export default function VirtualTargetListRow({
+ target,
+ globalStrategyId,
+ strategies,
+ snapshot,
+ liveStatus,
+ onDetail,
+ onTrade,
+ onTogglePin,
+ onRemove,
+}: Props) {
+ const strategyId = resolveVirtualTargetStrategyId(target, globalStrategyId);
+ const strategyName = strategies.find(s => s.id === strategyId)?.name ?? '전략 없음';
+ const matchPct = snapshot?.matchRate ?? 0;
+
+ return (
+
+
+
+
+
+ {target.koreanName ?? target.market}
+
+
+ {target.market} · {strategyName} · {matchPct.toFixed(0)}%
+
+
+
+
+ {!target.pinned && (
+
+ )}
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/app/src/screens/virtual/VirtualTradePanel.tsx b/app/src/screens/virtual/VirtualTradePanel.tsx
index 17ed454..bad1847 100644
--- a/app/src/screens/virtual/VirtualTradePanel.tsx
+++ b/app/src/screens/virtual/VirtualTradePanel.tsx
@@ -5,29 +5,34 @@ import { Haptics, ImpactStyle } from '@capacitor/haptics';
interface Props {
market: string;
summary: PaperSummaryDto | null;
+ /** 단일 매수/매도 화면 또는 둘 다 */
+ side?: 'BUY' | 'SELL' | 'both';
onOrder: (side: 'BUY' | 'SELL', qty: number, price: number) => Promise
;
}
-export default function VirtualTradePanel({ market, summary, onOrder }: Props) {
+export default function VirtualTradePanel({ market, summary, side = 'both', onOrder }: Props) {
const [qty, setQty] = useState('0.001');
const [price, setPrice] = useState('');
const [busy, setBusy] = useState(false);
const position = summary?.positions?.find(p => p.symbol === market);
- const submit = async (side: 'BUY' | 'SELL') => {
+ const submit = async (orderSide: 'BUY' | 'SELL') => {
const q = parseFloat(qty);
const p = parseFloat(price) || 0;
if (!Number.isFinite(q) || q <= 0) return;
setBusy(true);
try {
- await onOrder(side, q, p);
+ await onOrder(orderSide, q, p);
try { await Haptics.impact({ style: ImpactStyle.Medium }); } catch { /* web */ }
} finally {
setBusy(false);
}
};
+ const showBuy = side === 'both' || side === 'BUY';
+ const showSell = side === 'both' || side === 'SELL';
+
return (
{position && (
@@ -37,12 +42,7 @@ export default function VirtualTradePanel({ market, summary, onOrder }: Props) {
)}
- setQty(e.target.value)}
- style={inputStyle}
- />
+ setQty(e.target.value)} style={inputStyle} />
-
-
-
+
+ {showBuy && (
+
+ )}
+ {showSell && (
+
+ )}
);
diff --git a/app/src/screens/virtual/VirtualTradeScreen.tsx b/app/src/screens/virtual/VirtualTradeScreen.tsx
new file mode 100644
index 0000000..9afe3ce
--- /dev/null
+++ b/app/src/screens/virtual/VirtualTradeScreen.tsx
@@ -0,0 +1,36 @@
+import React from 'react';
+import { placePaperOrder, type PaperSummaryDto } from '../../lib/shared';
+import type { TradeSide } from '../../contexts/NavigationContext';
+import MobileStackHeader from '../../components/MobileStackHeader';
+import VirtualTradePanel from './VirtualTradePanel';
+
+interface Props {
+ market: string;
+ side: TradeSide;
+ summary: PaperSummaryDto | null;
+ onBack: () => void;
+ onOrderDone: () => Promise;
+}
+
+export default function VirtualTradeScreen({ market, side, summary, onBack, onOrderDone }: Props) {
+ return (
+
+
+
+ {
+ await placePaperOrder({ market, side: orderSide, price, quantity: qty });
+ await onOrderDone();
+ }}
+ />
+
+
+ );
+}
diff --git a/app/src/screens/virtual/VirtualTradingScreen.tsx b/app/src/screens/virtual/VirtualTradingScreen.tsx
index 438ebb0..23e06ac 100644
--- a/app/src/screens/virtual/VirtualTradingScreen.tsx
+++ b/app/src/screens/virtual/VirtualTradingScreen.tsx
@@ -1,183 +1,103 @@
-import React, { useCallback, useEffect, useMemo, useState } from 'react';
-import {
- loadPaperSummary,
- loadPaperTrades,
- loadStrategies,
- placePaperOrder,
- resetPaperAccount,
- type PaperSummaryDto,
- type PaperTradeDto,
- type StrategyDto,
-} from '../../lib/shared';
-import { useVirtualIndicatorSnapshots } from '@frontend/hooks/useVirtualIndicatorSnapshots';
-import { useVirtualAutoTrade } from '@frontend/hooks/useVirtualAutoTrade';
-import { useVirtualTargetLiveStatus } from '@frontend/hooks/useVirtualTargetLiveStatus';
-import {
- loadVirtualSession,
- loadVirtualTargets,
- saveVirtualSession,
- saveVirtualTargets,
- loadVirtualCardViewMode,
- saveVirtualCardViewMode,
- type VirtualSessionConfig,
- type VirtualTargetItem,
- type VirtualCardViewMode,
- resolveTargetCandleType,
-} from '@frontend/utils/virtualTradingStorage';
-import {
- syncVirtualTargetsToBackend,
- stopVirtualLiveOnBackend,
-} from '@frontend/utils/virtualLiveStrategySync';
+import React, { useState } from 'react';
+import { useAuth } from '../../contexts/AuthContext';
+import { useVirtualTradingCore } from '@frontend/hooks/useVirtualTradingCore';
import { resolveVirtualTargetStrategyId } from '@frontend/utils/virtualTargetStrategy';
-import { virtualTargetLimitMessage, isVirtualTargetAddAllowed } from '@frontend/utils/virtualTargetLimits';
-import { persistVirtualTargetPinned } from '@frontend/utils/virtualTargetMutations';
-import { useAppSettings, resolveAppDefaults } from '../../hooks/useAppSettings';
import { useNavigation } from '../../contexts/NavigationContext';
import BottomSheet from '../../components/BottomSheet';
import SegmentedControl from '../../components/SegmentedControl';
-import VirtualTargetCardMobile from './VirtualTargetCardMobile';
+import VirtualTargetListRow from './VirtualTargetListRow';
import VirtualFocusScreen from './VirtualFocusScreen';
-import VirtualTradePanel from './VirtualTradePanel';
-
-type RightTab = 'trade' | 'history';
+import VirtualTradeScreen from './VirtualTradeScreen';
+import VirtualHistoryScreen from './VirtualHistoryScreen';
+import { MarketSearchPanel } from '@frontend/components/MarketSearchPanel';
export default function VirtualTradingScreen() {
- const { focusMarket, clearVirtualFocus, openVirtualFocus } = useNavigation();
- const { settings } = useAppSettings();
- const defaults = resolveAppDefaults(settings);
+ const {
+ virtualNav,
+ goVirtualList,
+ goVirtualDetail,
+ goVirtualTrade,
+ goVirtualHistory,
+ } = useNavigation();
+ const { sessionKey: settingsSessionKey, authUser, guestMode } = useAuth();
- const [targets, setTargets] = useState(() => loadVirtualTargets());
- const [session, setSession] = useState(() => loadVirtualSession());
- const [strategies, setStrategies] = useState([]);
- const [summary, setSummary] = useState(null);
- const [trades, setTrades] = useState([]);
- const [loading, setLoading] = useState(true);
- const [selectedMarket, setSelectedMarket] = useState('KRW-BTC');
- const [viewMode, setViewMode] = useState(() => loadVirtualCardViewMode());
- const [sheetOpen, setSheetOpen] = useState(false);
- const [addSheetOpen, setAddSheetOpen] = useState(false);
- const [rightTab, setRightTab] = useState('trade');
- const [newMarket, setNewMarket] = useState('');
-
- const refreshSummary = useCallback(async () => {
- const [s, t] = await Promise.all([loadPaperSummary(), loadPaperTrades()]);
- setSummary(s);
- setTrades(t ?? []);
- }, []);
-
- useEffect(() => {
- void Promise.all([
- loadStrategies().then(setStrategies).catch(() => []),
- refreshSummary(),
- ]).finally(() => setLoading(false));
- }, [refreshSummary]);
-
- useEffect(() => { saveVirtualTargets(targets); }, [targets]);
- useEffect(() => { saveVirtualSession(session); }, [session]);
- useEffect(() => { saveVirtualCardViewMode(viewMode); }, [viewMode]);
-
- const targetRefs = useMemo(
- () => targets.map(t => ({
- market: t.market,
- strategyId: resolveVirtualTargetStrategyId(t, session.globalStrategyId),
- })),
- [targets, session.globalStrategyId],
- );
-
- const snapshots = useVirtualIndicatorSnapshots(
- targetRefs,
- strategies as Parameters[1],
- session.running,
- );
-
- const liveStatus = useVirtualTargetLiveStatus(targetRefs, session.running);
-
- const liveConnected = useMemo(
- () => Object.values(liveStatus.statusByMarket).some(s => s === 'live' || s === 'connecting'),
- [liveStatus.statusByMarket],
- );
-
- useVirtualAutoTrade({
+ const core = useVirtualTradingCore({ settingsSessionKey });
+ const {
targets,
session,
+ strategies,
+ summary,
+ trades,
+ loading,
snapshots,
- enabled: defaults.paperAutoTradeEnabled && defaults.paperTradingEnabled,
- paperAutoTradeBudgetPct: defaults.paperAutoTradeBudgetPct ?? 95,
- positions: summary?.positions,
- onFilled: refreshSummary,
- });
+ liveStatusByMarket,
+ handleStart,
+ handleStop,
+ handleRemoveTarget,
+ handleTogglePin,
+ handleGlobalStrategyChange,
+ handleExecutionTypeChange,
+ handlePositionModeChange,
+ addTarget,
+ reloadTargetsFromStorage,
+ } = core;
- const toggleRunning = useCallback(async () => {
- const next = { ...session, running: !session.running };
- setSession(next);
- if (next.running) {
- await syncVirtualTargetsToBackend(targets, next, true);
- } else {
- await stopVirtualLiveOnBackend(targets, next);
- }
- }, [session, targets]);
-
- const handleAddTarget = useCallback(() => {
- const market = newMarket.trim().toUpperCase();
- if (!market) return;
- if (targets.some(t => t.market === market)) return;
- if (!isVirtualTargetAddAllowed(targets.length, defaults.virtualTargetMaxCount)) {
- alert(virtualTargetLimitMessage(defaults.virtualTargetMaxCount));
- return;
- }
- setTargets(prev => [...prev, { market, strategyId: null }]);
- setNewMarket('');
- setAddSheetOpen(false);
- }, [newMarket, targets, defaults.virtualTargetMaxCount]);
-
- const handleRemoveTarget = useCallback((market: string) => {
- const t = targets.find(x => x.market === market);
- if (t?.pinned) return;
- setTargets(prev => prev.filter(x => x.market !== market));
- }, [targets]);
-
- const handleTogglePin = useCallback(async (market: string) => {
- const t = targets.find(x => x.market === market);
- if (!t) return;
- const pinned = !t.pinned;
- setTargets(prev => prev.map(x => (x.market === market ? { ...x, pinned } : x)));
- await persistVirtualTargetPinned(market, pinned);
- }, [targets]);
+ const [addSheetOpen, setAddSheetOpen] = useState(false);
const pnlPct = summary?.totalReturnPct ?? 0;
+ const liveConnected = Object.values(liveStatusByMarket).some(s => s === 'live' || s === 'connecting');
- if (focusMarket) {
- const target = targets.find(t => t.market === focusMarket);
- if (target) {
- return (
- {
- const sid = resolveVirtualTargetStrategyId(target, session.globalStrategyId);
- return snapshots[`${target.market}:${sid ?? ''}`] ?? snapshots[target.market];
- })()}
- liveConnected={Object.values(liveStatus.statusByMarket).some(s => s === 'live')}
- onBack={clearVirtualFocus}
- onOpenTrade={() => {
- setSelectedMarket(focusMarket);
- setSheetOpen(true);
- }}
- />
- );
- }
- clearVirtualFocus();
+ const market = virtualNav.market;
+ const target = market ? targets.find(t => t.market === market) : undefined;
+
+ if (virtualNav.view === 'detail' && market && target) {
+ const sid = resolveVirtualTargetStrategyId(target, session.globalStrategyId);
+ const snap = snapshots[`${market}:${sid ?? ''}`] ?? snapshots[market];
+ return (
+ goVirtualTrade(market, side)}
+ onHistory={() => goVirtualHistory(market)}
+ />
+ );
+ }
+
+ if (virtualNav.view === 'trade' && market && virtualNav.tradeSide) {
+ return (
+
+ );
+ }
+
+ if (virtualNav.view === 'history' && market) {
+ return (
+
+ );
}
return (
가상매매
+
@@ -187,9 +107,7 @@ export default function VirtualTradingScreen() {
총 자산
-
- ₩{(summary?.totalAsset ?? 0).toLocaleString()}
-
+
₩{(summary?.totalAsset ?? 0).toLocaleString()}
수익률
@@ -207,7 +125,7 @@ export default function VirtualTradingScreen() {
@@ -234,16 +152,7 @@ export default function VirtualTradingScreen() {
{ value: 'SIGNAL_ONLY' as const, label: '시그널' },
]}
value={session.positionMode}
- onChange={v => setSession(s => ({ ...s, positionMode: v }))}
- />
-
setViewMode(v)}
+ onChange={handlePositionModeChange}
/>
@@ -252,30 +161,30 @@ export default function VirtualTradingScreen() {
) : targets.length === 0 ? (
투자 대상 없음
-
+ 버튼으로 종목을 추가하세요
+ {guestMode ? (
+
게스트 모드입니다. 웹과 같은 목록을 보려면 동일 계정으로 로그인하세요.
+ ) : (
+
웹과 동일 계정({authUser?.username}) · + 버튼으로 종목 추가
+ )}
) : (
-
- {targets.map(target => {
- const strategyId = resolveVirtualTargetStrategyId(target, session.globalStrategyId);
- const snapKey = `${target.market}:${strategyId ?? ''}`;
- const snap = snapshots[snapKey] ?? snapshots[target.market];
+
+ {targets.map(t => {
+ const strategyId = resolveVirtualTargetStrategyId(t, session.globalStrategyId);
+ const snapKey = `${t.market}:${strategyId ?? ''}`;
+ const snap = snapshots[snapKey] ?? snapshots[t.market];
return (
-
openVirtualFocus(target.market)}
- onTrade={() => { setSelectedMarket(target.market); setSheetOpen(true); }}
- onRemove={() => handleRemoveTarget(target.market)}
- onTogglePin={() => void handleTogglePin(target.market)}
- onStrategyChange={sid => setTargets(prev => prev.map(t => t.market === target.market ? { ...t, strategyId: sid } : t))}
- onCandleTypeChange={ct => setTargets(prev => prev.map(t => t.market === target.market ? { ...t, candleType: ct } : t))}
+ liveStatus={liveStatusByMarket[t.market]}
+ onDetail={() => goVirtualDetail(t.market)}
+ onTrade={side => goVirtualTrade(t.market, side)}
+ onTogglePin={() => void handleTogglePin(t.market)}
+ onRemove={() => handleRemoveTarget(t.market)}
/>
);
})}
@@ -284,51 +193,18 @@ export default function VirtualTradingScreen() {
- setAddSheetOpen(false)}>
- setNewMarket(e.target.value)}
- style={{
- width: '100%',
- padding: '12px 14px',
- borderRadius: 10,
- border: '1px solid var(--gc-border)',
- background: 'rgba(255,255,255,0.05)',
- marginBottom: 12,
- }}
- />
-
-
-
- setSheetOpen(false)} height="half">
-
-
-
-
- {rightTab === 'trade' ? (
- {
- await placePaperOrder({ market: selectedMarket, side, price, quantity: qty });
- await refreshSummary();
+ setAddSheetOpen(false)} height="full">
+
+
{
+ addTarget(m);
+ setAddSheetOpen(false);
}}
+ onClose={() => setAddSheetOpen(false)}
/>
- ) : (
-
- {trades.filter(t => t.symbol === selectedMarket).slice(0, 30).map(t => (
-
-
- {t.side}
- {t.quantity} @ ₩{t.price?.toLocaleString()}
-
-
{t.createdAt ?? ''}
-
- ))}
-
- )}
+