Files
exchange-monitor-go/frontend/src/App.jsx
T
jackyu66gitandClaude Opus 4.7 2f4d7869a9 fix: 重启后加仓数据恢复 + scale_count/amount_usd 实时持久化
- restoreOpenPositions 从 orders 表 count 加仓次数,正确设置 ScaleLevels 和 AmountUSD
- 加仓时立即持久化 amount_usd 和 scale_count 到 trades 表(不等到平仓)
- PnL 图表支持单笔交易显示(原点+累计PnL连线)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-05 03:19:26 +08:00

773 lines
30 KiB
React

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(' ')
}
// Format exchange funds
let exFundsHtml = ''
if (stats.exchange_funds) {
exFundsHtml = Object.entries(stats.exchange_funds)
.map(([ex, f]) => `${ex}: $${f.balance.toFixed(2)}`)
.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>
{exFundsHtml && (
<div className="stats-row" style={{ marginTop: 2, fontSize: 11, opacity: 0.85 }}>
<div className="stat" style={{gridColumn:'1 / -1'}}><label>资金</label><span style={{fontWeight:600}}>{exFundsHtml}</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 }) {
const [modalOpen, setModalOpen] = useState(false)
const [modalTrade, setModalTrade] = useState(null)
const [modalOrders, setModalOrders] = useState([])
function openPositionDetail(id) {
if (!id) return
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 closeModal() {
setModalOpen(false)
}
useEffect(() => {
if (!modalOpen) return
function handler(e) {
if (e.key === 'Escape') closeModal()
}
document.addEventListener('keydown', handler)
return () => document.removeEventListener('keydown', handler)
}, [modalOpen])
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} className="trade-row" onClick={() => openPositionDetail(p.db_trade_id)}>
<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>
{modalOpen && (
<TradeDetailModal trade={modalTrade} orders={modalOrders} onClose={closeModal} />
)}
</>
)
}
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>毛价差</th><th>BGHL净利</th><th>HLBG净利</th></tr>
</thead>
<tbody id="price-body">
{coins.length === 0 ? (
<tr><td colSpan="6" 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><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' : ''
const nb = row['net_bg_to_hl']
const nh = row['net_hl_to_bg']
const nbCls = nb != null ? pnlClass(nb) : ''
const nhCls = nh != null ? pnlClass(nh) : ''
return (
<tr key={coin}>
<td><strong>{coin}</strong></td>
{cells}
<td className={spreadCls}>{spread != null ? spread.toFixed(4) + '%' : '-'}</td>
<td className={nbCls}>{nb != null ? nb.toFixed(2) + '%' : '-'}</td>
<td className={nhCls}>{nh != null ? nh.toFixed(2) + '%' : '-'}</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.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' : ''
// Sort orders: by Exchange ascending, then by CreatedAt ascending
const sortedOrders = [...(orders || [])].sort((a, b) => {
const exCmp = (a.Exchange || '').localeCompare(b.Exchange || '')
if (exCmp !== 0) return exCmp
return new Date(a.CreatedAt) - new Date(b.CreatedAt)
})
// Compute USD PnL per leg: AmountUSD * Pnl% / 100
const longPnlUSD = trade.AmountUSD && trade.LongPnl != null ? trade.AmountUSD * trade.LongPnl / 100 : null
const shortPnlUSD = trade.AmountUSD && trade.ShortPnl != null ? trade.AmountUSD * trade.ShortPnl / 100 : null
// Net PnL USD = total capital (both sides) * NetPnl% / 100
const netPnlUSD = trade.AmountUSD && trade.NetPnl != null ? 2 * trade.AmountUSD * trade.NetPnl / 100 : null
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) + '%' : '-'} {longPnlUSD != null ? <span style={{fontSize:11,opacity:0.8}}>(${longPnlUSD.toFixed(4)})</span> : null}</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) + '%' : '-'} {shortPnlUSD != null ? <span style={{fontSize:11,opacity:0.8}}>(${shortPnlUSD.toFixed(4)})</span> : null}</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) + '%' : '-'} {netPnlUSD != null ? <span style={{fontSize:12,opacity:0.8}}>(${netPnlUSD.toFixed(4)})</span> : null}</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>
{sortedOrders.map((o, i) => (
<tr key={i}>
<td>{o.Exchange}</td>
<td>{o.Type === 'entry' ? '开仓' : o.Type === 'exit' ? '平仓' : o.Type === 'scale' ? '加仓' : o.Type}</td>
<td>{o.Side === 'buy' ? '买' : '卖'}</td>
<td>${(o.Price || 0).toFixed(6)}</td>
<td className="text-right">{o.Size != null ? Number(o.Size).toFixed(4) : '-'}</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 + 2 * (t.AmountUSD || 0) * (t.NetPnl || 0) / 100, 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
// Prepend a zero point so single-trade chart still draws
if (data.length > 0) {
const t0 = new Date(data[0].ClosedAt).getTime() - 1000
points.push({ x: t0, y: 0 })
}
for (const t of data) {
cum += 2 * (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 < 1 ? (
<div className="loading" style={{paddingTop:100}}>暂无数据...</div>
) : (
<canvas ref={canvasRef} />
)}
</div>
</section>
)
}