앱 수정

This commit is contained in:
Macbook
2026-05-28 15:37:02 +09:00
parent 3503ef33f5
commit f64dc1e983
21 changed files with 1434 additions and 755 deletions
+109 -233
View File
@@ -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<VirtualTargetItem[]>(() => loadVirtualTargets());
const [session, setSession] = useState<VirtualSessionConfig>(() => loadVirtualSession());
const [strategies, setStrategies] = useState<StrategyDto[]>([]);
const [summary, setSummary] = useState<PaperSummaryDto | null>(null);
const [trades, setTrades] = useState<PaperTradeDto[]>([]);
const [loading, setLoading] = useState(true);
const [selectedMarket, setSelectedMarket] = useState('KRW-BTC');
const [viewMode, setViewMode] = useState<VirtualCardViewMode>(() => loadVirtualCardViewMode());
const [sheetOpen, setSheetOpen] = useState(false);
const [addSheetOpen, setAddSheetOpen] = useState(false);
const [rightTab, setRightTab] = useState<RightTab>('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<typeof useVirtualIndicatorSnapshots>[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 (
<VirtualFocusScreen
target={target}
session={session}
strategies={strategies}
snapshot={(() => {
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 (
<VirtualFocusScreen
target={target}
session={session}
strategies={strategies}
snapshot={snap}
liveConnected={liveConnected}
onBack={goVirtualList}
onTrade={side => goVirtualTrade(market, side)}
onHistory={() => goVirtualHistory(market)}
/>
);
}
if (virtualNav.view === 'trade' && market && virtualNav.tradeSide) {
return (
<VirtualTradeScreen
market={market}
side={virtualNav.tradeSide}
summary={summary}
onBack={goVirtualList}
onOrderDone={core.refreshPaperData}
/>
);
}
if (virtualNav.view === 'history' && market) {
return (
<VirtualHistoryScreen
market={market}
trades={trades}
onBack={goVirtualList}
/>
);
}
return (
<div className="screen virtual-screen">
<header className="screen-header">
<h1 className="screen-title"></h1>
<button type="button" className="chip" onClick={() => reloadTargetsFromStorage()} title="동기화"></button>
<button
type="button"
className={`btn-primary session-btn${session.running ? ' running' : ''}`}
onClick={() => void toggleRunning()}
onClick={() => void (session.running ? handleStop() : handleStart())}
>
{session.running ? '■ 중지' : '▶ 시작'}
</button>
@@ -187,9 +107,7 @@ export default function VirtualTradingScreen() {
<div className="virtual-summary-row">
<div>
<div className="text-muted" style={{ fontSize: 12 }}> </div>
<div style={{ fontSize: 22, fontWeight: 700 }}>
{(summary?.totalAsset ?? 0).toLocaleString()}
</div>
<div style={{ fontSize: 22, fontWeight: 700 }}>{(summary?.totalAsset ?? 0).toLocaleString()}</div>
</div>
<div style={{ textAlign: 'right' }}>
<div className="text-muted" style={{ fontSize: 12 }}></div>
@@ -207,7 +125,7 @@ export default function VirtualTradingScreen() {
<select
className="chip"
value={session.globalStrategyId ?? ''}
onChange={e => setSession(s => ({ ...s, globalStrategyId: e.target.value ? Number(e.target.value) : null }))}
onChange={e => handleGlobalStrategyChange(e.target.value ? Number(e.target.value) : null)}
style={{ maxWidth: 160 }}
>
<option value=""> </option>
@@ -222,7 +140,7 @@ export default function VirtualTradingScreen() {
{ value: 'REALTIME_TICK' as const, label: '실시간' },
]}
value={session.executionType}
onChange={v => setSession(s => ({ ...s, executionType: v }))}
onChange={handleExecutionTypeChange}
/>
</div>
@@ -234,16 +152,7 @@ export default function VirtualTradingScreen() {
{ value: 'SIGNAL_ONLY' as const, label: '시그널' },
]}
value={session.positionMode}
onChange={v => setSession(s => ({ ...s, positionMode: v }))}
/>
<SegmentedControl
size="sm"
options={[
{ value: 'summary' as const, label: '요약' },
{ value: 'detail' as const, label: '상세' },
]}
value={viewMode}
onChange={(v: VirtualCardViewMode) => setViewMode(v)}
onChange={handlePositionModeChange}
/>
</div>
@@ -252,30 +161,30 @@ export default function VirtualTradingScreen() {
) : targets.length === 0 ? (
<div className="empty-state">
<h3> </h3>
<p>+ </p>
{guestMode ? (
<p> . .</p>
) : (
<p> ({authUser?.username}) · + </p>
)}
</div>
) : (
<div className="virtual-cards" style={{ padding: '0 16px', display: 'flex', flexDirection: 'column', gap: 12 }}>
{targets.map(target => {
const strategyId = resolveVirtualTargetStrategyId(target, session.globalStrategyId);
const snapKey = `${target.market}:${strategyId ?? ''}`;
const snap = snapshots[snapKey] ?? snapshots[target.market];
<div className="stack-list" style={{ padding: '0 16px' }}>
{targets.map(t => {
const strategyId = resolveVirtualTargetStrategyId(t, session.globalStrategyId);
const snapKey = `${t.market}:${strategyId ?? ''}`;
const snap = snapshots[snapKey] ?? snapshots[t.market];
return (
<VirtualTargetCardMobile
key={target.market}
target={target}
session={session}
<VirtualTargetListRow
key={t.market}
target={t}
globalStrategyId={session.globalStrategyId}
strategies={strategies}
snapshot={snap}
viewMode={viewMode}
liveFlash={!!liveStatus.lastTickAtByMarket[target.market]}
liveStatus={liveStatus.statusByMarket[target.market]}
onFocus={() => 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() {
<button type="button" className="fab" aria-label="종목 추가" onClick={() => setAddSheetOpen(true)}>+</button>
<BottomSheet open={addSheetOpen} title="종목 추가" onClose={() => setAddSheetOpen(false)}>
<input
type="text"
placeholder="KRW-BTC"
value={newMarket}
onChange={e => 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,
}}
/>
<button type="button" className="btn-primary" style={{ width: '100%' }} onClick={handleAddTarget}></button>
</BottomSheet>
<BottomSheet open={sheetOpen} title={selectedMarket} onClose={() => setSheetOpen(false)} height="half">
<div className="chip-row" style={{ padding: '0 0 12px' }}>
<button type="button" className={`chip${rightTab === 'trade' ? ' active' : ''}`} onClick={() => setRightTab('trade')}></button>
<button type="button" className={`chip${rightTab === 'history' ? ' active' : ''}`} onClick={() => setRightTab('history')}></button>
</div>
{rightTab === 'trade' ? (
<VirtualTradePanel
market={selectedMarket}
summary={summary}
onOrder={async (side, qty, price) => {
await placePaperOrder({ market: selectedMarket, side, price, quantity: qty });
await refreshSummary();
<BottomSheet open={addSheetOpen} title="종목 검색" onClose={() => setAddSheetOpen(false)} height="full">
<div style={{ height: 'min(70vh, 520px)', overflow: 'hidden' }}>
<MarketSearchPanel
variant="embedded"
currentMarket={targets[0]?.market ?? 'KRW-BTC'}
onSelect={m => {
addTarget(m);
setAddSheetOpen(false);
}}
onClose={() => setAddSheetOpen(false)}
/>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{trades.filter(t => t.symbol === selectedMarket).slice(0, 30).map(t => (
<div key={t.id} className="card" style={{ padding: 12 }}>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<span className={t.side === 'BUY' ? 'text-green' : 'text-red'}>{t.side}</span>
<span>{t.quantity} @ {t.price?.toLocaleString()}</span>
</div>
<div className="text-muted" style={{ fontSize: 11, marginTop: 4 }}>{t.createdAt ?? ''}</div>
</div>
))}
</div>
)}
</div>
</BottomSheet>
<style>{`