전략분봉 저장 에러 문제 수정
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* 모의투자 화면 — 계좌 요약, 보유종목, 체결내역
|
||||
* 모의투자 대시보드 — 첨부 UI (3열 + 핵심 성과 지표)
|
||||
*/
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
loadPaperSummary,
|
||||
loadPaperTrades,
|
||||
@@ -10,16 +10,31 @@ import {
|
||||
type PaperSummaryDto,
|
||||
type PaperTradeDto,
|
||||
} from '../utils/backendApi';
|
||||
import { getKoreanName } from '../utils/marketNameCache';
|
||||
import { computePaperMetrics } from '../utils/paperMetrics';
|
||||
import { fmtKrw } from './TradeOrderPanel';
|
||||
import TradeOrderPanel from './TradeOrderPanel';
|
||||
import type { TradeOrderFillRequest } from '../types';
|
||||
import PaperMiniChart from './paper/PaperMiniChart';
|
||||
import PaperIndicatorPanel from './paper/PaperIndicatorPanel';
|
||||
import PaperCompactOrderbook from './paper/PaperCompactOrderbook';
|
||||
|
||||
type TabId = 'overview' | 'positions' | 'trades';
|
||||
const COIN_COLORS: Record<string, string> = {
|
||||
BTC: '#f7931a', ETH: '#627eea', XRP: '#23292f', SOL: '#9945ff',
|
||||
DOGE: '#c2a633', ADA: '#0033ad', DOT: '#e6007a', AVAX: '#e84142',
|
||||
};
|
||||
const DONUT_COLORS = ['#3f7ef5', '#22c55e', '#a78bfa', '#fbbf24', '#38bdf8', '#64748b'];
|
||||
|
||||
type HistoryTab = 'open' | 'recent';
|
||||
type RightTab = 'trade' | 'orderbook';
|
||||
|
||||
interface Props {
|
||||
tickers?: Map<string, { tradePrice: number | null }>;
|
||||
onGoChart?: (market: string) => void;
|
||||
/** 외부 체결(알림 모달 등) 후 목록 갱신 트리거 */
|
||||
refreshKey?: number;
|
||||
defaultMarket?: string;
|
||||
paperTradingEnabled?: boolean;
|
||||
paperAutoTradeEnabled?: boolean;
|
||||
onPaperOrderFilled?: () => void;
|
||||
}
|
||||
|
||||
function buildMarkPrices(
|
||||
@@ -27,12 +42,10 @@ function buildMarkPrices(
|
||||
tickers?: Map<string, { tradePrice: number | null }>,
|
||||
): Record<string, number> {
|
||||
const m: Record<string, number> = {};
|
||||
if (summary?.positions) {
|
||||
for (const p of summary.positions) {
|
||||
const t = tickers?.get(p.symbol)?.tradePrice;
|
||||
if (t != null && t > 0) m[p.symbol] = t;
|
||||
}
|
||||
}
|
||||
summary?.positions?.forEach(p => {
|
||||
const t = tickers?.get(p.symbol)?.tradePrice;
|
||||
if (t != null && t > 0) m[p.symbol] = t;
|
||||
});
|
||||
return m;
|
||||
}
|
||||
|
||||
@@ -41,277 +54,313 @@ function positionPriceKey(
|
||||
tickers?: Map<string, { tradePrice: number | null }>,
|
||||
): string {
|
||||
if (!positions?.length) return '';
|
||||
return positions
|
||||
.map(p => `${p.symbol}:${tickers?.get(p.symbol)?.tradePrice ?? ''}`)
|
||||
.join('|');
|
||||
return positions.map(p => `${p.symbol}:${tickers?.get(p.symbol)?.tradePrice ?? ''}`).join('|');
|
||||
}
|
||||
|
||||
const PaperTradingPage: React.FC<Props> = ({ tickers, onGoChart, refreshKey = 0 }) => {
|
||||
const [tab, setTab] = useState<TabId>('overview');
|
||||
function coinCode(symbol: string): string {
|
||||
return symbol.replace(/^KRW-/, '');
|
||||
}
|
||||
|
||||
function coinIcon(code: string): string {
|
||||
return code.slice(0, 1);
|
||||
}
|
||||
|
||||
const PaperTradingPage: React.FC<Props> = ({
|
||||
tickers,
|
||||
refreshKey = 0,
|
||||
defaultMarket = 'KRW-BTC',
|
||||
paperTradingEnabled = true,
|
||||
paperAutoTradeEnabled = false,
|
||||
onPaperOrderFilled,
|
||||
}) => {
|
||||
const [summary, setSummary] = useState<PaperSummaryDto | null>(null);
|
||||
const [trades, setTrades] = useState<PaperTradeDto[]>([]);
|
||||
const [initialLoading, setInitialLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedMarket, setSelectedMarket] = useState(defaultMarket);
|
||||
const [historyTab, setHistoryTab] = useState<HistoryTab>('recent');
|
||||
const [rightTab, setRightTab] = useState<RightTab>('trade');
|
||||
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 (opts?: { showSpinner?: boolean }) => {
|
||||
const showSpinner = opts?.showSpinner ?? false;
|
||||
if (showSpinner) setRefreshing(true);
|
||||
setError(null);
|
||||
const loadData = useCallback(async () => {
|
||||
try {
|
||||
const base = await loadPaperSummary();
|
||||
const marks = buildMarkPrices(base, tickersRef.current);
|
||||
const full = Object.keys(marks).length > 0
|
||||
? await loadPaperSummary(marks)
|
||||
: base;
|
||||
const full = Object.keys(marks).length > 0 ? await loadPaperSummary(marks) : base;
|
||||
setSummary(full);
|
||||
setTrades(await loadPaperTrades());
|
||||
lastPriceKeyRef.current = positionPriceKey(full?.positions, tickersRef.current);
|
||||
} catch {
|
||||
if (showSpinner) setError('모의투자 데이터를 불러오지 못했습니다.');
|
||||
} finally {
|
||||
if (showSpinner) setRefreshing(false);
|
||||
setInitialLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
/** 최초 진입·수동 새로고침 */
|
||||
const refresh = useCallback(() => {
|
||||
void loadData({ showSpinner: true });
|
||||
}, [loadData]);
|
||||
useEffect(() => { void loadData(); }, [loadData]);
|
||||
useEffect(() => { if (refreshKey > 0) void loadData(); }, [refreshKey, loadData]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadData({ showSpinner: true });
|
||||
}, [loadData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (refreshKey <= 0) return;
|
||||
void loadData();
|
||||
}, [refreshKey, loadData]);
|
||||
|
||||
/** 보유 종목 시세만 조용히 갱신 (tickers Map 참조 변경마다 전체 로딩 방지) */
|
||||
const priceKey = positionPriceKey(summary?.positions, tickers);
|
||||
const lastPriceKeyRef = useRef('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!priceKey || initialLoading) return;
|
||||
if (priceKey === lastPriceKeyRef.current) return;
|
||||
if (!priceKey || initialLoading || priceKey === lastPriceKeyRef.current) return;
|
||||
lastPriceKeyRef.current = priceKey;
|
||||
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
const base = summaryRef.current;
|
||||
if (!base?.positions?.length) return;
|
||||
const marks = buildMarkPrices(base, tickersRef.current);
|
||||
if (Object.keys(marks).length === 0) return;
|
||||
const marks = buildMarkPrices(summaryRef.current, tickersRef.current);
|
||||
if (!Object.keys(marks).length) return;
|
||||
try {
|
||||
const full = await loadPaperSummary(marks);
|
||||
if (!cancelled) setSummary(full);
|
||||
} catch { /* ignore */ }
|
||||
})();
|
||||
|
||||
return () => { cancelled = true; };
|
||||
}, [priceKey, initialLoading]);
|
||||
|
||||
const handleReset = useCallback(async () => {
|
||||
if (!window.confirm('모의투자 계좌를 초기화합니다. 보유 종목과 체결 이력이 모두 삭제됩니다. 계속할까요?')) return;
|
||||
const res = await resetPaperAccount();
|
||||
if (res) {
|
||||
setSummary(res);
|
||||
setTrades([]);
|
||||
lastPriceKeyRef.current = '';
|
||||
alert('모의투자 계좌가 초기화되었습니다.');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const metrics = useMemo(() => computePaperMetrics(trades, summary), [trades, summary]);
|
||||
const s = summary;
|
||||
const retUp = (s?.totalReturnPct ?? 0) >= 0;
|
||||
const showPanelLoading = initialLoading;
|
||||
const tradePrice = tickers?.get(selectedMarket)?.tradePrice ?? null;
|
||||
|
||||
const posQty = useMemo(() => {
|
||||
const p = s?.positions?.find(x => x.symbol === selectedMarket);
|
||||
return p?.quantity ?? 0;
|
||||
}, [s?.positions, selectedMarket]);
|
||||
|
||||
const allocation = useMemo(() => {
|
||||
if (!s) return [] as { label: string; value: number; pct: number }[];
|
||||
const items: { label: string; value: number }[] = [];
|
||||
if (s.cashBalance > 0) items.push({ label: 'KRW', value: s.cashBalance });
|
||||
s.positions.forEach(p => {
|
||||
const v = p.evalAmount ?? p.quantity * (p.markPrice ?? p.avgPrice);
|
||||
if (v > 0) items.push({ label: coinCode(p.symbol), value: v });
|
||||
});
|
||||
const total = items.reduce((a, x) => a + x.value, 0) || 1;
|
||||
return items.map(x => ({ ...x, pct: (x.value / total) * 100 }));
|
||||
}, [s]);
|
||||
|
||||
const donutStyle = useMemo(() => {
|
||||
if (!allocation.length) return { background: '#1f2335' };
|
||||
let acc = 0;
|
||||
const stops = allocation.map((a, i) => {
|
||||
const start = acc;
|
||||
acc += a.pct;
|
||||
return `${DONUT_COLORS[i % DONUT_COLORS.length]} ${start}% ${acc}%`;
|
||||
});
|
||||
return { background: `conic-gradient(${stops.join(', ')})` };
|
||||
}, [allocation]);
|
||||
|
||||
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);
|
||||
setRightTab('trade');
|
||||
}, [selectedMarket]);
|
||||
|
||||
const handleReset = useCallback(async () => {
|
||||
if (!window.confirm('모의투자 계좌를 초기화합니다. 계속할까요?')) return;
|
||||
const res = await resetPaperAccount();
|
||||
if (res) { setSummary(res); setTrades([]); }
|
||||
}, []);
|
||||
|
||||
if (initialLoading) {
|
||||
return <div className="ptd-page ptd-page--loading">모의투자 대시보드 로딩…</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="paper-page">
|
||||
<header className="paper-header">
|
||||
<div>
|
||||
<h1 className="paper-title">모의투자</h1>
|
||||
<p className="paper-subtitle">
|
||||
실시간 차트·전략 시그널과 연동된 가상 계좌입니다. 실제 거래소 주문은 발생하지 않습니다.
|
||||
</p>
|
||||
</div>
|
||||
<div className="paper-header-actions">
|
||||
<button type="button" className="paper-btn" onClick={refresh} disabled={refreshing}>
|
||||
{refreshing ? '새로고침 중…' : '새로고침'}
|
||||
</button>
|
||||
<button type="button" className="paper-btn paper-btn--danger" onClick={handleReset}>
|
||||
계좌 초기화
|
||||
</button>
|
||||
<div className="ptd-page">
|
||||
<header className="ptd-topbar">
|
||||
<h1 className="ptd-title">모의투자</h1>
|
||||
<div className="ptd-topbar-actions">
|
||||
<button type="button" className="ptd-btn" onClick={() => void loadData()}>새로고침</button>
|
||||
<button type="button" className="ptd-btn ptd-btn--danger" onClick={handleReset}>계좌 초기화</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{!s?.enabled && !showPanelLoading && (
|
||||
<div className="paper-banner paper-banner--warn">
|
||||
모의투자가 꺼져 있습니다. 설정 → 모의투자에서 사용을 켜 주세요.
|
||||
</div>
|
||||
{!s?.enabled && (
|
||||
<div className="ptd-banner ptd-banner--warn">모의투자가 꺼져 있습니다. 설정에서 활성화하세요.</div>
|
||||
)}
|
||||
|
||||
{s?.enabled && s.autoTradeEnabled && !showPanelLoading && (
|
||||
<div className="paper-banner paper-banner--info">
|
||||
자동매매 ON — 실시간 전략 시그널(BUY/SELL) 발생 시 모의 계좌에 자동 체결됩니다.
|
||||
(매수: 가용현금 {s.autoTradeBudgetPct}% · 매도: 보유 전량)
|
||||
<section className="ptd-metrics">
|
||||
<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>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{s?.enabled && !s.autoTradeEnabled && !showPanelLoading && (
|
||||
<div className="paper-banner paper-banner--info">
|
||||
자동매매 OFF — 시그널은 알림만 표시되며, 매매는 차트 우측 매수·매도에서 수동으로 진행합니다.
|
||||
</div>
|
||||
)}
|
||||
<div className="ptd-main">
|
||||
<aside className="ptd-col ptd-col--left">
|
||||
<div className="ptd-card ptd-asset-card">
|
||||
<div className="ptd-card-label">실시간 자산 현황</div>
|
||||
<div className="ptd-asset-total">{s ? fmtKrw(s.totalAsset) : '—'} <span>KRW</span></div>
|
||||
<div className={`ptd-asset-ret ${retUp ? 'up' : 'down'}`}>
|
||||
{s ? `${retUp ? '+' : ''}${s.totalReturnPct.toFixed(2)}%` : '—'}
|
||||
</div>
|
||||
<div className="ptd-asset-sub">초기자본 {s ? fmtKrw(s.initialCapital) : '—'} KRW</div>
|
||||
</div>
|
||||
|
||||
{error && <div className="paper-banner paper-banner--error">{error}</div>}
|
||||
<div className="ptd-card">
|
||||
<div className="ptd-card-head">보유 포트폴리오</div>
|
||||
<table className="ptd-table">
|
||||
<thead>
|
||||
<tr><th>자산</th><th>현재가</th><th>수량</th><th>손익</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(s?.positions ?? []).length === 0 ? (
|
||||
<tr><td colSpan={4} className="ptd-muted">보유 종목 없음</td></tr>
|
||||
) : s!.positions.map(p => {
|
||||
const code = coinCode(p.symbol);
|
||||
const up = (p.profitLoss ?? 0) >= 0;
|
||||
return (
|
||||
<tr key={p.symbol} className={selectedMarket === p.symbol ? 'ptd-row--sel' : ''} onClick={() => setSelectedMarket(p.symbol)}>
|
||||
<td>
|
||||
<span className="ptd-coin" style={{ background: COIN_COLORS[code] ?? '#64748b' }}>{coinIcon(code)}</span>
|
||||
{code}
|
||||
</td>
|
||||
<td>{p.markPrice != null ? fmtKrw(p.markPrice) : '—'}</td>
|
||||
<td>{p.quantity.toFixed(4)}</td>
|
||||
<td className={up ? 'up' : 'down'}>
|
||||
{p.profitLoss != null ? `${up ? '+' : ''}${fmtKrw(p.profitLoss)}` : '—'}
|
||||
{p.profitLossPct != null && <small> ({p.profitLossPct.toFixed(1)}%)</small>}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="paper-summary-grid">
|
||||
<div className="paper-card">
|
||||
<span className="paper-card-label">총 평가자산</span>
|
||||
<span className="paper-card-value">{s ? fmtKrw(s.totalAsset) : '—'} KRW</span>
|
||||
</div>
|
||||
<div className="paper-card">
|
||||
<span className="paper-card-label">주문가능 현금</span>
|
||||
<span className="paper-card-value">{s ? fmtKrw(s.cashBalance) : '—'} KRW</span>
|
||||
</div>
|
||||
<div className="paper-card">
|
||||
<span className="paper-card-label">주식 평가금액</span>
|
||||
<span className="paper-card-value">{s ? fmtKrw(s.stockEvalAmount) : '—'} KRW</span>
|
||||
</div>
|
||||
<div className="paper-card">
|
||||
<span className="paper-card-label">총 수익률</span>
|
||||
<span className={`paper-card-value ${retUp ? 'up' : 'down'}`}>
|
||||
{s ? `${s.totalReturnPct >= 0 ? '+' : ''}${s.totalReturnPct.toFixed(2)}%` : '—'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="paper-card">
|
||||
<span className="paper-card-label">평가손익</span>
|
||||
<span className={`paper-card-value ${(s?.unrealizedPnl ?? 0) >= 0 ? 'up' : 'down'}`}>
|
||||
{s ? fmtKrw(s.unrealizedPnl) : '—'} KRW
|
||||
</span>
|
||||
</div>
|
||||
<div className="paper-card">
|
||||
<span className="paper-card-label">실현손익</span>
|
||||
<span className={`paper-card-value ${(s?.realizedPnl ?? 0) >= 0 ? 'up' : 'down'}`}>
|
||||
{s ? fmtKrw(s.realizedPnl) : '—'} KRW
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ptd-card ptd-donut-card">
|
||||
<div className="ptd-card-head">투자 비중</div>
|
||||
<div className="ptd-donut-wrap">
|
||||
<div className="ptd-donut" style={donutStyle}>
|
||||
<div className="ptd-donut-hole">
|
||||
<span>{s ? `${(s.totalAsset / 1_000_000).toFixed(1)}M` : '—'}</span>
|
||||
</div>
|
||||
</div>
|
||||
<ul className="ptd-donut-legend">
|
||||
{allocation.map((a, i) => (
|
||||
<li key={a.label}>
|
||||
<span className="ptd-dot" style={{ background: DONUT_COLORS[i % DONUT_COLORS.length] }} />
|
||||
{a.label} {a.pct.toFixed(0)}%
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<nav className="paper-tabs">
|
||||
{(['overview', 'positions', 'trades'] as TabId[]).map(id => (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
className={`paper-tab${tab === id ? ' active' : ''}`}
|
||||
onClick={() => setTab(id)}
|
||||
>
|
||||
{id === 'overview' ? '계좌 개요' : id === 'positions' ? '보유종목' : '체결내역'}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
<main className="ptd-col ptd-col--center">
|
||||
<div className="ptd-card ptd-chart-card">
|
||||
<PaperMiniChart market={selectedMarket} />
|
||||
<PaperIndicatorPanel market={selectedMarket} />
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<div className="paper-panel">
|
||||
{showPanelLoading && <p className="paper-muted">로딩 중...</p>}
|
||||
<aside className="ptd-col ptd-col--right" ref={orderAnchorRef}>
|
||||
<div className="ptd-right-panel">
|
||||
<div className="ptd-tabs ptd-tabs--main">
|
||||
<button type="button" className={`ptd-tab${rightTab === 'trade' ? ' active' : ''}`} onClick={() => setRightTab('trade')}>매매</button>
|
||||
<button type="button" className={`ptd-tab${rightTab === 'orderbook' ? ' active' : ''}`} onClick={() => setRightTab('orderbook')}>호가</button>
|
||||
</div>
|
||||
<div className="ptd-right-body">
|
||||
{rightTab === 'trade' ? (
|
||||
<>
|
||||
<div className="ptd-card ptd-order-card">
|
||||
<div className="ptd-card-head">주문</div>
|
||||
<div className="ptd-order-stack">
|
||||
<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(); }}
|
||||
/>
|
||||
<TradeOrderPanel
|
||||
side="sell"
|
||||
market={selectedMarket}
|
||||
tradePrice={tradePrice}
|
||||
availableCoinQty={posQty}
|
||||
fillRequest={fillSell}
|
||||
onMarketSelect={setSelectedMarket}
|
||||
showSymbolField={false}
|
||||
paperTradingEnabled={paperTradingEnabled}
|
||||
paperAutoTradeEnabled={paperAutoTradeEnabled}
|
||||
onPaperOrderFilled={() => { onPaperOrderFilled?.(); void loadData(); }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!showPanelLoading && tab === 'overview' && s && (
|
||||
<dl className="paper-dl">
|
||||
<dt>초기 자본</dt><dd>{fmtKrw(s.initialCapital)} KRW</dd>
|
||||
<dt>수수료율</dt><dd>{s.feeRatePct}%</dd>
|
||||
<dt>슬리피지</dt><dd>{s.slippagePct}%</dd>
|
||||
<dt>최소 주문</dt><dd>{fmtKrw(s.minOrderKrw)} KRW</dd>
|
||||
<dt>자동매매</dt>
|
||||
<dd>{s.autoTradeEnabled ? `ON (매수 시 가용현금 ${s.autoTradeBudgetPct}%)` : 'OFF (수동 매매)'}</dd>
|
||||
<dt>보유 종목 수</dt><dd>{s.positions.length}개</dd>
|
||||
</dl>
|
||||
)}
|
||||
|
||||
{!showPanelLoading && tab === 'positions' && (
|
||||
<table className="paper-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>종목</th>
|
||||
<th>수량</th>
|
||||
<th>평균단가</th>
|
||||
<th>현재가</th>
|
||||
<th>평가금액</th>
|
||||
<th>평가손익</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(s?.positions ?? []).length === 0 ? (
|
||||
<tr><td colSpan={7} className="paper-muted">보유 종목이 없습니다.</td></tr>
|
||||
) : s!.positions.map(p => (
|
||||
<tr key={p.symbol}>
|
||||
<td>
|
||||
<strong>{getKoreanName(p.symbol)}</strong>
|
||||
<span className="paper-sym">{p.symbol}</span>
|
||||
</td>
|
||||
<td>{p.quantity.toFixed(8).replace(/\.?0+$/, '')}</td>
|
||||
<td>{fmtKrw(p.avgPrice)}</td>
|
||||
<td>{p.markPrice != null ? fmtKrw(p.markPrice) : '—'}</td>
|
||||
<td>{p.evalAmount != null ? fmtKrw(p.evalAmount) : '—'}</td>
|
||||
<td className={(p.profitLoss ?? 0) >= 0 ? 'up' : 'down'}>
|
||||
{p.profitLoss != null ? `${fmtKrw(p.profitLoss)} (${(p.profitLossPct ?? 0).toFixed(2)}%)` : '—'}
|
||||
</td>
|
||||
<td>
|
||||
{onGoChart && (
|
||||
<button type="button" className="paper-link" onClick={() => onGoChart(p.symbol)}>
|
||||
차트
|
||||
</button>
|
||||
<div className="ptd-card ptd-history-card">
|
||||
<div className="ptd-tabs">
|
||||
<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>
|
||||
{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, 12).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>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
|
||||
{!showPanelLoading && tab === 'trades' && (
|
||||
<table className="paper-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>시간</th>
|
||||
<th>종목</th>
|
||||
<th>구분</th>
|
||||
<th>출처</th>
|
||||
<th>체결가</th>
|
||||
<th>수량</th>
|
||||
<th>수수료</th>
|
||||
<th>체결후 현금</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{trades.length === 0 ? (
|
||||
<tr><td colSpan={8} className="paper-muted">체결 내역이 없습니다.</td></tr>
|
||||
) : trades.map(t => (
|
||||
<tr key={t.id}>
|
||||
<td className="paper-time">{t.createdAt?.replace('T', ' ').slice(0, 19) ?? '—'}</td>
|
||||
<td>{getKoreanName(t.symbol)}</td>
|
||||
<td className={t.side === 'BUY' ? 'up' : 'down'}>{t.side}</td>
|
||||
<td>{t.source === 'STRATEGY' ? '전략' : '수동'}</td>
|
||||
<td>{fmtKrw(t.price)}</td>
|
||||
<td>{t.quantity.toFixed(6).replace(/\.?0+$/, '')}</td>
|
||||
<td>{fmtKrw(t.feeAmount)}</td>
|
||||
<td>{fmtKrw(t.cashAfter)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<PaperCompactOrderbook market={selectedMarket} onPick={handleObPick} fillHeight hideHeader />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function MetricCard({ icon, title, value, sub, tone }: {
|
||||
icon: string; title: string; value: string; sub: string; tone: 'up' | 'down';
|
||||
}) {
|
||||
return (
|
||||
<div className={`ptd-metric ptd-metric--${tone}`}>
|
||||
<span className="ptd-metric-icon">{icon}</span>
|
||||
<div>
|
||||
<div className="ptd-metric-title">{title}</div>
|
||||
<div className="ptd-metric-value">{value}</div>
|
||||
<div className="ptd-metric-sub">{sub}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PaperTradingPage;
|
||||
|
||||
Reference in New Issue
Block a user