feat: 手续费改为逐笔USD累算 + Vite React前端 + system_orders表 + README

This commit is contained in:
jackyu66git
2026-05-04 04:34:27 +08:00
parent 15a4684208
commit 0288dc8284
18 changed files with 2919 additions and 521 deletions
+12
View File
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Exchange Monitor Dashboard</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
+1568
View File
File diff suppressed because it is too large Load Diff
+19
View File
@@ -0,0 +1,19 @@
{
"name": "exchange-monitor-frontend",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.2.1",
"vite": "^5.4.2"
}
}
+226
View File
@@ -0,0 +1,226 @@
:root {
--bg: #0d1117;
--card: #161b22;
--border: #30363d;
--text: #c9d1d9;
--text-dim: #8b949e;
--accent: #58a6ff;
--green: #3fb950;
--red: #f85149;
--yellow: #d29922;
--blue: #58a6ff;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
background: var(--bg);
color: var(--text);
font-size: 14px;
line-height: 1.5;
min-height: 100vh;
}
#app { max-width: 1440px; margin: 0 auto; padding: 16px; }
/* Header */
header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
background: var(--card);
border: 1px solid var(--border);
border-radius: 8px;
margin-bottom: 16px;
}
header h1 { font-size: 18px; font-weight: 600; }
.header-meta { display: flex; align-items: center; gap: 8px; font-size: 13px; color: var(--text-dim); }
.sep { color: var(--border); }
.status-offline { color: var(--red); }
.status-online { color: var(--green); }
/* Grid layout */
.grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.card-wide { grid-column: 1 / -1; }
/* Cards */
.card {
background: var(--card);
border: 1px solid var(--border);
border-radius: 8px;
padding: 12px;
}
.card h2 {
font-size: 14px;
font-weight: 600;
color: var(--text-dim);
margin-bottom: 10px;
padding-bottom: 8px;
border-bottom: 1px solid var(--border);
}
/* Stats row */
.stats-row {
display: flex;
gap: 16px;
flex-wrap: wrap;
}
.stat {
display: flex;
flex-direction: column;
align-items: center;
min-width: 60px;
}
.stat label { font-size: 11px; color: var(--text-dim); margin-bottom: 2px; }
.stat span { font-size: 20px; font-weight: 700; }
.pct-green { color: var(--green); }
.pct-red { color: var(--red); }
.pct-gray { color: var(--text-dim); }
.pct-yellow { color: var(--yellow); }
.pct-blue { color: var(--blue); }
/* Connection status dots */
#conn-detail { font-size: 11px; white-space: nowrap; }
/* Tables */
.table-wrap {
overflow-x: auto;
max-height: 320px;
overflow-y: auto;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
th {
text-align: left;
padding: 6px 8px;
color: var(--text-dim);
font-weight: 500;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.5px;
position: sticky;
top: 0;
background: var(--card);
border-bottom: 1px solid var(--border);
}
td {
padding: 5px 8px;
border-bottom: 1px solid rgba(48, 54, 61, 0.5);
white-space: nowrap;
}
tr:hover td { background: rgba(88, 166, 255, 0.05); }
.trade-row { cursor: pointer; }
.loading { text-align: center; color: var(--text-dim); padding: 20px !important; }
.text-green { color: var(--green); }
.text-red { color: var(--red); }
.text-yellow { color: var(--yellow); }
.text-dim { color: var(--text-dim); }
.text-right { text-align: right; }
/* Scrollbar */
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: #484f58; }
/* Responsive */
@media (max-width: 768px) {
.grid { grid-template-columns: 1fr; }
header { flex-direction: column; gap: 8px; }
.stats-row { justify-content: center; }
}
/* Blacklist items */
#bl-body { display: flex; gap: 8px; flex-wrap: wrap; }
.bl-item {
background: rgba(248, 81, 73, 0.1);
border: 1px solid rgba(248, 81, 73, 0.3);
border-radius: 4px;
padding: 4px 10px;
font-size: 12px;
color: var(--red);
cursor: default;
}
/* Trade Detail Modal */
.modal-overlay {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.7);
z-index: 1000;
display: flex;
align-items: flex-start;
justify-content: center;
padding: 40px 16px;
overflow-y: auto;
}
.modal-content {
background: var(--card);
border: 1px solid var(--border);
border-radius: 12px;
max-width: 700px;
width: 100%;
box-shadow: 0 8px 32px rgba(0,0,0,0.5);
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
border-bottom: 1px solid var(--border);
}
.modal-header h2 { font-size: 16px; margin: 0; padding: 0; border: none; color: var(--text); }
.modal-close {
background: none;
border: none;
color: var(--text-dim);
font-size: 20px;
cursor: pointer;
padding: 4px 8px;
border-radius: 4px;
line-height: 1;
}
.modal-close:hover { background: rgba(255,255,255,0.1); color: var(--text); }
#trade-detail-body { padding: 0; }
.detail-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0;
}
.detail-section {
padding: 14px 20px;
border-bottom: 1px solid rgba(48,54,61,0.4);
}
.detail-section:last-child { border-bottom: none; }
.detail-section-full { grid-column: 1 / -1; }
.detail-section h3 {
font-size: 12px;
color: var(--text-dim);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 8px;
}
.detail-row {
display: flex;
justify-content: space-between;
padding: 3px 0;
font-size: 13px;
}
.detail-row .label { color: var(--text-dim); }
.detail-row .value { font-weight: 500; }
.detail-orders { width: 100%; font-size: 12px; }
.detail-orders th { background: var(--bg); font-size: 10px; }
.detail-orders td { padding: 4px 6px; }
+698
View File
@@ -0,0 +1,698 @@
import { useState, useEffect, useRef, useCallback } from 'react'
const EXCHANGES = ['HyperLiquid', 'Bitget']
function formatPrice(p) {
if (p == null || p <= 0) return '-'
if (p >= 100) return p.toFixed(2)
if (p >= 1) return p.toFixed(4)
return p.toFixed(6)
}
function pnlClass(val) {
if (val == null) return ''
return val > 0 ? 'text-green' : val < 0 ? 'text-red' : ''
}
export default function App() {
const [clock, setClock] = useState('--:--:--')
const [connStatus, setConnStatus] = useState('● 未连接')
const [connOnline, setConnOnline] = useState(false)
const [connDetail, setConnDetail] = useState('')
const [prices, setPrices] = useState([])
const [pricesAge, setPricesAge] = useState('')
const [opps, setOpps] = useState([])
const [positions, setPositions] = useState([])
const [blacklist, setBlacklist] = useState([])
const [stats, setStats] = useState({})
const [trades, setTrades] = useState([])
const priceCacheRef = useRef({})
// Clock
useEffect(() => {
const tick = () => setClock(new Date().toLocaleTimeString('zh-CN', { hour12: false }))
tick()
const id = setInterval(tick, 1000)
return () => clearInterval(id)
}, [])
// SSE
useEffect(() => {
let es = new EventSource('/events')
es.addEventListener('connected', () => {
setConnStatus('● 已连接')
setConnOnline(true)
})
es.onerror = () => {
setConnStatus('● 已断开 (重连中...)')
setConnOnline(false)
setTimeout(() => {
es = new EventSource('/events')
}, 3000)
}
es.onmessage = (e) => {
try {
const msg = JSON.parse(e.data)
switch (msg.event) {
case 'prices':
handlePrices(msg.data)
break
case 'arb':
setOpps(msg.data || [])
break
case 'positions':
setPositions(msg.data || [])
break
case 'blacklist':
setBlacklist(msg.data || [])
break
case 'stats':
setStats(msg.data || {})
if (msg.data && msg.data.blacklist) {
setBlacklist(msg.data.blacklist)
}
break
case 'trade_close':
loadTrades()
break
}
} catch (err) {
// ignore
}
}
return () => es.close()
}, [])
// Load trades from API
const loadTrades = useCallback(async () => {
try {
const resp = await fetch('/api/trades')
const data = await resp.json()
setTrades(data.trades || [])
} catch (err) {
// ignore
}
}, [])
useEffect(() => {
loadTrades()
const id = setInterval(loadTrades, 10000)
return () => clearInterval(id)
}, [loadTrades])
// Handle prices
function handlePrices(data) {
if (!data || data.length === 0) return
setPrices(data)
setPricesAge(new Date().toLocaleTimeString('zh-CN', { hour12: false }))
// Update price cache for color changes
const cache = priceCacheRef.current
for (const row of data) {
for (const ex of EXCHANGES) {
const key = row.coin + '.' + ex
const p = row[ex] || 0
if (cache[key]) {
cache[key].last = p
} else {
cache[key] = { last: p }
}
}
}
}
// ---- Render helpers ----
function getCoinList() {
const seen = new Set()
const coins = []
if (!prices) return coins
for (const row of prices) {
if (!seen.has(row.coin)) {
seen.add(row.coin)
coins.push(row.coin)
}
}
return coins
}
function getPrevPrice(coin, ex) {
return priceCacheRef.current[coin + '.' + ex]?.last
}
function priceClass(last, cur) {
if (last == null || cur == null) return ''
return cur > last ? 'text-green' : cur < last ? 'text-red' : ''
}
const coins = getCoinList()
return (
<div id="app">
<header>
<h1> 跨交易所套利监控</h1>
<div className="header-meta">
<span>{clock}</span>
<span className="sep">|</span>
<span className={connOnline ? 'status-online' : 'status-offline'}>{connStatus}</span>
</div>
</header>
<div className="grid">
{/* Stats Summary */}
<StatsCard stats={stats} />
{/* Open Positions */}
<PositionsCard positions={positions} />
{/* PnL Growth Chart */}
<PnlChart />
{/* Price Table */}
<PriceTable coins={coins} prices={prices} getPrevPrice={getPrevPrice} priceClass={priceClass} pricesAge={pricesAge} />
{/* Arbitrage Opportunities */}
<ArbTable opps={opps} />
{/* Recent Trades */}
<TradesCard trades={trades} onRefresh={loadTrades} />
{/* Blacklist */}
<BlacklistCard blacklist={blacklist} />
</div>
</div>
)
}
// ============ Components ============
function StatsCard({ stats }) {
const d = stats.detail
const capital = stats.capital
// Format connection status
let connHtml = ''
if (stats.connections) {
connHtml = Object.entries(stats.connections)
.map(([ex, status]) => `${ex}:${status}`).join(' ')
}
return (
<section className="card" id="stats-card">
<h2>📊 统计数据</h2>
<div className="stats-row">
<div className="stat"><label>总交易</label><span id="stat-total">{stats.total_trades || 0}</span></div>
<div className="stat"><label>收敛</label><span className="pct-green">{stats.converged || 0}</span></div>
<div className="stat"><label>发散</label><span className="pct-red">{stats.diverged || 0}</span></div>
<div className="stat"><label>持平</label><span className="pct-gray">{stats.flat || 0}</span></div>
<div className="stat"><label>持仓</label><span className="pct-yellow">{stats.open_positions || 0} / <span>5</span></span></div>
<div className="stat"><label>币种</label><span className="pct-blue">{stats.coins || 0}</span></div>
<div className="stat" id="conn-stats"><label>连接</label><span id="conn-detail" style={{fontSize:11}}>{connHtml}</span></div>
</div>
{d && (
<div className="stats-row detail-stats" style={{ marginTop: 4, fontSize: 12, opacity: 0.85 }}>
<div className="stat"><label>总PnL</label><span>{(d.total_pnl_usd != null ? '$' + d.total_pnl_usd.toFixed(2) : '—') + (d.capital_pnl != null ? ' (' + d.capital_pnl.toFixed(4) + '%)' : '')}</span></div>
<div className="stat"><label>本金</label><span>{capital != null ? '$' + capital.toFixed(0) : '—'}</span></div>
<div className="stat"><label>胜率</label><span>{d.win_rate != null ? d.win_rate.toFixed(1) + '%' : '—'}</span></div>
<div className="stat"><label>最多盈利</label><span className="text-green">{d.max_profit != null ? d.max_profit.toFixed(4) + '%' : '—'}</span></div>
<div className="stat"><label>最多亏损</label><span className="text-red">{d.max_loss != null ? d.max_loss.toFixed(4) + '%' : '—'}</span></div>
<div className="stat"><label>平均持仓</label><span>{d.avg_dur || '—'}</span></div>
</div>
)}
</section>
)
}
function PositionsCard({ positions }) {
return (
<section className="card" id="positions-card">
<h2>🔒 当前持仓</h2>
<div className="table-wrap">
<table id="positions-table">
<thead>
<tr><th>币种</th><th>方向</th><th>规模</th><th>入价差</th><th>现价差</th><th>估盈亏</th><th>加仓</th><th>时长</th></tr>
</thead>
<tbody id="positions-body">
{positions.length === 0 ? (
<tr><td colSpan="8" className="loading">无持仓</td></tr>
) : (
[...positions].sort((a, b) => a.coin.localeCompare(b.coin)).map(p => (
<tr key={p.coin}>
<td><strong>{p.coin}</strong></td>
<td>{p.direction}</td>
<td className="text-right">${(p.amount_usd || 0).toFixed(0)}</td>
<td className="text-right">{(p.entry_spread || 0).toFixed(4)}%</td>
<td className="text-right">{p.current_spread != null ? p.current_spread.toFixed(4) + '%' : '-'}</td>
<td className={'text-right ' + pnlClass(p.pnl_est)}><strong>{p.pnl_est != null ? p.pnl_est.toFixed(4) + '%' : '-'}</strong></td>
<td className="text-right">{p.scales || 0}</td>
<td>{p.duration || '-'}</td>
</tr>
))
)}
</tbody>
</table>
</div>
</section>
)
}
function BlacklistCard({ blacklist }) {
return (
<section className="card" id="bl-card">
<h2> 黑名单</h2>
<div className="stats-row" id="bl-body">
{!blacklist || blacklist.length === 0 ? (
<span className="text-dim">暂无</span>
) : (
blacklist.map((item, i) => {
const sec = item.remaining_sec || 0
const remaining = sec > 0 ? `${Math.floor(sec/60)}m${sec%60}s` : ''
return <span key={i} className="bl-item" title={`${item.coin}: ${remaining}`}> {item.coin}{remaining ? ` (${remaining})` : ''}</span>
})
)}
</div>
</section>
)
}
function PriceTable({ coins, prices, getPrevPrice, priceClass, pricesAge }) {
return (
<section className="card" id="prices-card">
<h2>💰 实时价格 <span className="text-dim" style={{ fontSize: 11 }}>{pricesAge}</span></h2>
<div className="table-wrap">
<table id="price-table">
<thead>
<tr><th>币种</th><th>HyperLiquid</th><th>Bitget</th><th>BGHL价差</th></tr>
</thead>
<tbody id="price-body">
{coins.length === 0 ? (
<tr><td colSpan="4" className="loading">等待数据...</td></tr>
) : coins.map(coin => {
const row = prices.find(p => p.coin === coin)
if (!row) {
return <tr key={coin}><td>{coin}</td><td className="text-dim">-</td><td className="text-dim">-</td><td className="text-dim">-</td></tr>
}
const cells = EXCHANGES.map(ex => {
const p = row[ex]
const prev = getPrevPrice(coin, ex)
const cls = prev ? priceClass(prev, p || 0) : ''
return <td key={ex} className={cls}>{formatPrice(p)}</td>
})
const spread = row['bg_hl_spread']
const spreadCls = spread > 0.2 ? 'text-green' : spread < -0.2 ? 'text-red' : ''
return (
<tr key={coin}>
<td><strong>{coin}</strong></td>
{cells}
<td className={spreadCls}>{spread != null ? spread.toFixed(4) + '%' : '-'}</td>
</tr>
)
})}
</tbody>
</table>
</div>
</section>
)
}
function ArbTable({ opps }) {
return (
<section className="card" id="arb-card">
<h2>🎯 套利机会 (BGHL)</h2>
<div className="table-wrap">
<table id="arb-table">
<thead>
<tr><th>币种</th><th>方向</th><th>买价</th><th>卖价</th><th>净利%</th></tr>
</thead>
<tbody id="arb-body">
{!opps || opps.length === 0 ? (
<tr><td colSpan="5" className="text-dim">暂无套利机会</td></tr>
) : opps.slice(0, 10).map((opp, i) => {
const cls = opp.net_profit > 0.10 ? 'text-green' : opp.net_profit > 0.05 ? 'text-yellow' : ''
return (
<tr key={i}>
<td>{opp.coin}</td>
<td>{opp.direction}</td>
<td className="text-right">{formatPrice(opp.buy_price)}</td>
<td className="text-right">{formatPrice(opp.sell_price)}</td>
<td className={'text-right ' + cls}><strong>{(opp.net_profit || 0).toFixed(4)}</strong></td>
</tr>
)
})}
</tbody>
</table>
</div>
</section>
)
}
function TradesCard({ trades, onRefresh }) {
const [modalTrade, setModalTrade] = useState(null)
const [modalOrders, setModalOrders] = useState([])
const [modalOpen, setModalOpen] = useState(false)
function openTradeDetail(id) {
setModalOpen(true)
setModalTrade(null)
setModalOrders([])
fetch('/api/trade/' + id)
.then(r => r.json())
.then(data => {
setModalTrade(data.trade)
setModalOrders(data.orders || [])
})
.catch(() => {
setModalTrade({ ID: id })
})
}
function closeTradeDetail() {
setModalOpen(false)
}
// Close on Escape
useEffect(() => {
if (!modalOpen) return
function handler(e) {
if (e.key === 'Escape') closeTradeDetail()
}
document.addEventListener('keydown', handler)
return () => document.removeEventListener('keydown', handler)
}, [modalOpen])
return (
<>
<section className="card card-wide" id="trades-card">
<h2>📋 历史交易</h2>
<div className="table-wrap">
<table id="trades-table">
<thead>
<tr><th>时间</th><th>币种</th><th>方向</th><th>入价差</th><th>出价差</th><th>净利%</th><th>结果</th><th>原因</th></tr>
</thead>
<tbody id="trades-body">
{trades.length === 0 ? (
<tr><td colSpan="8" className="text-dim">暂无交易记录</td></tr>
) : trades.slice(0, 20).map(t => {
const pnlCls = t.NetPnl > 0 ? 'text-green' : t.NetPnl < 0 ? 'text-red' : ''
const convCls = t.Convergence === '价差收敛' ? 'text-green' : t.Convergence === '价差发散' ? 'text-red' : 'text-yellow'
return (
<tr key={t.ID} className="trade-row" onClick={() => openTradeDetail(t.ID)}>
<td className="text-dim">{t.ClosedAt ? new Date(t.ClosedAt).toLocaleTimeString('zh-CN', { hour12: false }) : '-'}</td>
<td><strong>{t.Coin}</strong></td>
<td>{t.Direction}</td>
<td className="text-right">{t.EntrySpread != null ? t.EntrySpread.toFixed(4) : '-'}</td>
<td className="text-right">{t.ExitSpread != null ? t.ExitSpread.toFixed(4) : '-'}</td>
<td className={'text-right ' + pnlCls}><strong>{t.NetPnl != null ? t.NetPnl.toFixed(4) + '%' : '-'}</strong></td>
<td className={convCls}>{t.Convergence || '-'}</td>
<td>{t.ExitReason || '-'}</td>
</tr>
)
})}
</tbody>
</table>
</div>
</section>
{/* Trade Detail Modal */}
{modalOpen && (
<TradeDetailModal trade={modalTrade} orders={modalOrders} onClose={closeTradeDetail} />
)}
</>
)
}
function TradeDetailModal({ trade, orders, onClose }) {
function handleOverlayClick(e) {
if (e.target === e.currentTarget) onClose()
}
if (!trade) {
return (
<div className="modal-overlay" onClick={handleOverlayClick}>
<div className="modal-content">
<div className="modal-header">
<h2>📋 交易详情</h2>
<button className="modal-close" onClick={onClose}></button>
</div>
<div id="trade-detail-body">
<div className="loading">加载中...</div>
</div>
</div>
</div>
)
}
const opened = new Date(trade.OpenedAt)
const closed = trade.ClosedAt ? new Date(trade.ClosedAt) : null
const dur = closed ? Math.round((closed - opened) / 1000) + 's' : '-'
const pnlCls = trade.NetPnl > 0 ? 'text-green' : trade.NetPnl < 0 ? 'text-red' : ''
return (
<div className="modal-overlay" onClick={handleOverlayClick}>
<div className="modal-content">
<div className="modal-header">
<h2>📋 交易详情</h2>
<button className="modal-close" onClick={onClose}></button>
</div>
<div id="trade-detail-body">
<div className="detail-grid">
<div className="detail-section">
<h3>概览</h3>
<div className="detail-row"><span className="label">币种</span><span className="value"><strong>{trade.Coin}</strong>/USDT</span></div>
<div className="detail-row"><span className="label">方向</span><span className="value">{trade.Direction || '-'}</span></div>
<div className="detail-row"><span className="label">状态</span><span className="value">{trade.Status === 'closed' ? '已平仓' : trade.Status}</span></div>
<div className="detail-row"><span className="label">加仓次数</span><span className="value">{trade.ScaleCount || 0} </span></div>
<div className="detail-row"><span className="label">总规模</span><span className="value">${(trade.AmountUSD || 0).toFixed(0)}</span></div>
</div>
<div className="detail-section">
<h3>时间</h3>
<div className="detail-row"><span className="label">开仓</span><span className="value">{opened.toLocaleString('zh-CN', { hour12: false })}</span></div>
<div className="detail-row"><span className="label">平仓</span><span className="value">{closed ? closed.toLocaleString('zh-CN', { hour12: false }) : '-'}</span></div>
<div className="detail-row"><span className="label">持仓时长</span><span className="value">{dur}</span></div>
</div>
<div className="detail-section">
<h3>价差</h3>
<div className="detail-row"><span className="label">入场价差</span><span className="value">{trade.EntrySpread != null ? trade.EntrySpread.toFixed(4) + '%' : '-'}</span></div>
<div className="detail-row"><span className="label">出场价差</span><span className="value">{trade.ExitSpread != null ? trade.ExitSpread.toFixed(4) + '%' : '-'}</span></div>
<div className="detail-row"><span className="label">收敛情况</span><span className={'value ' + (trade.Convergence === '价差收敛' ? 'text-green' : trade.Convergence === '价差发散' ? 'text-red' : '')}>{trade.Convergence || '-'}</span></div>
<div className="detail-row"><span className="label">平仓原因</span><span className="value">{trade.ExitReason || '-'}</span></div>
</div>
<div className="detail-section">
<h3>手续费</h3>
<div className="detail-row"><span className="label">开仓费</span><span className="value">${(trade.FeeEntry || 0).toFixed(4)}</span></div>
<div className="detail-row"><span className="label">平仓费</span><span className="value">${(trade.FeeExit || 0).toFixed(4)}</span></div>
<div className="detail-row"><span className="label">总手续费</span><span className="value">${((trade.FeeEntry || 0) + (trade.FeeExit || 0)).toFixed(4)}</span></div>
</div>
<div className="detail-section">
<h3>多仓 {trade.LongExchange || '-'}</h3>
<div className="detail-row"><span className="label">入场价</span><span className="value">${(trade.LongEntry || 0).toFixed(6)}</span></div>
<div className="detail-row"><span className="label">出场价</span><span className="value">${(trade.LongExit || 0).toFixed(6)}</span></div>
<div className="detail-row"><span className="label">盈亏</span><span className={'value ' + (trade.LongPnl > 0 ? 'text-green' : trade.LongPnl < 0 ? 'text-red' : '')}>{trade.LongPnl != null ? trade.LongPnl.toFixed(4) + '%' : '-'}</span></div>
</div>
<div className="detail-section">
<h3>空仓 {trade.ShortExchange || '-'}</h3>
<div className="detail-row"><span className="label">入场价</span><span className="value">${(trade.ShortEntry || 0).toFixed(6)}</span></div>
<div className="detail-row"><span className="label">出场价</span><span className="value">${(trade.ShortExit || 0).toFixed(6)}</span></div>
<div className="detail-row"><span className="label">盈亏</span><span className={'value ' + (trade.ShortPnl > 0 ? 'text-green' : trade.ShortPnl < 0 ? 'text-red' : '')}>{trade.ShortPnl != null ? trade.ShortPnl.toFixed(4) + '%' : '-'}</span></div>
</div>
<div className="detail-section detail-section-full">
<h3>净收益</h3>
<div className="detail-row" style={{ fontSize: 16 }}>
<span className="label">总计</span>
<span className={'value ' + pnlCls} style={{ fontWeight: 700 }}>{trade.NetPnl != null ? trade.NetPnl.toFixed(4) + '%' : '-'}</span>
</div>
</div>
</div>
{orders.length > 0 && (
<div className="detail-section detail-section-full" style={{ borderTop: '1px solid var(--border)' }}>
<h3>订单明细 ({orders.length})</h3>
<table className="detail-orders">
<thead>
<tr><th>类型</th><th>方向</th><th>交易所</th><th>价格</th><th>数量</th><th>手续费</th><th>订单ID</th></tr>
</thead>
<tbody>
{orders.map((o, i) => (
<tr key={i}>
<td>{o.Type === 'entry' ? '开仓' : o.Type === 'exit' ? '平仓' : o.Type === 'scale' ? '加仓' : o.Type}</td>
<td>{o.Side === 'buy' ? '买' : '卖'}</td>
<td>{o.Exchange}</td>
<td>${(o.Price || 0).toFixed(6)}</td>
<td>{o.Size || '-'}</td>
<td>{o.Fee != null ? '$' + (o.Fee).toFixed(4) : '-'}</td>
<td>{o.OrderID ? o.OrderID.substring(0, 12) + '...' : '-'}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
</div>
)
}
// ============ PnL Growth Chart ============
function PnlChart() {
const canvasRef = useRef(null)
const [data, setData] = useState([])
const [totalPnl, setTotalPnl] = useState(0)
// Fetch trades for chart
useEffect(() => {
async function fetchTrades() {
try {
const resp = await fetch('/api/trades?limit=1000')
const json = await resp.json()
const trades = (json.trades || [])
.filter(t => t.ClosedAt && t.NetPnl != null)
.sort((a, b) => new Date(a.ClosedAt) - new Date(b.ClosedAt))
setData(trades)
const total = trades.reduce((sum, t) => sum + (t.NetPnl || 0), 0)
setTotalPnl(total)
} catch (e) {}
}
fetchTrades()
const id = setInterval(fetchTrades, 10000)
return () => clearInterval(id)
}, [])
// Draw chart
useEffect(() => {
const canvas = canvasRef.current
if (!canvas || data.length < 2) return
const rect = canvas.parentElement.getBoundingClientRect()
const dpr = window.devicePixelRatio || 1
const W = rect.width
const H = rect.height
canvas.width = W * dpr
canvas.height = H * dpr
canvas.style.width = W + 'px'
canvas.style.height = H + 'px'
const ctx = canvas.getContext('2d')
ctx.scale(dpr, dpr)
const pad = { top: 20, right: 20, bottom: 35, left: 55 }
const plotW = W - pad.left - pad.right
const plotH = H - pad.top - pad.bottom
// Compute cumulative PnL
const points = []
let cum = 0
for (const t of data) {
cum += (t.AmountUSD || 0) * (t.NetPnl || 0) / 100
points.push({ x: new Date(t.ClosedAt).getTime(), y: cum })
}
const minT = points[0].x
const maxT = points[points.length - 1].x
const yVals = points.map(p => p.y)
const minY = Math.min(0, ...yVals)
const maxY = Math.max(0, ...yVals)
const yRange = Math.max(maxY - minY, 0.01)
const yPad = yRange * 0.15
const toX = t => pad.left + (t - minT) / Math.max(maxT - minT, 1) * plotW
const toY = y => pad.top + plotH - (y - (minY - yPad)) / (yRange + 2 * yPad) * plotH
// Clear
ctx.clearRect(0, 0, W, H)
// Grid lines
ctx.strokeStyle = 'rgba(48,54,61,0.5)'
ctx.lineWidth = 1
ctx.font = '11px sans-serif'
ctx.fillStyle = '#8b949e'
const ySteps = 5
for (let i = 0; i <= ySteps; i++) {
const yVal = (minY - yPad) + (yRange + 2 * yPad) * i / ySteps
const yPos = toY(yVal)
ctx.beginPath()
ctx.moveTo(pad.left, yPos)
ctx.lineTo(W - pad.right, yPos)
ctx.stroke()
ctx.fillText('$' + yVal.toFixed(2), 2, yPos + 4)
}
// Zero line
if (minY < 0 && maxY > 0) {
const y0 = toY(0)
ctx.strokeStyle = 'rgba(248,81,73,0.3)'
ctx.lineWidth = 1
ctx.setLineDash([4, 4])
ctx.beginPath()
ctx.moveTo(pad.left, y0)
ctx.lineTo(W - pad.right, y0)
ctx.stroke()
ctx.setLineDash([])
}
// X axis labels
const xSteps = Math.min(6, points.length)
for (let i = 0; i < xSteps; i++) {
const idx = Math.floor(i * (points.length - 1) / (xSteps - 1))
const xPos = toX(points[idx].x)
const date = new Date(points[idx].x)
ctx.fillStyle = '#8b949e'
ctx.textAlign = 'center'
ctx.fillText(date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }), xPos, H - 5)
}
// Line
ctx.beginPath()
ctx.strokeStyle = '#58a6ff'
ctx.lineWidth = 2
for (let i = 0; i < points.length; i++) {
const x = toX(points[i].x)
const y = toY(points[i].y)
if (i === 0) ctx.moveTo(x, y)
else ctx.lineTo(x, y)
}
ctx.stroke()
// Fill gradient
const gradient = ctx.createLinearGradient(0, pad.top, 0, H - pad.bottom)
gradient.addColorStop(0, 'rgba(88,166,255,0.15)')
gradient.addColorStop(1, 'rgba(88,166,255,0.01)')
ctx.lineTo(toX(points[points.length - 1].x), toY(minY - yPad))
ctx.lineTo(toX(points[0].x), toY(minY - yPad))
ctx.closePath()
ctx.fillStyle = gradient
ctx.fill()
// Latest value dot
const last = points[points.length - 1]
const lx = toX(last.x)
const ly = toY(last.y)
ctx.beginPath()
ctx.arc(lx, ly, 4, 0, Math.PI * 2)
ctx.fillStyle = last.y >= 0 ? '#3fb950' : '#f85149'
ctx.fill()
ctx.strokeStyle = '#0d1117'
ctx.lineWidth = 2
ctx.stroke()
// Latest value label
ctx.fillStyle = '#c9d1d9'
ctx.font = 'bold 13px sans-serif'
ctx.textAlign = 'center'
ctx.fillText('$' + last.y.toFixed(2), lx, ly - 12)
}, [data])
return (
<section className="card card-wide" id="pnl-chart-card">
<h2>📈 总PnL成长曲线 <span className="text-dim" style={{fontSize:11}}>{data.length > 0 ? `$${totalPnl.toFixed(2)}` : ''}</span></h2>
<div className="chart-container" style={{height:260}}>
{data.length < 2 ? (
<div className="loading" style={{paddingTop:100}}>暂无数据...</div>
) : (
<canvas ref={canvasRef} />
)}
</div>
</section>
)
}
+10
View File
@@ -0,0 +1,10 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import './App.css'
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)
+17
View File
@@ -0,0 +1,17 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
base: '/static/',
server: {
port: 5173,
proxy: {
'/api': { target: 'http://localhost:8888', changeOrigin: true },
'/events': { target: 'http://localhost:8888', changeOrigin: true },
},
},
build: {
outDir: 'dist',
},
})