551 lines
21 KiB
TypeScript
551 lines
21 KiB
TypeScript
/**
|
|
* 가상투자 대시보드 — 모의투자 레이아웃 기반, 다종목 전략 모니터링
|
|
*/
|
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
import {
|
|
loadPaperSummary,
|
|
loadPaperTrades,
|
|
loadStrategies,
|
|
saveLiveStrategySettings,
|
|
pinCandleWatch,
|
|
loadLiveStrategySettings,
|
|
type PaperSummaryDto,
|
|
type PaperTradeDto,
|
|
type StrategyDto,
|
|
} from '../utils/backendApi';
|
|
import type { Theme, TradeOrderFillRequest } from '../types';
|
|
import type { TickerData } from '../hooks/useMarketTicker';
|
|
import TradeOrderPanel from './TradeOrderPanel';
|
|
import { OrderbookPanel } from './OrderbookPanel';
|
|
import PaperSplitPanel from './paper/PaperSplitPanel';
|
|
import { useUpbitOrderbook } from '../hooks/useUpbitOrderbook';
|
|
import { useUpbitRecentTrades } from '../hooks/useUpbitRecentTrades';
|
|
import PaperTradeHistoryList from './paper/PaperTradeHistoryList';
|
|
import BuilderPageShell from './layout/BuilderPageShell';
|
|
import VirtualLeftTargetPanel from './virtual/VirtualLeftTargetPanel';
|
|
import VirtualTargetGrid from './virtual/VirtualTargetGrid';
|
|
import {
|
|
VirtualSessionHeaderControls,
|
|
VirtualViewHeaderControls,
|
|
} from './virtual/VirtualGridUnifiedHeader';
|
|
import type { VirtualCardDisplayMode } from './virtual/VirtualTargetCard';
|
|
import { useVirtualIndicatorSnapshots } from '../hooks/useVirtualIndicatorSnapshots';
|
|
import { useVirtualBackendTradeSignals } from '../hooks/useVirtualBackendTradeSignals';
|
|
import { useVirtualTargetLiveStatus } from '../hooks/useVirtualTargetLiveStatus';
|
|
import type { ChartRealtimeSource } from '../hooks/useChartRealtimeData';
|
|
import {
|
|
loadVirtualSession,
|
|
loadVirtualTargets,
|
|
saveVirtualSession,
|
|
saveVirtualTargets,
|
|
loadVirtualCardViewMode,
|
|
saveVirtualCardViewMode,
|
|
loadVirtualGridPreset,
|
|
saveVirtualGridPreset,
|
|
type VirtualSessionConfig,
|
|
type VirtualTargetItem,
|
|
type VirtualCardViewMode,
|
|
} from '../utils/virtualTradingStorage';
|
|
import { useAppSettings, resolveAppDefaults } from '../hooks/useAppSettings';
|
|
import { coerceFiniteNumber } from '../utils/safeFormat';
|
|
import type { TradeNotificationGridPresetId } from '../utils/tradeNotificationGridPresets';
|
|
import {
|
|
syncVirtualTargetsToBackend,
|
|
stopVirtualLiveOnBackend,
|
|
} from '../utils/virtualLiveStrategySync';
|
|
import {
|
|
syncStrategyTimeframesFromLayoutIfNeeded,
|
|
warnStrategyTimeframeMismatch,
|
|
} from '../utils/strategyTimeframeSync';
|
|
import { pinStrategyEvaluationTimeframes } from '../utils/strategyTimeframePin';
|
|
import { persistVirtualTargetPinned } from '../utils/virtualTargetMutations';
|
|
import {
|
|
coalesceTargetsToDefaultStrategy,
|
|
resolveVirtualTargetStrategyId,
|
|
} from '../utils/virtualTargetStrategy';
|
|
import { virtualTargetLimitMessage } from '../utils/virtualTargetLimits';
|
|
import '../styles/virtualTradingDashboard.css';
|
|
|
|
type RightTab = 'trade' | 'orderbook' | 'history';
|
|
|
|
interface Props {
|
|
theme?: Theme;
|
|
tickers?: Map<string, TickerData>;
|
|
defaultMarket?: string;
|
|
paperTradingEnabled?: boolean;
|
|
paperAutoTradeEnabled?: boolean;
|
|
paperAutoTradeBudgetPct?: number;
|
|
onPaperAutoTradeEnabled?: (enabled: boolean) => void;
|
|
onPaperOrderFilled?: () => void;
|
|
}
|
|
|
|
const VirtualTradingPage: React.FC<Props> = ({
|
|
theme = 'dark',
|
|
tickers,
|
|
defaultMarket = 'KRW-BTC',
|
|
paperTradingEnabled = true,
|
|
paperAutoTradeEnabled = false,
|
|
paperAutoTradeBudgetPct = 95,
|
|
onPaperAutoTradeEnabled,
|
|
onPaperOrderFilled,
|
|
}) => {
|
|
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(defaultMarket);
|
|
const [rightTab, setRightTab] = useState<RightTab>('trade');
|
|
const [fillBuy, setFillBuy] = useState<TradeOrderFillRequest | null>(null);
|
|
const [fillSell, setFillSell] = useState<TradeOrderFillRequest | null>(null);
|
|
const [viewMode, setViewMode] = useState<VirtualCardViewMode>(() => loadVirtualCardViewMode());
|
|
const [gridPreset, setGridPreset] = useState<TradeNotificationGridPresetId>(() => loadVirtualGridPreset());
|
|
const [globalDisplayMode, setGlobalDisplayMode] = useState<VirtualCardDisplayMode>('signal');
|
|
const [focusMarket, setFocusMarket] = useState<string | null>(null);
|
|
const orderAnchorRef = useRef<HTMLDivElement>(null);
|
|
const { settings: appSettings } = useAppSettings();
|
|
const appChartDefaults = resolveAppDefaults(appSettings);
|
|
const chartRealtimeSource = (appChartDefaults.chartRealtimeSource
|
|
?? 'BACKEND_STOMP') as ChartRealtimeSource;
|
|
const chartCandleAreaPriceLabels = appChartDefaults.chartCandleAreaPriceLabels;
|
|
const chartSeriesPriceLabels = appChartDefaults.chartSeriesPriceLabels;
|
|
const chartPaneSeparator = appChartDefaults.chartPaneSeparator;
|
|
const chartLiveReceiveHighlight = appChartDefaults.chartLiveReceiveHighlight;
|
|
const virtualTargetMaxCount = appChartDefaults.virtualTargetMaxCount;
|
|
|
|
useEffect(() => {
|
|
void Promise.all([
|
|
loadStrategies().then(setStrategies).catch(() => setStrategies([])),
|
|
loadPaperSummary().then(setSummary),
|
|
loadPaperTrades().then(setTrades),
|
|
]).finally(() => setLoading(false));
|
|
}, []);
|
|
|
|
/** DB is_pinned → localStorage 투자대상 목록 동기화 (최초 1회) */
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
const initial = loadVirtualTargets();
|
|
if (initial.length === 0) return;
|
|
void (async () => {
|
|
const merged = await Promise.all(
|
|
initial.map(async t => {
|
|
try {
|
|
const s = await loadLiveStrategySettings(t.market);
|
|
const pinned = !!s.isPinned;
|
|
if (!!t.pinned === pinned) return t;
|
|
return { ...t, pinned };
|
|
} catch {
|
|
return t;
|
|
}
|
|
}),
|
|
);
|
|
if (!cancelled) setTargets(merged);
|
|
})();
|
|
return () => { cancelled = true; };
|
|
}, []);
|
|
|
|
useEffect(() => { saveVirtualTargets(targets); }, [targets]);
|
|
useEffect(() => { saveVirtualSession(session); }, [session]);
|
|
|
|
/** 예전 데이터: 종목 strategyId에 전역 ID가 복사된 경우 → 기본 전략 따름(null) */
|
|
useEffect(() => {
|
|
if (session.globalStrategyId == null) return;
|
|
setTargets(prev => coalesceTargetsToDefaultStrategy(prev, session.globalStrategyId));
|
|
}, [session.globalStrategyId]);
|
|
|
|
useEffect(() => { saveVirtualCardViewMode(viewMode); }, [viewMode]);
|
|
useEffect(() => { saveVirtualGridPreset(gridPreset); }, [gridPreset]);
|
|
|
|
const strategyNames = useMemo(
|
|
() => Object.fromEntries(strategies.map(s => [s.id, s.name])),
|
|
[strategies],
|
|
);
|
|
|
|
const refreshPaperData = useCallback(async (opts?: { focusHistory?: boolean }) => {
|
|
try {
|
|
const [sum, tr] = await Promise.all([loadPaperSummary(), loadPaperTrades()]);
|
|
setSummary(sum);
|
|
setTrades(tr);
|
|
} catch { /* ignore */ }
|
|
onPaperOrderFilled?.();
|
|
if (opts?.focusHistory) setRightTab('history');
|
|
}, [onPaperOrderFilled]);
|
|
|
|
const handleGlobalDisplayModeChange = useCallback((mode: VirtualCardDisplayMode) => {
|
|
setGlobalDisplayMode(mode);
|
|
}, []);
|
|
|
|
const handleGridPresetChange = useCallback((preset: TradeNotificationGridPresetId) => {
|
|
setGridPreset(preset);
|
|
}, []);
|
|
|
|
const targetRefs = useMemo(() =>
|
|
targets.map(t => ({
|
|
market: t.market,
|
|
strategyId: resolveVirtualTargetStrategyId(t, session.globalStrategyId),
|
|
})),
|
|
[targets, session.globalStrategyId]);
|
|
|
|
const { snapshots, loadingByMarket: snapshotLoadingByMarket } = useVirtualIndicatorSnapshots(
|
|
targetRefs,
|
|
strategies,
|
|
session.running,
|
|
);
|
|
const { statusByMarket: liveStatusByMarket, lastTickAtByMarket } = useVirtualTargetLiveStatus(
|
|
targetRefs,
|
|
session.running,
|
|
);
|
|
|
|
useVirtualBackendTradeSignals({
|
|
enabled: paperTradingEnabled && paperAutoTradeEnabled,
|
|
session,
|
|
targets,
|
|
tickers,
|
|
paperAutoTradeBudgetPct,
|
|
positions: summary?.positions,
|
|
onFilled: () => void refreshPaperData({ focusHistory: true }),
|
|
});
|
|
|
|
const mergedLiveStatus = useMemo(() => {
|
|
if (!session.running) return liveStatusByMarket;
|
|
const merged = { ...liveStatusByMarket };
|
|
const now = Date.now();
|
|
for (const [market, snap] of Object.entries(snapshots)) {
|
|
if (snap?.updatedAt && now - snap.updatedAt < 8000) {
|
|
merged[market] = 'live';
|
|
}
|
|
}
|
|
return merged;
|
|
}, [liveStatusByMarket, snapshots, session.running]);
|
|
|
|
const posQty = useMemo(() => {
|
|
const p = summary?.positions?.find(x => x.symbol === selectedMarket);
|
|
return coerceFiniteNumber(p?.quantity) ?? 0;
|
|
}, [summary?.positions, selectedMarket]);
|
|
|
|
const tradePrice = useMemo(
|
|
() => coerceFiniteNumber(tickers?.get(selectedMarket)?.tradePrice),
|
|
[tickers, selectedMarket],
|
|
);
|
|
|
|
const { orderbook, wsStatus: obWsStatus, spread } = useUpbitOrderbook(selectedMarket);
|
|
const { trades: recentTrades, strength: tradeStrength } = useUpbitRecentTrades(selectedMarket);
|
|
const selectedTicker = tickers?.get(selectedMarket);
|
|
const orderbookPrevClose = selectedTicker
|
|
? (selectedTicker.tradePrice ?? 0) - (selectedTicker.changePrice ?? 0)
|
|
: 0;
|
|
const orderbookTickerInfo = selectedTicker ? {
|
|
tradePrice: selectedTicker.tradePrice,
|
|
changeRate: selectedTicker.changeRate,
|
|
changePrice: selectedTicker.changePrice,
|
|
accTradePrice24: selectedTicker.accTradePrice24,
|
|
accTradeVolume24: selectedTicker.accTradeVolume24,
|
|
highPrice: selectedTicker.highPrice,
|
|
lowPrice: selectedTicker.lowPrice,
|
|
openingPrice: selectedTicker.openingPrice,
|
|
} : undefined;
|
|
|
|
const handleSelectMarket = useCallback((market: string) => {
|
|
setSelectedMarket(market);
|
|
const price = tickers?.get(market)?.tradePrice;
|
|
if (price != null && price > 0) {
|
|
const seq = Date.now();
|
|
setFillBuy({ market, price, side: 'buy', seq });
|
|
setFillSell({ market, price, side: 'sell', seq: seq + 1 });
|
|
}
|
|
setRightTab('trade');
|
|
}, [tickers]);
|
|
|
|
const handleTargetsChange = useCallback((items: VirtualTargetItem[]) => {
|
|
if (items.length > virtualTargetMaxCount) {
|
|
window.alert(virtualTargetLimitMessage(virtualTargetMaxCount));
|
|
return;
|
|
}
|
|
setTargets(items);
|
|
if (items.length > 0 && !items.some(t => t.market === selectedMarket)) {
|
|
handleSelectMarket(items[0].market);
|
|
}
|
|
}, [selectedMarket, handleSelectMarket, virtualTargetMaxCount]);
|
|
|
|
const handleTogglePin = useCallback((market: string) => {
|
|
setTargets(prev => {
|
|
const item = prev.find(t => t.market === market);
|
|
if (!item) return prev;
|
|
const pinned = !item.pinned;
|
|
void persistVirtualTargetPinned(market, pinned).catch(() => {
|
|
window.alert('고정 설정 저장에 실패했습니다.');
|
|
});
|
|
return prev.map(t => (t.market === market ? { ...t, pinned } : t));
|
|
});
|
|
}, []);
|
|
|
|
const handleGlobalStrategyChange = useCallback((strategyId: number | null) => {
|
|
const next = { ...session, globalStrategyId: strategyId };
|
|
setSession(next);
|
|
if (session.running && strategyId) {
|
|
void syncVirtualTargetsToBackend(targets, next, true);
|
|
}
|
|
}, [session, targets]);
|
|
|
|
const handleStart = useCallback(async () => {
|
|
if (targets.length === 0) {
|
|
window.alert('투자대상 종목을 먼저 추가하세요.');
|
|
return;
|
|
}
|
|
if (!session.globalStrategyId) {
|
|
window.alert('투자전략을 선택하세요.');
|
|
return;
|
|
}
|
|
const globalStrat = strategies.find(s => s.id === session.globalStrategyId);
|
|
const synced = await syncStrategyTimeframesFromLayoutIfNeeded(
|
|
session.globalStrategyId,
|
|
globalStrat,
|
|
);
|
|
if (!synced) return;
|
|
const ok = await warnStrategyTimeframeMismatch(session.globalStrategyId, globalStrat);
|
|
if (!ok) return;
|
|
try {
|
|
await syncVirtualTargetsToBackend(targets, session, true);
|
|
setSession(s => ({ ...s, running: true }));
|
|
} catch {
|
|
window.alert('가상투자 시작 설정 저장에 실패했습니다.');
|
|
}
|
|
}, [targets, session, strategies]);
|
|
|
|
const handleStop = useCallback(async () => {
|
|
try {
|
|
await stopVirtualLiveOnBackend(targets, session);
|
|
} catch { /* ignore */ }
|
|
setSession(s => ({ ...s, running: false }));
|
|
}, [targets, session]);
|
|
|
|
/** 실시간 차트 우측 호가와 동일: 매수 호가(bid) → 매수 탭, 매도 호가(ask) → 매도 탭 */
|
|
const handleOrderbookRowClick = useCallback((price: number, rowType: 'ask' | 'bid') => {
|
|
setRightTab('trade');
|
|
const side = rowType === 'bid' ? 'buy' : 'sell';
|
|
const req: TradeOrderFillRequest = { market: selectedMarket, price, side, seq: Date.now() };
|
|
if (side === 'buy') setFillBuy(req);
|
|
else setFillSell(req);
|
|
}, [selectedMarket]);
|
|
|
|
const handleTargetStrategyChange = useCallback(async (market: string, strategyId: number | null) => {
|
|
setTargets(prev => prev.map(t =>
|
|
t.market === market ? { ...t, strategyId } : t,
|
|
));
|
|
if (!session.running) return;
|
|
const effectiveId = resolveVirtualTargetStrategyId({ strategyId }, session.globalStrategyId);
|
|
if (effectiveId == null) return;
|
|
try {
|
|
await saveLiveStrategySettings({
|
|
market,
|
|
strategyId: effectiveId,
|
|
isLiveCheck: true,
|
|
executionType: session.executionType,
|
|
positionMode: session.positionMode,
|
|
skipWatchlistSync: true,
|
|
skipGlobalTemplate: true,
|
|
});
|
|
await pinStrategyEvaluationTimeframes(market, effectiveId);
|
|
} catch {
|
|
window.alert('종목 전략 변경 저장에 실패했습니다.');
|
|
}
|
|
}, [session]);
|
|
|
|
const resyncRunningSession = useCallback(async (nextSession: VirtualSessionConfig) => {
|
|
if (!session.running || targets.length === 0) return;
|
|
try {
|
|
await syncVirtualTargetsToBackend(targets, nextSession, true);
|
|
} catch {
|
|
window.alert('가상투자 설정 동기화에 실패했습니다.');
|
|
}
|
|
}, [session.running, targets]);
|
|
|
|
const handleExecutionTypeChange = useCallback((executionType: VirtualSessionConfig['executionType']) => {
|
|
const next = { ...session, executionType };
|
|
setSession(next);
|
|
void resyncRunningSession(next);
|
|
}, [session, resyncRunningSession]);
|
|
|
|
const handlePositionModeChange = useCallback((positionMode: VirtualSessionConfig['positionMode']) => {
|
|
const next = { ...session, positionMode };
|
|
setSession(next);
|
|
void resyncRunningSession(next);
|
|
}, [session, resyncRunningSession]);
|
|
|
|
const rightTabs = (
|
|
<div className="bps-right-tabs">
|
|
<button type="button" className={`bps-right-tab${rightTab === 'trade' ? ' bps-right-tab--on' : ''}`} onClick={() => setRightTab('trade')}>매매</button>
|
|
<button type="button" className={`bps-right-tab${rightTab === 'orderbook' ? ' bps-right-tab--on' : ''}`} onClick={() => setRightTab('orderbook')}>호가</button>
|
|
<button type="button" className={`bps-right-tab${rightTab === 'history' ? ' bps-right-tab--on' : ''}`} onClick={() => setRightTab('history')}>
|
|
거래{trades.length > 0 ? ` (${trades.length})` : ''}
|
|
</button>
|
|
</div>
|
|
);
|
|
|
|
return (
|
|
<BuilderPageShell
|
|
theme={theme}
|
|
title="가상매매"
|
|
subtitle="Virtual Trading"
|
|
pageClassName="bps-page--vtd"
|
|
loading={loading}
|
|
loadingText="가상매매 대시보드 로딩…"
|
|
collapsiblePanels
|
|
headerCenter={(
|
|
<VirtualSessionHeaderControls
|
|
session={session}
|
|
strategies={strategies}
|
|
onStrategyChange={handleGlobalStrategyChange}
|
|
onExecutionTypeChange={handleExecutionTypeChange}
|
|
onPositionModeChange={handlePositionModeChange}
|
|
onStart={() => void handleStart()}
|
|
onStop={() => void handleStop()}
|
|
paperAutoTradeEnabled={paperAutoTradeEnabled}
|
|
onPaperAutoTradeEnabled={onPaperAutoTradeEnabled}
|
|
paperTradingEnabled={paperTradingEnabled}
|
|
/>
|
|
)}
|
|
headerActions={(
|
|
<VirtualViewHeaderControls
|
|
session={session}
|
|
viewMode={viewMode}
|
|
onViewModeChange={setViewMode}
|
|
globalDisplayMode={globalDisplayMode}
|
|
onGlobalDisplayModeChange={handleGlobalDisplayModeChange}
|
|
showSignalChartToggle={!focusMarket}
|
|
gridPreset={gridPreset}
|
|
onGridPresetChange={handleGridPresetChange}
|
|
showGridLayoutPicker={!focusMarket}
|
|
/>
|
|
)}
|
|
leftStorageKey="vtd-left-width"
|
|
leftDefaultWidth={380}
|
|
leftCollapsedStorageKey="vtd-left-open"
|
|
rightStorageKey="vtd-right-width"
|
|
rightDefaultWidth={380}
|
|
rightCollapsedStorageKey="vtd-right-open"
|
|
left={(
|
|
<VirtualLeftTargetPanel
|
|
targets={targets}
|
|
globalStrategyId={session.globalStrategyId}
|
|
strategies={strategies}
|
|
selectedMarket={selectedMarket}
|
|
tickers={tickers}
|
|
onTargetsChange={handleTargetsChange}
|
|
onSelectMarket={handleSelectMarket}
|
|
onTargetStrategyChange={(market, strategyId) => void handleTargetStrategyChange(market, strategyId)}
|
|
onTogglePin={handleTogglePin}
|
|
virtualTargetMaxCount={virtualTargetMaxCount}
|
|
/>
|
|
)}
|
|
center={(
|
|
<VirtualTargetGrid
|
|
targets={targets}
|
|
strategies={strategies}
|
|
snapshots={snapshots}
|
|
snapshotLoadingByMarket={snapshotLoadingByMarket}
|
|
session={session}
|
|
onTargetStrategyChange={(market, strategyId) => void handleTargetStrategyChange(market, strategyId)}
|
|
liveStatusByMarket={mergedLiveStatus}
|
|
lastTickAtByMarket={lastTickAtByMarket}
|
|
selectedMarket={selectedMarket}
|
|
onSelectMarket={handleSelectMarket}
|
|
theme={theme}
|
|
chartRealtimeSource={chartRealtimeSource}
|
|
chartCandleAreaPriceLabels={chartCandleAreaPriceLabels}
|
|
chartSeriesPriceLabels={chartSeriesPriceLabels}
|
|
chartPaneSeparator={chartPaneSeparator}
|
|
chartLiveReceiveHighlight={chartLiveReceiveHighlight}
|
|
tickers={tickers}
|
|
viewMode={viewMode}
|
|
globalDisplayMode={globalDisplayMode}
|
|
onFocusMarketChange={setFocusMarket}
|
|
gridPreset={gridPreset}
|
|
/>
|
|
)}
|
|
rightTabs={rightTabs}
|
|
right={(
|
|
<div className="ptd-right-body" ref={orderAnchorRef}>
|
|
{rightTab === 'trade' ? (
|
|
<PaperSplitPanel
|
|
className="ptd-split-panel--right"
|
|
topTitle="매수"
|
|
bottomTitle="매도"
|
|
top={(
|
|
<div className="ptd-order-card">
|
|
<TradeOrderPanel
|
|
side="buy"
|
|
market={selectedMarket}
|
|
tradePrice={tradePrice}
|
|
availableKrw={coerceFiniteNumber(summary?.cashBalance) ?? 0}
|
|
fillRequest={fillBuy}
|
|
searchAnchorRef={orderAnchorRef}
|
|
onMarketSelect={handleSelectMarket}
|
|
showSymbolField
|
|
paperTradingEnabled={paperTradingEnabled}
|
|
paperAutoTradeEnabled={paperAutoTradeEnabled}
|
|
onPaperOrderFilled={() => void refreshPaperData({ focusHistory: true })}
|
|
/>
|
|
</div>
|
|
)}
|
|
bottom={(
|
|
<div className="ptd-order-card">
|
|
<TradeOrderPanel
|
|
side="sell"
|
|
market={selectedMarket}
|
|
tradePrice={tradePrice}
|
|
availableCoinQty={posQty}
|
|
fillRequest={fillSell}
|
|
onMarketSelect={handleSelectMarket}
|
|
showSymbolField={false}
|
|
paperTradingEnabled={paperTradingEnabled}
|
|
paperAutoTradeEnabled={paperAutoTradeEnabled}
|
|
onPaperOrderFilled={() => void refreshPaperData({ focusHistory: true })}
|
|
/>
|
|
</div>
|
|
)}
|
|
/>
|
|
) : rightTab === 'orderbook' ? (
|
|
<div className="rsp-ob-stack ptd-ob-stack--fill">
|
|
<div className="rsp-trade-card rsp-ob-card">
|
|
<div className="rsp-trade-card-title rsp-ob-card-title">실시간 호가</div>
|
|
<div className="rsp-trade-card-body rsp-ob-card-body">
|
|
<OrderbookPanel
|
|
market={selectedMarket}
|
|
asks={orderbook.asks}
|
|
bids={orderbook.bids}
|
|
totalAskSize={orderbook.totalAskSize}
|
|
totalBidSize={orderbook.totalBidSize}
|
|
wsStatus={obWsStatus}
|
|
bestAsk={spread.bestAsk}
|
|
bestBid={spread.bestBid}
|
|
spread={spread.spread}
|
|
spreadPct={spread.pct}
|
|
prevClose={orderbookPrevClose}
|
|
tickerInfo={orderbookTickerInfo}
|
|
recentTrades={recentTrades}
|
|
tradeStrength={tradeStrength}
|
|
onRowClick={handleOrderbookRowClick}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<PaperTradeHistoryList
|
|
trades={trades}
|
|
strategyNames={strategyNames}
|
|
tickers={tickers}
|
|
onSelectMarket={handleSelectMarket}
|
|
emptyText="수동·자동 매매 체결 내역이 없습니다."
|
|
className="ptd-trade-history--fill"
|
|
/>
|
|
)}
|
|
</div>
|
|
)}
|
|
/>
|
|
);
|
|
};
|
|
|
|
export default VirtualTradingPage;
|