모의투자 관리 기능 추가
This commit is contained in:
@@ -1,44 +1,58 @@
|
||||
/**
|
||||
* 모의투자 대시보드 — 첨부 UI (3열 + 핵심 성과 지표)
|
||||
* 모의투자 대시보드 — 가상매매와 동일 3열 레이아웃(좌·중앙·우 접기/리사이즈)
|
||||
*/
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
loadPaperSummary,
|
||||
loadPaperTrades,
|
||||
loadPaperTradesPaged,
|
||||
loadPaperPendingOrders,
|
||||
loadStrategies,
|
||||
resetPaperAccount,
|
||||
type PaperOrderDto,
|
||||
type PaperPositionDto,
|
||||
type PaperSummaryDto,
|
||||
type PaperTradeDto,
|
||||
type StrategyDto,
|
||||
} from '../utils/backendApi';
|
||||
import type { TickerData } from '../hooks/useMarketTicker';
|
||||
import { coerceFiniteNumber } from '../utils/safeFormat';
|
||||
import PaperInvestmentKpi from './paper/PaperInvestmentKpi';
|
||||
import PaperAllocationTable from './paper/PaperAllocationTable';
|
||||
import PaperPendingOrdersList from './paper/PaperPendingOrdersList';
|
||||
import PaperLedgerTab from './paper/PaperLedgerTab';
|
||||
import PaperAssetTrendChart from './paper/PaperAssetTrendChart';
|
||||
import { computePaperMetrics } from '../utils/paperMetrics';
|
||||
import { fmtKrw } from './TradeOrderPanel';
|
||||
import TradeOrderPanel from './TradeOrderPanel';
|
||||
import { TradeSplitOrderPanel, TradeOrderbookPanelContent, TradeRightPanelBody } from './trade';
|
||||
import type { Theme, TradeOrderFillRequest } from '../types';
|
||||
import PaperAnalysisChart from './paper/PaperAnalysisChart';
|
||||
import PaperLeftStrategyTab from './paper/PaperLeftStrategyTab';
|
||||
import PaperLeftSettingsTab from './paper/PaperLeftSettingsTab';
|
||||
import PaperCompactOrderbook from './paper/PaperCompactOrderbook';
|
||||
import PaperSplitPanel from './paper/PaperSplitPanel';
|
||||
import PaperTradeHistoryList from './paper/PaperTradeHistoryList';
|
||||
import BuilderPageShell from './layout/BuilderPageShell';
|
||||
import { useUpbitOrderbook } from '../hooks/useUpbitOrderbook';
|
||||
import { useUpbitRecentTrades } from '../hooks/useUpbitRecentTrades';
|
||||
|
||||
type HistoryTab = 'open' | 'recent';
|
||||
type HistoryTab = 'open' | 'recent' | 'ledger';
|
||||
type CenterTab = 'invest' | 'chart';
|
||||
type RightTab = 'trade' | 'orderbook' | 'history';
|
||||
type LeftTab = 'strategy' | 'settings';
|
||||
|
||||
interface Props {
|
||||
theme?: Theme;
|
||||
tickers?: Map<string, { tradePrice: number | null }>;
|
||||
tickers?: Map<string, TickerData>;
|
||||
onGoChart?: (market: string) => void;
|
||||
refreshKey?: number;
|
||||
defaultMarket?: string;
|
||||
paperTradingEnabled?: boolean;
|
||||
paperAutoTradeEnabled?: boolean;
|
||||
onPaperOrderFilled?: () => void;
|
||||
/** 설정 → 모의투자 카테고리로 이동 */
|
||||
onOpenSettings?: () => void;
|
||||
}
|
||||
|
||||
function buildMarkPrices(
|
||||
summary: PaperSummaryDto | null,
|
||||
tickers?: Map<string, { tradePrice: number | null }>,
|
||||
tickers?: Map<string, TickerData>,
|
||||
): Record<string, number> {
|
||||
const m: Record<string, number> = {};
|
||||
summary?.positions?.forEach(p => {
|
||||
@@ -50,7 +64,7 @@ function buildMarkPrices(
|
||||
|
||||
function positionPriceKey(
|
||||
positions: PaperPositionDto[] | undefined,
|
||||
tickers?: Map<string, { tradePrice: number | null }>,
|
||||
tickers?: Map<string, TickerData>,
|
||||
): string {
|
||||
if (!positions?.length) return '';
|
||||
return positions.map(p => `${p.symbol}:${tickers?.get(p.symbol)?.tradePrice ?? ''}`).join('|');
|
||||
@@ -68,37 +82,52 @@ const PaperTradingPage: React.FC<Props> = ({
|
||||
paperTradingEnabled = true,
|
||||
paperAutoTradeEnabled = false,
|
||||
onPaperOrderFilled,
|
||||
onOpenSettings,
|
||||
}) => {
|
||||
const [strategies, setStrategies] = useState<StrategyDto[]>([]);
|
||||
const [summary, setSummary] = useState<PaperSummaryDto | null>(null);
|
||||
const [trades, setTrades] = useState<PaperTradeDto[]>([]);
|
||||
const [pendingOrders, setPendingOrders] = useState<PaperOrderDto[]>([]);
|
||||
const [dataRefreshKey, setDataRefreshKey] = useState(0);
|
||||
const [initialLoading, setInitialLoading] = useState(true);
|
||||
const [selectedMarket, setSelectedMarket] = useState(defaultMarket);
|
||||
const [centerTab, setCenterTab] = useState<CenterTab>('invest');
|
||||
const [historyTab, setHistoryTab] = useState<HistoryTab>('recent');
|
||||
const [rightTab, setRightTab] = useState<RightTab>('trade');
|
||||
const [leftTab, setLeftTab] = useState<LeftTab>('strategy');
|
||||
const [fillBuy, setFillBuy] = useState<TradeOrderFillRequest | null>(null);
|
||||
const [fillSell, setFillSell] = useState<TradeOrderFillRequest | null>(null);
|
||||
const orderAnchorRef = useRef<HTMLDivElement>(null);
|
||||
const fillSeqRef = useRef(0);
|
||||
|
||||
const tickersRef = useRef(tickers);
|
||||
tickersRef.current = tickers;
|
||||
const summaryRef = useRef(summary);
|
||||
summaryRef.current = summary;
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
const loadData = useCallback(async (opts?: { focusHistory?: boolean }) => {
|
||||
try {
|
||||
const base = await loadPaperSummary();
|
||||
const marks = buildMarkPrices(base, tickersRef.current);
|
||||
const full = Object.keys(marks).length > 0 ? await loadPaperSummary(marks) : base;
|
||||
setSummary(full);
|
||||
setTrades(await loadPaperTrades());
|
||||
const tradePage = await loadPaperTradesPaged({ page: 0, size: 100 });
|
||||
setTrades(tradePage.content);
|
||||
setPendingOrders(await loadPaperPendingOrders());
|
||||
setDataRefreshKey(k => k + 1);
|
||||
} finally {
|
||||
setInitialLoading(false);
|
||||
}
|
||||
}, []);
|
||||
onPaperOrderFilled?.();
|
||||
if (opts?.focusHistory) setRightTab('history');
|
||||
}, [onPaperOrderFilled]);
|
||||
|
||||
useEffect(() => {
|
||||
void Promise.all([
|
||||
loadData(),
|
||||
loadStrategies().then(setStrategies).catch(() => setStrategies([])),
|
||||
]);
|
||||
}, [loadData]);
|
||||
|
||||
useEffect(() => { void loadData(); }, [loadData]);
|
||||
useEffect(() => { if (refreshKey > 0) void loadData(); }, [refreshKey, loadData]);
|
||||
|
||||
const priceKey = positionPriceKey(summary?.positions, tickers);
|
||||
@@ -118,39 +147,84 @@ const PaperTradingPage: React.FC<Props> = ({
|
||||
return () => { cancelled = true; };
|
||||
}, [priceKey, initialLoading]);
|
||||
|
||||
const strategyNames = useMemo(
|
||||
() => Object.fromEntries(strategies.map(s => [s.id, s.name])),
|
||||
[strategies],
|
||||
);
|
||||
|
||||
const metrics = useMemo(() => computePaperMetrics(trades, summary), [trades, summary]);
|
||||
const s = summary;
|
||||
const tradePrice = tickers?.get(selectedMarket)?.tradePrice ?? null;
|
||||
|
||||
const posQty = useMemo(() => {
|
||||
const p = s?.positions?.find(x => x.symbol === selectedMarket);
|
||||
return p?.quantity ?? 0;
|
||||
return coerceFiniteNumber(p?.quantity) ?? 0;
|
||||
}, [s?.positions, selectedMarket]);
|
||||
|
||||
const handleObPick = useCallback((price: number, rowType: 'ask' | 'bid') => {
|
||||
const side = rowType === 'ask' ? 'buy' : 'sell';
|
||||
fillSeqRef.current += 1;
|
||||
const req: TradeOrderFillRequest = {
|
||||
market: selectedMarket,
|
||||
price,
|
||||
side,
|
||||
seq: fillSeqRef.current,
|
||||
};
|
||||
if (side === 'buy') setFillBuy(req); else setFillSell(req);
|
||||
const selectedAlloc = useMemo(
|
||||
() => s?.allocations?.find(a => a.symbol === selectedMarket),
|
||||
[s?.allocations, selectedMarket],
|
||||
);
|
||||
|
||||
const orderableCash = s?.orderableCash ?? s?.cashBalance ?? 0;
|
||||
|
||||
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 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 handleReset = useCallback(async () => {
|
||||
if (!window.confirm('모의투자 계좌를 초기화합니다. 계속할까요?')) return;
|
||||
const res = await resetPaperAccount();
|
||||
if (res) { setSummary(res); setTrades([]); }
|
||||
if (res) {
|
||||
setSummary(res);
|
||||
setTrades([]);
|
||||
setPendingOrders([]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
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')}>체결</button>
|
||||
<button type="button" className={`bps-right-tab${rightTab === 'history' ? ' bps-right-tab--on' : ''}`} onClick={() => setRightTab('history')}>
|
||||
거래{trades.length > 0 ? ` (${trades.length})` : ''}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -166,10 +240,16 @@ const PaperTradingPage: React.FC<Props> = ({
|
||||
theme={theme}
|
||||
title="모의투자"
|
||||
subtitle="Paper Trading"
|
||||
pageClassName="bps-page--vtd bps-page--ptd"
|
||||
loading={initialLoading}
|
||||
loadingText="모의투자 대시보드 로딩…"
|
||||
collapsiblePanels
|
||||
leftStorageKey="ptd-left-width"
|
||||
footerStorageKey="ptd-footer-height"
|
||||
leftDefaultWidth={380}
|
||||
leftCollapsedStorageKey="ptd-left-open"
|
||||
rightStorageKey="ptd-right-width"
|
||||
rightDefaultWidth={440}
|
||||
rightCollapsedStorageKey="ptd-right-open"
|
||||
headerActions={(
|
||||
<>
|
||||
<button type="button" className="bps-btn bps-btn--ghost" onClick={() => void loadData()}>새로고침</button>
|
||||
@@ -185,129 +265,108 @@ const PaperTradingPage: React.FC<Props> = ({
|
||||
market={selectedMarket}
|
||||
summary={s}
|
||||
paperAutoTradeEnabled={paperAutoTradeEnabled}
|
||||
onMarketSelect={setSelectedMarket}
|
||||
onMarketSelect={handleSelectMarket}
|
||||
/>
|
||||
) : (
|
||||
<PaperLeftSettingsTab
|
||||
onOpenSettings={onOpenSettings}
|
||||
onAccountReset={() => { setSummary(null); setTrades([]); void loadData(); }}
|
||||
/>
|
||||
)}
|
||||
centerHead={(
|
||||
<span className="bps-center-head-title">{coinCode(selectedMarket)} / KRW</span>
|
||||
<div className="ptd-center-head">
|
||||
<div className="ptd-tabs ptd-tabs--center">
|
||||
<button type="button" className={`ptd-tab${centerTab === 'invest' ? ' active' : ''}`} onClick={() => setCenterTab('invest')}>투자금 관리</button>
|
||||
<button type="button" className={`ptd-tab${centerTab === 'chart' ? ' active' : ''}`} onClick={() => setCenterTab('chart')}>차트</button>
|
||||
</div>
|
||||
<span className="bps-center-head-title">{coinCode(selectedMarket)} / KRW</span>
|
||||
</div>
|
||||
)}
|
||||
center={(
|
||||
center={centerTab === 'invest' ? (
|
||||
<div className="ptd-card ptd-invest-card ptd-center-fill">
|
||||
<PaperInvestmentKpi summary={s} />
|
||||
<PaperAllocationTable
|
||||
allocations={s?.allocations ?? []}
|
||||
selectedMarket={selectedMarket}
|
||||
onSelectMarket={handleSelectMarket}
|
||||
onChanged={() => void loadData()}
|
||||
/>
|
||||
<div className="ptd-invest-metrics">
|
||||
<PaperAssetTrendChart refreshKey={dataRefreshKey} />
|
||||
<div className="ptd-metrics-grid">
|
||||
<MetricCard icon="📉" title="MDD" value={`${metrics.mddPct.toFixed(2)}%`} sub="최대 낙폭" tone="down" />
|
||||
<MetricCard icon="📊" title="Sharpe Ratio" value={metrics.sharpeRatio.toFixed(2)} sub="위험 대비 수익" tone="up" />
|
||||
<MetricCard icon="🏆" title="Win Rate" value={`${metrics.winRatePct.toFixed(0)}%`} sub="승률" tone="up" />
|
||||
<MetricCard icon="🛡" title="Profit Factor" value={metrics.profitFactor.toFixed(2)} sub="총이익/총손실" tone="up" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="ptd-card ptd-chart-card ptd-center-fill">
|
||||
<PaperAnalysisChart market={selectedMarket} theme={theme} />
|
||||
</div>
|
||||
)}
|
||||
rightTabs={rightTabs}
|
||||
right={(
|
||||
<div className="ptd-right-body" ref={orderAnchorRef}>
|
||||
<TradeRightPanelBody anchorRef={orderAnchorRef} embeddedInShell>
|
||||
{rightTab === 'trade' ? (
|
||||
<PaperSplitPanel
|
||||
className="ptd-split-panel--right"
|
||||
topTitle="매수"
|
||||
bottomTitle="매도"
|
||||
top={(
|
||||
<TradeOrderPanel
|
||||
side="buy"
|
||||
market={selectedMarket}
|
||||
tradePrice={tradePrice}
|
||||
availableKrw={s?.cashBalance ?? 0}
|
||||
fillRequest={fillBuy}
|
||||
searchAnchorRef={orderAnchorRef}
|
||||
onMarketSelect={setSelectedMarket}
|
||||
showSymbolField
|
||||
paperTradingEnabled={paperTradingEnabled}
|
||||
paperAutoTradeEnabled={paperAutoTradeEnabled}
|
||||
onPaperOrderFilled={() => { onPaperOrderFilled?.(); void loadData(); }}
|
||||
/>
|
||||
)}
|
||||
bottom={(
|
||||
<TradeOrderPanel
|
||||
side="sell"
|
||||
market={selectedMarket}
|
||||
tradePrice={tradePrice}
|
||||
availableCoinQty={posQty}
|
||||
fillRequest={fillSell}
|
||||
onMarketSelect={setSelectedMarket}
|
||||
showSymbolField={false}
|
||||
paperTradingEnabled={paperTradingEnabled}
|
||||
paperAutoTradeEnabled={paperAutoTradeEnabled}
|
||||
onPaperOrderFilled={() => { onPaperOrderFilled?.(); void loadData(); }}
|
||||
/>
|
||||
)}
|
||||
<TradeSplitOrderPanel
|
||||
market={selectedMarket}
|
||||
tradePrice={tradePrice}
|
||||
availableKrw={orderableCash}
|
||||
availableSymbolBuyKrw={selectedAlloc?.availableBuyKrw}
|
||||
fillBuy={fillBuy}
|
||||
fillSell={fillSell}
|
||||
searchAnchorRef={orderAnchorRef}
|
||||
onMarketSelect={handleSelectMarket}
|
||||
paperTradingEnabled={paperTradingEnabled}
|
||||
paperAutoTradeEnabled={paperAutoTradeEnabled}
|
||||
onPaperOrderFilled={() => void loadData({ focusHistory: true })}
|
||||
/>
|
||||
) : rightTab === 'orderbook' ? (
|
||||
<PaperSplitPanel
|
||||
className="ptd-split-panel--right"
|
||||
topTitle="매도 호가"
|
||||
bottomTitle="매수 호가"
|
||||
top={(
|
||||
<PaperCompactOrderbook
|
||||
market={selectedMarket}
|
||||
onPick={handleObPick}
|
||||
section="asks"
|
||||
fillHeight
|
||||
hideHeader
|
||||
depth={10}
|
||||
/>
|
||||
)}
|
||||
bottom={(
|
||||
<PaperCompactOrderbook
|
||||
market={selectedMarket}
|
||||
onPick={handleObPick}
|
||||
section="bids"
|
||||
fillHeight
|
||||
hideHeader
|
||||
depth={10}
|
||||
/>
|
||||
)}
|
||||
<TradeOrderbookPanelContent
|
||||
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 ?? undefined}
|
||||
onRowClick={handleOrderbookRowClick}
|
||||
/>
|
||||
) : (
|
||||
<PaperSplitPanel
|
||||
className="ptd-split-panel--right"
|
||||
topTitle="체결 구분"
|
||||
bottomTitle={historyTab === 'open' ? '미체결' : '최근체결'}
|
||||
top={(
|
||||
<div className="ptd-tabs ptd-tabs--in-card">
|
||||
<button type="button" className={`ptd-tab${historyTab === 'open' ? ' active' : ''}`} onClick={() => setHistoryTab('open')}>미체결</button>
|
||||
<button type="button" className={`ptd-tab${historyTab === 'recent' ? ' active' : ''}`} onClick={() => setHistoryTab('recent')}>최근체결</button>
|
||||
</div>
|
||||
)}
|
||||
bottom={historyTab === 'open' ? (
|
||||
<p className="ptd-muted">미체결 주문 없음 (모의투자 즉시 체결)</p>
|
||||
) : (
|
||||
<table className="ptd-table ptd-table--compact">
|
||||
<thead>
|
||||
<tr><th>시간</th><th>자산</th><th>유형</th><th>가격</th><th>상태</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{trades.slice(0, 20).map(t => (
|
||||
<tr key={t.id}>
|
||||
<td className="ptd-time">{t.createdAt?.slice(11, 19) ?? '—'}</td>
|
||||
<td>{coinCode(t.symbol)}</td>
|
||||
<td className={t.side === 'BUY' ? 'up' : 'down'}>{t.side === 'BUY' ? '매수' : '매도'}</td>
|
||||
<td>{fmtKrw(t.price)}</td>
|
||||
<td><span className="ptd-status ptd-status--done">체결</span></td>
|
||||
</tr>
|
||||
))}
|
||||
{!trades.length && <tr><td colSpan={5} className="ptd-muted">체결 내역 없음</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
/>
|
||||
<div className="ptd-right-history">
|
||||
<div className="ptd-tabs ptd-tabs--history">
|
||||
<button type="button" className={`ptd-tab${historyTab === 'open' ? ' active' : ''}`} onClick={() => setHistoryTab('open')}>미체결</button>
|
||||
<button type="button" className={`ptd-tab${historyTab === 'recent' ? ' active' : ''}`} onClick={() => setHistoryTab('recent')}>체결</button>
|
||||
<button type="button" className={`ptd-tab${historyTab === 'ledger' ? ' active' : ''}`} onClick={() => setHistoryTab('ledger')}>원장</button>
|
||||
</div>
|
||||
<div className="ptd-right-history-body">
|
||||
{historyTab === 'open' ? (
|
||||
<PaperPendingOrdersList orders={pendingOrders} onChanged={() => void loadData()} />
|
||||
) : historyTab === 'ledger' ? (
|
||||
<PaperLedgerTab refreshKey={dataRefreshKey} />
|
||||
) : (
|
||||
<PaperTradeHistoryList
|
||||
trades={trades}
|
||||
strategyNames={strategyNames}
|
||||
tickers={tickers}
|
||||
onSelectMarket={handleSelectMarket}
|
||||
emptyText="체결 내역이 없습니다."
|
||||
className="ptd-trade-history--fill"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
footerLabel="성과 지표"
|
||||
footer={(
|
||||
<div className="ptd-metrics-grid">
|
||||
<MetricCard icon="📉" title="MDD" value={`${metrics.mddPct.toFixed(2)}%`} sub="최대 낙폭" tone="down" />
|
||||
<MetricCard icon="📊" title="Sharpe Ratio" value={metrics.sharpeRatio.toFixed(2)} sub="위험 대비 수익" tone="up" />
|
||||
<MetricCard icon="🏆" title="Win Rate" value={`${metrics.winRatePct.toFixed(0)}%`} sub="승률" tone="up" />
|
||||
<MetricCard icon="🛡" title="Profit Factor" value={metrics.profitFactor.toFixed(2)} sub="총이익/총손실" tone="up" />
|
||||
</div>
|
||||
</TradeRightPanelBody>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user