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 && (
)}
{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}
| 币种 | HyperLiquid | Bitget | 毛价差 | BG→HL净利 | HL→BG净利 |
{coins.length === 0 ? (
| 等待数据... |
) : coins.map(coin => {
const row = prices.find(p => p.coin === coin)
if (!row) {
return | {coin} | - | - | - | - | - |
}
const cells = EXCHANGES.map(ex => {
const p = row[ex]
const prev = getPrevPrice(coin, ex)
const cls = prev ? priceClass(prev, p || 0) : ''
return {formatPrice(p)} |
})
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 (
| {coin} |
{cells}
{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})
| 交易所 | 类型 | 方向 | 价格 | 仓位 | 手续费 | 订单ID |
{sortedOrders.map((o, i) => (
| {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 ? (
暂无数据...
) : (
)}
)
}