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 (

⚡ 跨交易所套利监控

{clock} | {connStatus}
{/* Stats Summary */} {/* Open Positions */} {/* PnL Growth Chart */} {/* Price Table */} {/* Arbitrage Opportunities */} {/* Recent Trades */} {/* Blacklist */}
) } // ============ 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 (

📊 统计数据

{stats.total_trades || 0}
{stats.converged || 0}
{stats.diverged || 0}
{stats.flat || 0}
{stats.open_positions || 0} / 5
{stats.coins || 0}
{connHtml}
{exFundsHtml && (
{exFundsHtml}
)} {d && (
{(d.total_pnl_usd != null ? '$' + d.total_pnl_usd.toFixed(2) : '—') + (d.capital_pnl != null ? ' (' + d.capital_pnl.toFixed(4) + '%)' : '')}
{capital != null ? '$' + capital.toFixed(0) : '—'}
{d.win_rate != null ? d.win_rate.toFixed(1) + '%' : '—'}
{d.max_profit != null ? d.max_profit.toFixed(4) + '%' : '—'}
{d.max_loss != null ? d.max_loss.toFixed(4) + '%' : '—'}
{d.avg_dur || '—'}
)}
) } 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 ( <>

🔒 当前持仓

{positions.length === 0 ? ( ) : ( [...positions].sort((a, b) => a.coin.localeCompare(b.coin)).map(p => ( openPositionDetail(p.db_trade_id)}> )) )}
币种方向规模入价差现价差估盈亏加仓时长
无持仓
{p.coin} {p.direction} ${(p.amount_usd || 0).toFixed(0)} {(p.entry_spread || 0).toFixed(4)}% {p.current_spread != null ? p.current_spread.toFixed(4) + '%' : '-'} {p.pnl_est != null ? '$' + p.pnl_est.toFixed(4) : '-'} {p.scales || 0} {p.duration || '-'}
{modalOpen && ( )} ) } function BlacklistCard({ blacklist }) { return (

⛔ 黑名单

{!blacklist || blacklist.length === 0 ? ( 暂无 ) : ( blacklist.map((item, i) => { const sec = item.remaining_sec || 0 const remaining = sec > 0 ? `${Math.floor(sec/60)}m${sec%60}s` : '' return ⛔ {item.coin}{remaining ? ` (${remaining})` : ''} }) )}
) } function PriceTable({ coins, prices, getPrevPrice, priceClass, pricesAge }) { return (

💰 实时价格 {pricesAge}

{coins.length === 0 ? ( ) : coins.map(coin => { const row = prices.find(p => p.coin === coin) if (!row) { return } const cells = EXCHANGES.map(ex => { const p = row[ex] const prev = getPrevPrice(coin, ex) const cls = prev ? priceClass(prev, p || 0) : '' return }) 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 ( {cells} ) })}
币种HyperLiquidBitget毛价差BG→HL净利HL→BG净利
等待数据...
{coin}-----
{formatPrice(p)}
{coin}{spread != null ? spread.toFixed(4) + '%' : '-'} {nb != null ? nb.toFixed(2) + '%' : '-'} {nh != null ? nh.toFixed(2) + '%' : '-'}
) } function ArbTable({ opps }) { return (

🎯 套利机会 (BG↔HL)

{!opps || opps.length === 0 ? ( ) : opps.map((opp, i) => { const cls = opp.net_profit > 0.10 ? 'text-green' : opp.net_profit > 0.05 ? 'text-yellow' : '' return ( ) })}
币种方向买价卖价净利%
暂无套利机会
{opp.coin} {opp.direction} {formatPrice(opp.buy_price)} {formatPrice(opp.sell_price)} {(opp.net_profit || 0).toFixed(4)}
) } 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 ( <>

📋 历史交易

{trades.length === 0 ? ( ) : 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 ( openTradeDetail(t.ID)}> ) })}
时间币种方向入价差出价差净利%结果原因
暂无交易记录
{t.ClosedAt ? new Date(t.ClosedAt).toLocaleTimeString('zh-CN', { hour12: false }) : '-'} {t.Coin} {t.Direction} {t.EntrySpread != null ? t.EntrySpread.toFixed(4) : '-'} {t.ExitSpread != null ? t.ExitSpread.toFixed(4) : '-'} {t.NetPnl != null ? t.NetPnl.toFixed(4) + '%' : '-'} {t.Convergence || '-'} {t.ExitReason || '-'}
{/* Trade Detail Modal */} {modalOpen && ( )} ) } function TradeDetailModal({ trade, orders, onClose }) { function handleOverlayClick(e) { if (e.target === e.currentTarget) onClose() } if (!trade) { return (

📋 交易详情

加载中...
) } 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 (

📋 交易详情

概览

币种{trade.Coin}/USDT
方向{trade.Direction || '-'}
状态{trade.Status === 'closed' ? '已平仓' : trade.Status}
加仓次数{trade.ScaleCount || 0} 次
总规模${(trade.AmountUSD || 0).toFixed(0)}

时间

开仓{opened.toLocaleString('zh-CN', { hour12: false })}
平仓{closed ? closed.toLocaleString('zh-CN', { hour12: false }) : '-'}
持仓时长{dur}

价差

入场价差{trade.EntrySpread != null ? trade.EntrySpread.toFixed(4) + '%' : '-'}
出场价差{trade.ExitSpread != null ? trade.ExitSpread.toFixed(4) + '%' : '-'}
收敛情况{trade.Convergence || '-'}
平仓原因{trade.ExitReason || '-'}

手续费

开仓费${(trade.FeeEntry || 0).toFixed(4)}
平仓费${(trade.FeeExit || 0).toFixed(4)}
总手续费${((trade.FeeEntry || 0) + (trade.FeeExit || 0)).toFixed(4)}

多仓 {trade.LongExchange || '-'}

入场价${(trade.LongEntry || 0).toFixed(6)}
出场价${(trade.LongExit || 0).toFixed(6)}
盈亏 0 ? 'text-green' : trade.LongPnl < 0 ? 'text-red' : '')}>{trade.LongPnl != null ? trade.LongPnl.toFixed(4) + '%' : '-'} {longPnlUSD != null ? (${longPnlUSD.toFixed(4)}) : null}

