219 lines
7.8 KiB
TypeScript
219 lines
7.8 KiB
TypeScript
import React, { useState } from 'react';
|
|
import { useAuth } from '../../contexts/AuthContext';
|
|
import { useAppSettings } from '../../hooks/useAppSettings';
|
|
import { useVirtualTradingCore } from '@frontend/hooks/useVirtualTradingCore';
|
|
import { resolveVirtualTargetStrategyId } from '@frontend/utils/virtualTargetStrategy';
|
|
import { useNavigation } from '../../contexts/NavigationContext';
|
|
import BottomSheet from '../../components/BottomSheet';
|
|
import SegmentedControl from '../../components/SegmentedControl';
|
|
import VirtualTargetListRow from './VirtualTargetListRow';
|
|
import VirtualFocusScreen from './VirtualFocusScreen';
|
|
import VirtualTradeScreen from './VirtualTradeScreen';
|
|
import VirtualHistoryScreen from './VirtualHistoryScreen';
|
|
import { MarketSearchPanel } from '@frontend/components/MarketSearchPanel';
|
|
|
|
export default function VirtualTradingScreen() {
|
|
const {
|
|
virtualNav,
|
|
goVirtualList,
|
|
goVirtualDetail,
|
|
goVirtualTrade,
|
|
goVirtualHistory,
|
|
} = useNavigation();
|
|
const { sessionKey: settingsSessionKey, authUser, guestMode } = useAuth();
|
|
const { isLoaded: settingsLoaded } = useAppSettings(settingsSessionKey);
|
|
|
|
const core = useVirtualTradingCore({ settingsSessionKey });
|
|
const {
|
|
targets,
|
|
session,
|
|
strategies,
|
|
summary,
|
|
trades,
|
|
loading,
|
|
snapshots,
|
|
liveStatusByMarket,
|
|
handleStart,
|
|
handleStop,
|
|
handleRemoveTarget,
|
|
handleTogglePin,
|
|
handleGlobalStrategyChange,
|
|
handleExecutionTypeChange,
|
|
handlePositionModeChange,
|
|
addTarget,
|
|
reloadTargetsFromStorage,
|
|
} = core;
|
|
|
|
const [addSheetOpen, setAddSheetOpen] = useState(false);
|
|
|
|
const pnlPct = summary?.totalReturnPct ?? 0;
|
|
const liveConnected = Object.values(liveStatusByMarket).some(s => s === 'live' || s === 'connecting');
|
|
|
|
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 (session.running ? handleStop() : handleStart())}
|
|
>
|
|
{session.running ? '■ 중지' : '▶ 시작'}
|
|
</button>
|
|
</header>
|
|
|
|
<div className="virtual-summary card" style={{ margin: '0 16px 12px' }}>
|
|
<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>
|
|
<div style={{ textAlign: 'right' }}>
|
|
<div className="text-muted" style={{ fontSize: 12 }}>수익률</div>
|
|
<div className={`${pnlPct >= 0 ? 'text-green' : 'text-red'}`} style={{ fontSize: 22, fontWeight: 700 }}>
|
|
{pnlPct >= 0 ? '+' : ''}{pnlPct.toFixed(2)}%
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{!liveConnected && session.running && (
|
|
<div className="connection-banner" style={{ marginTop: 8, borderRadius: 8 }}>실시간 연결 끊김</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="chip-row">
|
|
<select
|
|
className="chip"
|
|
value={session.globalStrategyId ?? ''}
|
|
onChange={e => handleGlobalStrategyChange(e.target.value ? Number(e.target.value) : null)}
|
|
style={{ maxWidth: 160 }}
|
|
>
|
|
<option value="">전략 선택</option>
|
|
{strategies.map(s => (
|
|
<option key={s.id} value={s.id}>{s.name}</option>
|
|
))}
|
|
</select>
|
|
<SegmentedControl
|
|
size="sm"
|
|
options={[
|
|
{ value: 'CANDLE_CLOSE' as const, label: '봉마감' },
|
|
{ value: 'REALTIME_TICK' as const, label: '실시간' },
|
|
]}
|
|
value={session.executionType}
|
|
onChange={handleExecutionTypeChange}
|
|
/>
|
|
</div>
|
|
|
|
<div className="chip-row">
|
|
<SegmentedControl
|
|
size="sm"
|
|
options={[
|
|
{ value: 'LONG_ONLY' as const, label: '롱온리' },
|
|
{ value: 'SIGNAL_ONLY' as const, label: '시그널' },
|
|
]}
|
|
value={session.positionMode}
|
|
onChange={handlePositionModeChange}
|
|
/>
|
|
</div>
|
|
|
|
{!settingsLoaded || loading ? (
|
|
<div className="loading-center">{!settingsLoaded ? '설정 동기화 중…' : '로딩 중…'}</div>
|
|
) : targets.length === 0 ? (
|
|
<div className="empty-state">
|
|
<h3>투자 대상 없음</h3>
|
|
{guestMode ? (
|
|
<p>게스트 모드입니다. 웹과 같은 목록을 보려면 <strong>동일 계정으로 로그인</strong>하세요.</p>
|
|
) : (
|
|
<p>웹({authUser?.username})과 동일 DB입니다. ↻ 동기화 또는 + 로 종목 추가</p>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<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 (
|
|
<VirtualTargetListRow
|
|
key={t.market}
|
|
target={t}
|
|
globalStrategyId={session.globalStrategyId}
|
|
strategies={strategies}
|
|
snapshot={snap}
|
|
liveStatus={liveStatusByMarket[t.market]}
|
|
onDetail={() => goVirtualDetail(t.market)}
|
|
onTrade={side => goVirtualTrade(t.market, side)}
|
|
onTogglePin={() => void handleTogglePin(t.market)}
|
|
onRemove={() => handleRemoveTarget(t.market)}
|
|
/>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
<button type="button" className="fab" aria-label="종목 추가" onClick={() => setAddSheetOpen(true)}>+</button>
|
|
|
|
<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>
|
|
</BottomSheet>
|
|
|
|
<style>{`
|
|
.session-btn.running { background: var(--gc-red); }
|
|
.virtual-summary-row { display: flex; justify-content: space-between; align-items: flex-start; }
|
|
`}</style>
|
|
</div>
|
|
);
|
|
}
|