空仓 {trade.ShortExchange || '-'}

入场价${(trade.ShortEntry || 0).toFixed(6)}
出场价${(trade.ShortExit || 0).toFixed(6)}
盈亏 0 ? 'text-green' : trade.ShortPnl < 0 ? 'text-red' : '')}>{trade.ShortPnl != null ? trade.ShortPnl.toFixed(4) + '%' : '-'} {shortPnlUSD != null ? (${shortPnlUSD.toFixed(4)}) : null}

净收益

总计 {trade.NetPnl != null ? trade.NetPnl.toFixed(4) + '%' : '-'} {netPnlUSD != null ? (${netPnlUSD.toFixed(4)}) : null}
{orders.length > 0 && (

订单明细 ({orders.length})

{sortedOrders.map((o, i) => ( ))}
交易所类型方向价格仓位手续费订单ID
{o.Exchange} {o.Type === 'entry' ? '开仓' : o.Type === 'exit' ? '平仓' : o.Type === 'scale' ? '加仓' : o.Type} {o.Side === 'buy' ? '买' : '卖'} ${(o.Price || 0).toFixed(6)} {o.Size != null ? Number(o.Size).toFixed(4) : '-'} {o.Fee != null ? '$' + (o.Fee).toFixed(4) : '-'} {o.OrderID ? o.OrderID.substring(0, 12) + '...' : '-'}
)}
) } // ============ 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 (

📈 总PnL成长曲线 {data.length > 0 ? `$${totalPnl.toFixed(2)}` : ''}

{data.length < 1 ? (
暂无数据...
) : ( )}
) }