Phase 3: Real-time enhancements

P3-1: Scan optimization — only BG↔HL (50+ pair combos → 2)
P3-2: Real-time spread chart — spreadHistory ring buffer +
      /api/spread-history endpoint + Chart.js spread chart
P3-3: Live position PnL — positions SSE now includes
      estimated current profit/loss + current spread
P3-4: Real-time trade events — trader.OnTradeEvent callback
      fires SSE 'trade_open' / 'trade_close' immediately
P3-5: Connection status monitoring — tracks last update time
      per exchange, broadcast via stats.connections + /api/connections

Frontend: spread chart card, PnL column in positions,
          connection status dots in stats bar,
          green/red border flash on trade events
This commit is contained in:
jackyu66git
2026-05-03 18:05:19 +08:00
parent da561325d7
commit beb3611778
7 changed files with 528 additions and 316 deletions
+198 -59
View File
@@ -5,6 +5,7 @@ import (
"fmt"
"io/fs"
"log"
"math"
"net/http"
"sync"
"time"
@@ -19,12 +20,13 @@ import (
type sseClient struct {
ch chan []byte
done chan struct{}
filter string // optional coin filter (empty = all)
filter string
}
type SSEHub struct {
mu sync.RWMutex
clients map[*sseClient]bool
seq uint64
}
func NewSSEHub() *SSEHub {
@@ -71,25 +73,24 @@ func (h *SSEHub) Broadcast(event string, data interface{}) {
select {
case c.ch <- raw:
default:
// Client too slow, skip
}
}
}
// ============================================================
// Price History — ring buffer for charting
// History Ring Buffers
// ============================================================
const maxHistoryPoints = 500
type pricePoint struct {
T int64 `json:"t"` // unix ms
T int64 `json:"t"`
P float64 `json:"p"`
}
type priceHistory struct {
mu sync.RWMutex
buffers map[string]map[string][]pricePoint // coin -> exchange -> points
buffers map[string]map[string][]pricePoint
}
func newPriceHistory() *priceHistory {
@@ -101,7 +102,6 @@ func newPriceHistory() *priceHistory {
func (ph *priceHistory) Record(coin, exchange string, price float64) {
ph.mu.Lock()
defer ph.mu.Unlock()
if ph.buffers[coin] == nil {
ph.buffers[coin] = make(map[string][]pricePoint)
}
@@ -116,58 +116,107 @@ func (ph *priceHistory) Record(coin, exchange string, price float64) {
func (ph *priceHistory) GetHistory(coin, exchange string, limit int) []pricePoint {
ph.mu.RLock()
defer ph.mu.RUnlock()
buf := ph.buffers[coin][exchange]
if len(buf) == 0 {
return nil
}
if limit <= 0 || limit >= len(buf) {
result := make([]pricePoint, len(buf))
copy(result, buf)
return result
r := make([]pricePoint, len(buf))
copy(r, buf)
return r
}
result := make([]pricePoint, limit)
copy(result, buf[len(buf)-limit:])
return result
r := make([]pricePoint, limit)
copy(r, buf[len(buf)-limit:])
return r
}
// ============================================================
// Dashboard — main orchestrator
// Spread History — tracks BG↔HL spread % per coin (P3-2)
// ============================================================
type spreadPoint struct {
T int64 `json:"t"`
Spread float64 `json:"s"` // spread % (positive = BG cheaper than HL for BG->HL direction)
}
type spreadHistory struct {
mu sync.RWMutex
buffers map[string][]spreadPoint // coin -> spread points
}
func newSpreadHistory() *spreadHistory {
return &spreadHistory{
buffers: make(map[string][]spreadPoint),
}
}
func (sh *spreadHistory) Record(coin string, spread float64) {
sh.mu.Lock()
defer sh.mu.Unlock()
sh.buffers[coin] = append(sh.buffers[coin], spreadPoint{T: time.Now().UnixMilli(), Spread: spread})
if len(sh.buffers[coin]) > maxHistoryPoints {
sh.buffers[coin] = sh.buffers[coin][len(sh.buffers[coin])-maxHistoryPoints:]
}
}
func (sh *spreadHistory) GetHistory(coin string, limit int) []spreadPoint {
sh.mu.RLock()
defer sh.mu.RUnlock()
buf := sh.buffers[coin]
if len(buf) == 0 {
return nil
}
if limit <= 0 || limit >= len(buf) {
r := make([]spreadPoint, len(buf))
copy(r, buf)
return r
}
r := make([]spreadPoint, limit)
copy(r, buf[len(buf)-limit:])
return r
}
// ============================================================
// Dashboard
// ============================================================
type Dashboard struct {
hub *SSEHub
history *priceHistory
spreads *spreadHistory
store *PriceStore
trader *Trader
db *db.DB
addr string
// cached arb scan results — updated every tick
mu sync.RWMutex
lastScan []*ArbOpportunity
scanTime time.Time
// cached arb scan results
mu sync.RWMutex
lastScan []*ArbOpportunity
scanTime time.Time
// P3-5: connection status — exchange -> last update time
connMu sync.RWMutex
connMap map[string]time.Time // exchange name -> last price timestamp
}
func NewDashboard(store *PriceStore, trader *Trader, database *db.DB, addr string) *Dashboard {
return &Dashboard{
hub: NewSSEHub(),
history: newPriceHistory(),
spreads: newSpreadHistory(),
store: store,
trader: trader,
db: database,
addr: addr,
connMap: make(map[string]time.Time),
}
}
// Run starts the HTTP server + SSE broadcaster goroutine.
func (d *Dashboard) Run() {
// SSE broadcaster — pushes data every ~1s
go d.broadcastLoop()
mux := http.NewServeMux()
// Static files — embed subdirectory
staticSub, err := fs.Sub(staticFS, "web/static")
if err != nil {
log.Printf("[Web] Failed to create static sub-fs: %v", err)
@@ -175,23 +224,20 @@ func (d *Dashboard) Run() {
mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticSub))))
}
// Main page
mux.HandleFunc("GET /", d.handleIndex)
// API endpoints
mux.HandleFunc("GET /api/status", d.handleStatus)
mux.HandleFunc("GET /api/history", d.handleHistory)
mux.HandleFunc("GET /api/spread-history", d.handleSpreadHistory) // P3-2
mux.HandleFunc("GET /api/trades", d.handleTrades)
mux.HandleFunc("GET /api/trade/", d.handleTradeDetail)
// SSE
mux.HandleFunc("GET /api/connections", d.handleConnStatus) // P3-5
mux.HandleFunc("GET /events", d.handleSSE)
server := &http.Server{
Addr: d.addr,
Handler: mux,
ReadTimeout: 10 * time.Second,
WriteTimeout: 0, // SSE needs no write timeout
WriteTimeout: 0,
}
log.Printf("[Web] Dashboard listening on http://%s", d.addr)
@@ -211,7 +257,7 @@ func (d *Dashboard) broadcastLoop() {
continue
}
// 1. Prices
// 1. Prices + spreads + connection status
var prices []map[string]interface{}
for _, coin := range TrackedCoins {
exMap := snap[coin.Name]
@@ -224,34 +270,65 @@ func (d *Dashboard) broadcastLoop() {
for ex, p := range exMap {
entry[ex] = p
}
// Add bid-ask spreads
for ex := range exMap {
sp := d.store.GetSpread(coin.Name, ex)
if sp > 0 {
entry[ex+"_spread"] = sp
}
}
// P3-2: Calculate BG↔HL spread and record
bgP := exMap[ExBitget]
hlP := exMap[ExHyperLiquid]
if bgP > 0 && hlP > 0 {
spreadPct := (hlP - bgP) / bgP * 100
entry["bg_hl_spread"] = spreadPct
d.spreads.Record(coin.Name, spreadPct)
}
prices = append(prices, entry)
}
d.hub.Broadcast("prices", prices)
// 2. Open positions
// 2. Open positions with live PnL (P3-3)
positions := d.trader.GetOpenPositions()
if len(positions) > 0 {
posList := make([]map[string]interface{}, 0, len(positions))
for _, pos := range positions {
posList = append(posList, map[string]interface{}{
"coin": pos.Coin,
"direction": pos.Direction,
"amount_usd": pos.AmountUSD,
"entry_spread": pos.EntrySpread,
"scales": pos.ScaleLevels,
"duration": time.Since(pos.StartedAt).Round(time.Second).String(),
"started_at": pos.StartedAt.Format("15:04:05"),
})
posList := make([]map[string]interface{}, 0, len(positions))
for _, pos := range positions {
posEntry := map[string]interface{}{
"coin": pos.Coin,
"direction": pos.Direction,
"amount_usd": pos.AmountUSD,
"entry_spread": pos.EntrySpread,
"scales": pos.ScaleLevels,
"duration": time.Since(pos.StartedAt).Round(time.Second).String(),
"started_at": pos.StartedAt.Format("15:04:05"),
}
d.hub.Broadcast("positions", posList)
// Calculate live PnL from current prices
if exMap := snap[pos.Coin]; exMap != nil {
bgP := exMap[ExBitget]
hlP := exMap[ExHyperLiquid]
if bgP > 0 && hlP > 0 {
var longCurrent, shortCurrent float64
if pos.LongLeg.Exchange == ExBitget {
longCurrent, shortCurrent = bgP, hlP
} else {
longCurrent, shortCurrent = hlP, bgP
}
longPnl := (longCurrent - pos.LongLeg.EntryPrice) / pos.LongLeg.EntryPrice * 100
shortPnl := (pos.ShortLeg.EntryPrice - shortCurrent) / pos.ShortLeg.EntryPrice * 100
totalFees := 2 * (makerFees[ExBitget] + makerFees[ExHyperLiquid])
netPnl := longPnl + shortPnl - totalFees
currentSpread := (hlP - bgP) / bgP * 100
posEntry["current_spread"] = math.Round(currentSpread*10000) / 10000
posEntry["pnl_est"] = math.Round(netPnl*10000) / 10000
}
}
posList = append(posList, posEntry)
}
d.hub.Broadcast("positions", posList)
// 3. Arb scan results
d.mu.RLock()
@@ -275,7 +352,7 @@ func (d *Dashboard) broadcastLoop() {
d.hub.Broadcast("arb", scanList)
}
// 4. Stats
// 4. Stats + connection status (P3-5)
converged, diverged, flat, total := d.trader.GetClosedStats()
stats := map[string]interface{}{
"total_trades": total,
@@ -285,11 +362,31 @@ func (d *Dashboard) broadcastLoop() {
"open_positions": len(positions),
"coins": len(prices),
}
// Connection status
d.connMu.RLock()
connInfo := make(map[string]string)
for ex, lastTime := range d.connMap {
age := time.Since(lastTime)
if age < 10*time.Second {
connInfo[ex] = "online"
} else if age < 30*time.Second {
connInfo[ex] = "stale"
} else {
connInfo[ex] = "offline"
}
}
d.connMu.RUnlock()
stats["connections"] = connInfo
d.hub.Broadcast("stats", stats)
}
}
// UpdateScan caches the latest arb scan results.
// ============================================================
// Public methods called from main.go / trader
// ============================================================
func (d *Dashboard) UpdateScan(opps []*ArbOpportunity) {
d.mu.Lock()
d.lastScan = opps
@@ -297,11 +394,22 @@ func (d *Dashboard) UpdateScan(opps []*ArbOpportunity) {
d.mu.Unlock()
}
// RecordPrice adds a price to the history buffer and optionally broadcasts.
func (d *Dashboard) RecordPrice(coin, exchange string, price float64) {
d.history.Record(coin, exchange, price)
}
// RecordConnStatus updates the last-seen time for an exchange (P3-5).
func (d *Dashboard) RecordConnStatus(exchange string) {
d.connMu.Lock()
d.connMap[exchange] = time.Now()
d.connMu.Unlock()
}
// BroadcastEvent sends an immediate SSE event (P3-4).
func (d *Dashboard) BroadcastEvent(event string, data interface{}) {
d.hub.Broadcast(event, data)
}
// ============================================================
// HTTP Handlers
// ============================================================
@@ -322,11 +430,10 @@ func (d *Dashboard) handleStatus(w http.ResponseWriter, r *http.Request) {
converged, diverged, flat, total := d.trader.GetClosedStats()
resp := map[string]interface{}{
"prices": snap,
"positions": len(positions),
"stats": map[string]int{"total": total, "converged": converged, "diverged": diverged, "flat": flat},
"prices": snap,
"positions": len(positions),
"stats": map[string]int{"total": total, "converged": converged, "diverged": diverged, "flat": flat},
}
writeJSON(w, resp)
}
@@ -334,7 +441,6 @@ func (d *Dashboard) handleHistory(w http.ResponseWriter, r *http.Request) {
coin := r.URL.Query().Get("coin")
exchange := r.URL.Query().Get("exchange")
if coin == "" || exchange == "" {
// Return available coins/exchanges
snap := d.store.GetAll()
coins := make([]string, 0, len(snap))
for c := range snap {
@@ -343,7 +449,6 @@ func (d *Dashboard) handleHistory(w http.ResponseWriter, r *http.Request) {
writeJSON(w, map[string]interface{}{"coins": coins, "exchanges": []string{"Binance", "HyperLiquid", "Bitget", "dYdX"}})
return
}
points := d.history.GetHistory(coin, exchange, 300)
writeJSON(w, map[string]interface{}{
"coin": coin,
@@ -352,22 +457,52 @@ func (d *Dashboard) handleHistory(w http.ResponseWriter, r *http.Request) {
})
}
// handleSpreadHistory returns BG↔HL spread history for a coin (P3-2).
func (d *Dashboard) handleSpreadHistory(w http.ResponseWriter, r *http.Request) {
coin := r.URL.Query().Get("coin")
if coin == "" {
writeJSON(w, map[string]interface{}{"coins": trackedCoinNames()})
return
}
points := d.spreads.GetHistory(coin, 300)
writeJSON(w, map[string]interface{}{
"coin": coin,
"points": points,
})
}
// handleConnStatus returns connection health for all exchanges (P3-5).
func (d *Dashboard) handleConnStatus(w http.ResponseWriter, r *http.Request) {
d.connMu.RLock()
conns := make(map[string]string)
for ex, t := range d.connMap {
age := time.Since(t)
switch {
case age < 10*time.Second:
conns[ex] = "online"
case age < 30*time.Second:
conns[ex] = "stale"
default:
conns[ex] = "offline"
}
}
d.connMu.RUnlock()
writeJSON(w, conns)
}
func (d *Dashboard) handleTrades(w http.ResponseWriter, r *http.Request) {
if d.db == nil {
writeJSON(w, map[string]interface{}{"trades": []interface{}{}, "total": 0})
return
}
page := 1
limit := 20
coin := r.URL.Query().Get("coin")
trades, total, err := d.db.GetTrades(page, limit, coin)
if err != nil {
http.Error(w, err.Error(), 500)
return
}
writeJSON(w, map[string]interface{}{
"trades": trades,
"total": total,
@@ -381,19 +516,16 @@ func (d *Dashboard) handleTradeDetail(w http.ResponseWriter, r *http.Request) {
http.Error(w, "DB not available", 503)
return
}
var id int64
if _, err := fmt.Sscanf(r.URL.Path, "/api/trade/%d", &id); err != nil {
http.Error(w, "Invalid trade ID", 400)
return
}
trade, orders, err := d.db.GetTradeByID(id)
if err != nil {
http.Error(w, err.Error(), 404)
return
}
writeJSON(w, map[string]interface{}{
"trade": trade,
"orders": orders,
@@ -415,7 +547,6 @@ func (d *Dashboard) handleSSE(w http.ResponseWriter, r *http.Request) {
client := d.hub.Subscribe("")
defer d.hub.Unsubscribe(client)
// Send initial heartbeat
fmt.Fprintf(w, "event: connected\ndata: {\"status\":\"ok\"}\n\n")
flusher.Flush()
@@ -437,3 +568,11 @@ func writeJSON(w http.ResponseWriter, v interface{}) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(v)
}
func trackedCoinNames() []string {
names := make([]string, len(TrackedCoins))
for i, c := range TrackedCoins {
names[i] = c.Name
}
return names
}
+4
View File
@@ -50,6 +50,9 @@ func main() {
// Initialize dashboard (web server + SSE)
dashboard := NewDashboard(store, trader, database, ":8888")
go dashboard.Run()
// P3-4: wire real-time trade event broadcast
trader.OnTradeEvent = dashboard.BroadcastEvent
if trader.IsConfigured() {
modeLabel := trader.ModeLabel()
log.Printf("[Trader] %s mode: automated trading ENABLED (threshold >= %.2f%%, $%.0f/trade)",
@@ -85,6 +88,7 @@ func main() {
err := runner(func(coin string, price, bid, ask float64) {
store.SetWithSpread(coin, name, price, bid, ask)
dashboard.RecordPrice(coin, name, price)
dashboard.RecordConnStatus(name) // P3-5
})
log.Printf("[%s] WS error: %v (reconnecting...)", name, err)
select {
+33 -82
View File
@@ -1,9 +1,7 @@
package main
import (
"log"
"sort"
"time"
)
// Exchange names
@@ -55,88 +53,50 @@ func netProfit(buyPrice, sellPrice, buyFee, sellFee float64) float64 {
return (revenue/cost - 1)*100 - (buyFee + sellFee)
}
// ScanArbWithFees checks all coins for arbitrage opportunities using a custom fee map.
// Pass feeRates for taker fees or makerFees for limit order fees.
func ScanArbWithFees(store *PriceStore, fees map[string]float64) []*ArbOpportunity {
// ScanBGHL scans coins for arbitrage ONLY between Bitget and HyperLiquid (P3-1).
// Returns both directions (BG->HL and HL->BG) sorted by net profit descending.
func ScanBGHL(store *PriceStore) []*ArbOpportunity {
snapshot := store.GetAll()
var results []*ArbOpportunity
for _, coin := range TrackedCoins {
coinStart := time.Now()
exMap := snapshot[coin.Name]
if exMap == nil {
continue
}
bnP := exMap[ExBinance]
hlP := exMap[ExHyperLiquid]
bgP := exMap[ExBitget]
dyP := exMap[ExDydx]
var pairs []struct {
profit float64
buyEx string
sellEx string
buyP float64
sellP float64
}
addPair := func(ex1, ex2 string, p1, p2 float64) {
if p1 <= 0 || p2 <= 0 {
return
}
pairs = append(pairs,
struct {
profit float64
buyEx string
sellEx string
buyP float64
sellP float64
}{netProfit(p1, p2, fees[ex1], fees[ex2]), ex1, ex2, p1, p2},
struct {
profit float64
buyEx string
sellEx string
buyP float64
sellP float64
}{netProfit(p2, p1, fees[ex2], fees[ex1]), ex2, ex1, p2, p1},
)
}
addPair(ExBinance, ExHyperLiquid, bnP, hlP)
addPair(ExBinance, ExBitget, bnP, bgP)
addPair(ExBinance, ExDydx, bnP, dyP)
addPair(ExHyperLiquid, ExBitget, hlP, bgP)
addPair(ExHyperLiquid, ExDydx, hlP, dyP)
addPair(ExBitget, ExDydx, bgP, dyP)
if len(pairs) == 0 {
hlP := exMap[ExHyperLiquid]
if bgP <= 0 || hlP <= 0 {
continue
}
best := pairs[0]
for _, p := range pairs[1:] {
if p.profit > best.profit {
best = p
}
}
// BG->HL: buy cheap at Bitget, sell expensive at HyperLiquid
profitBG := netProfit(bgP, hlP, makerFees[ExBitget], makerFees[ExHyperLiquid])
// HL->BG: buy cheap at HyperLiquid, sell expensive at Bitget
profitHL := netProfit(hlP, bgP, makerFees[ExHyperLiquid], makerFees[ExBitget])
grossBasis := (best.sellP - best.buyP) / best.buyP * 100
grossBG := (hlP - bgP) / bgP * 100
grossHL := (bgP - hlP) / hlP * 100
results = append(results, &ArbOpportunity{
Coin: coin.Name,
Direction: shortName(best.buyEx) + "->" + shortName(best.sellEx),
BuyEx: best.buyEx,
SellEx: best.sellEx,
BuyPrice: best.buyP,
SellPrice: best.sellP,
NetProfit: best.profit,
GrossBasis: grossBasis,
Direction: "BG->HL",
BuyEx: ExBitget,
SellEx: ExHyperLiquid,
BuyPrice: bgP,
SellPrice: hlP,
NetProfit: profitBG,
GrossBasis: grossBG,
}, &ArbOpportunity{
Coin: coin.Name,
Direction: "HL->BG",
BuyEx: ExHyperLiquid,
SellEx: ExBitget,
BuyPrice: hlP,
SellPrice: bgP,
NetProfit: profitHL,
GrossBasis: grossHL,
})
coinElapsed := time.Since(coinStart)
if coinElapsed > time.Millisecond {
log.Printf("[Profile] scan %s took %dµs", coin.Name, coinElapsed.Microseconds())
}
}
sort.Slice(results, func(i, j int) bool {
@@ -146,22 +106,13 @@ func ScanArbWithFees(store *PriceStore, fees map[string]float64) []*ArbOpportuni
return results
}
// ScanArbWithFees checks all coins — REDIRECTED to ScanBGHL for performance (P3-1).
// Kept for backward compatibility; only BG↔HL is relevant for trading.
func ScanArbWithFees(store *PriceStore, fees map[string]float64) []*ArbOpportunity {
return ScanBGHL(store)
}
// ScanArb checks all coins using taker fees.
func ScanArb(store *PriceStore) []*ArbOpportunity {
return ScanArbWithFees(store, feeRates)
}
func shortName(exchange string) string {
switch exchange {
case ExBinance:
return "BN"
case ExHyperLiquid:
return "HL"
case ExBitget:
return "BG"
case ExDydx:
return "dYdX"
}
return "??"
}
+27
View File
@@ -63,6 +63,8 @@ type Trader struct {
positions map[string]*ArbPosition // coin -> position
lastTradeTime map[string]time.Time
closedTrades []TradeRecord // history of closed trades
OnTradeEvent func(event string, data interface{}) // P3-4: real-time SSE push
}
// TradeRecord stores a finalized trade for stats tracking.
@@ -277,6 +279,17 @@ func (t *Trader) executeEntry(opp *ArbOpportunity, store *PriceStore, notifier *
pos.LongLeg.Exchange, pos.LongLeg.EntryPrice,
pos.ShortLeg.Exchange, pos.ShortLeg.EntryPrice,
diff, t.cfg.TradeAmountUSD))
// P3-4: real-time trade event push
if t.OnTradeEvent != nil {
t.OnTradeEvent("trade_open", map[string]interface{}{
"coin": pos.Coin,
"direction": pos.Direction,
"entry_spread": diff,
"amount_usd": t.cfg.TradeAmountUSD,
"time": time.Now().Format("15:04:05"),
})
}
}
// checkScaleIn adds more position when spread widens further.
@@ -432,6 +445,20 @@ func (t *Trader) checkExit(pos *ArbPosition, bgP, hlP, diffPct float64, notifier
msg += fmt.Sprintf(" 平仓异常: %s\n", closeErr)
}
notifier.Send(msg)
// P3-4: real-time trade event push
if t.OnTradeEvent != nil {
t.OnTradeEvent("trade_close", map[string]interface{}{
"coin": pos.Coin,
"direction": pos.Direction,
"entry_spread": pos.EntrySpread,
"exit_spread": diffPct,
"pnl_pct": netPnl,
"convergence": convergenceLabel,
"duration": elapsed.Round(time.Second).String(),
"time": time.Now().Format("15:04:05"),
})
}
}
func (t *Trader) placeOrder(leg *PositionLeg, side string, store *PriceStore) string {
+241 -139
View File
@@ -1,5 +1,5 @@
/* ============================================================
Exchange Monitor Dashboard — Frontend Logic
Exchange Monitor Dashboard — Frontend Logic v3 (P3)
============================================================ */
(function() {
@@ -9,21 +9,25 @@
const $ = id => document.getElementById(id);
const els = {
clock: $('clock'),
connStatus: $('conn-status'),
priceBody: $('price-body'),
arbBody: $('arb-body'),
posBody: $('positions-body'),
tradesBody: $('trades-body'),
statTotal: $('stat-total'),
statConv: $('stat-converged'),
statDiv: $('stat-diverged'),
statFlat: $('stat-flat'),
statPos: $('stat-positions'),
statCoins: $('stat-coins'),
chartCoin: $('chart-coin'),
chartExch: $('chart-exchange'),
chartCanvas: $('priceChart'),
clock: $('clock'),
connStatus: $('conn-status'),
connDetail: $('conn-detail'),
pricesAge: $('prices-age'),
priceBody: $('price-body'),
arbBody: $('arb-body'),
posBody: $('positions-body'),
tradesBody: $('trades-body'),
statTotal: $('stat-total'),
statConv: $('stat-converged'),
statDiv: $('stat-diverged'),
statFlat: $('stat-flat'),
statPos: $('stat-positions'),
statCoins: $('stat-coins'),
chartCoin: $('chart-coin'),
chartExch: $('chart-exchange'),
chartCanvas: $('priceChart'),
spreadCoin: $('spread-coin'),
spreadCanvas: $('spreadChart'),
};
// ---- Clock ----
@@ -34,7 +38,6 @@ function updateClock() {
setInterval(updateClock, 1000);
updateClock();
// ---- Price table helpers ----
const EXCHANGES = ['Binance', 'HyperLiquid', 'Bitget', 'dYdX'];
const COINS = ['DOGE', 'LINK', 'ONDO', 'OP', 'WIF', 'ARB'];
@@ -45,23 +48,24 @@ function formatPrice(p) {
return p.toFixed(6);
}
function priceClass(lastPrice, currentPrice) {
if (lastPrice == null || currentPrice == null) return '';
if (currentPrice > lastPrice) return 'text-green';
if (currentPrice < lastPrice) return 'text-red';
return '';
function priceClass(last, cur) {
if (last == null || cur == null) return '';
return cur > last ? 'text-green' : cur < last ? 'text-red' : '';
}
// ---- Price history for chart ----
const priceCache = {}; // coin.exchange -> { last: float, points: [{t,p}] }
function pnlClass(val) {
if (val == null) return '';
return val > 0 ? 'text-green' : val < 0 ? 'text-red' : '';
}
// ---- Price cache for chart data ----
const priceCache = {};
// ---- SSE Connection ----
let eventSource = null;
function connectSSE() {
if (eventSource) {
eventSource.close();
}
if (eventSource) eventSource.close();
eventSource = new EventSource('/events');
@@ -93,14 +97,13 @@ const eventHandlers = {};
eventHandlers.prices = (prices) => {
if (!prices || prices.length === 0) return;
// Build table rows
let html = '';
let coinsOnline = 0;
for (const coin of COINS) {
const row = prices.find(p => p.coin === coin);
if (!row) {
html += `<tr><td>${coin}</td>${EXCHANGES.map(() => '<td class="text-dim">-</td>').join('')}</tr>`;
html += `<tr><td>${coin}</td>${EXCHANGES.map(() => '<td class="text-dim">-</td>').join('')}<td class="text-dim">-</td></tr>`;
continue;
}
coinsOnline++;
@@ -113,18 +116,15 @@ eventHandlers.prices = (prices) => {
const curP = p || 0;
const cls = prev ? priceClass(prev.last, curP) : '';
// Store for directional arrows next time
if (prev) {
prev.last = curP;
} else {
priceCache[key] = { last: curP, points: [] };
}
// Record for chart
if (p > 0) {
const pt = { t: Date.now(), p: p };
if (!priceCache[key]) priceCache[key] = { last: p, points: [] };
priceCache[key].points.push(pt);
priceCache[key].points.push({ t: Date.now(), p: p });
if (priceCache[key].points.length > 500) {
priceCache[key].points = priceCache[key].points.slice(-500);
}
@@ -137,12 +137,17 @@ eventHandlers.prices = (prices) => {
return `<td class="${cls}">${display}</td>`;
});
html += `<tr><td><strong>${coin}</strong></td>${cells.join('')}</tr>`;
// P3-2: Add spread column
const spread = row['bg_hl_spread'];
const spreadCls = spread > 0.1 ? 'text-green' : spread < -0.1 ? 'text-red' : '';
const spreadStr = spread != null ? spread.toFixed(4) + '%' : '-';
html += `<tr><td><strong>${coin}</strong></td>${cells.join('')}<td class="${spreadCls}">${spreadStr}</td></tr>`;
}
els.priceBody.innerHTML = html;
els.pricesAge.textContent = new Date().toLocaleTimeString('zh-CN', { hour12: false });
// Update coin selector if needed
updateChartSelectors(prices);
};
@@ -166,24 +171,33 @@ eventHandlers.arb = (opps) => {
els.arbBody.innerHTML = html;
};
// P3-3: Positions with live PnL
eventHandlers.positions = (positions) => {
if (!positions || positions.length === 0) {
els.posBody.innerHTML = '<tr><td colspan="6" class="text-dim">无持仓</td></tr>';
els.posBody.innerHTML = '<tr><td colspan="8" class="text-dim">无持仓</td></tr>';
return;
}
const html = positions.map(p => `<tr>
<td><strong>${p.coin}</strong></td>
<td>${p.direction}</td>
<td class="text-right">$${p.amount_usd.toFixed(0)}</td>
<td class="text-right">${p.entry_spread.toFixed(4)}%</td>
<td class="text-right">${p.scales}</td>
<td>${p.duration}</td>
</tr>`).join('');
const html = positions.map(p => {
const pnl = p.pnl_est;
const pnlStr = pnl != null ? pnl.toFixed(4) + '%' : '-';
const curSpread = p.current_spread != null ? p.current_spread.toFixed(4) + '%' : '-';
return `<tr>
<td><strong>${p.coin}</strong></td>
<td>${p.direction}</td>
<td class="text-right">$${p.amount_usd.toFixed(0)}</td>
<td class="text-right">${p.entry_spread.toFixed(4)}%</td>
<td class="text-right">${curSpread}</td>
<td class="text-right ${pnlClass(pnl)}"><strong>${pnlStr}</strong></td>
<td class="text-right">${p.scales}</td>
<td>${p.duration}</td>
</tr>`;
}).join('');
els.posBody.innerHTML = html;
};
// P3-5: Connection status in stats
eventHandlers.stats = (stats) => {
els.statTotal.textContent = stats.total_trades || 0;
els.statConv.textContent = stats.converged || 0;
@@ -191,27 +205,53 @@ eventHandlers.stats = (stats) => {
els.statFlat.textContent = stats.flat || 0;
els.statPos.textContent = stats.open_positions || 0;
els.statCoins.textContent = stats.coins || 0;
// Connection status dots
if (stats.connections) {
const dots = Object.entries(stats.connections).map(([ex, status]) => {
const color = status === 'online' ? '#3fb950' : status === 'stale' ? '#d29922' : '#f85149';
return `<span style="color:${color}">●</span> ${ex}`;
}).join(' ');
els.connDetail.innerHTML = dots;
}
};
// ---- Chart ----
let chart = null;
// P3-4: Real-time trade events
eventHandlers.trade_open = (trade) => {
// Flash the positions card to draw attention
const card = $('positions-card');
card.style.transition = 'border-color 0.3s';
card.style.borderColor = '#3fb950';
setTimeout(() => { card.style.borderColor = ''; }, 2000);
// Refresh trades table
setTimeout(loadTrades, 500);
};
function initChart() {
eventHandlers.trade_close = (trade) => {
const card = $('trades-card');
card.style.transition = 'border-color 0.3s';
card.style.borderColor = trade.pnl_pct > 0 ? '#3fb950' : '#f85149';
setTimeout(() => { card.style.borderColor = ''; }, 2000);
setTimeout(loadTrades, 500);
};
// ---- Price Chart ----
let priceChart = null;
function initPriceChart() {
const ctx = els.chartCanvas.getContext('2d');
chart = new Chart(ctx, {
priceChart = new Chart(ctx, {
type: 'line',
data: {
datasets: [{
label: 'Price',
data: [],
borderColor: '#58a6ff',
backgroundColor: 'rgba(88, 166, 255, 0.1)',
borderWidth: 2,
pointRadius: 0,
fill: true,
tension: 0.2,
}]
},
data: { datasets: [{
label: 'Price',
data: [],
borderColor: '#58a6ff',
backgroundColor: 'rgba(88, 166, 255, 0.1)',
borderWidth: 2,
pointRadius: 0,
fill: true,
tension: 0.2,
}] },
options: {
responsive: true,
maintainAspectRatio: false,
@@ -219,108 +259,171 @@ function initChart() {
plugins: {
legend: { display: false },
tooltip: {
mode: 'index',
intersect: false,
mode: 'index', intersect: false,
callbacks: {
title: (items) => {
if (items.length > 0) {
const d = new Date(items[0].parsed.x);
return d.toLocaleTimeString('zh-CN', { hour12: false });
}
return '';
},
title: (items) => items.length ? new Date(items[0].parsed.x).toLocaleTimeString('zh-CN', { hour12: false }) : '',
label: (item) => item.parsed.y.toFixed(4),
}
}
},
},
},
scales: {
x: {
type: 'linear',
display: true,
ticks: {
color: '#8b949e',
maxTicksLimit: 10,
callback: (val) => {
const d = new Date(val);
return d.toLocaleTimeString('zh-CN', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' });
}
color: '#8b949e', maxTicksLimit: 10,
callback: (v) => new Date(v).toLocaleTimeString('zh-CN', { hour12: false, hour: '2-digit', minute: '2-digit' }),
},
grid: { color: 'rgba(48, 54, 61, 0.5)' },
grid: { color: 'rgba(48,54,61,0.5)' },
},
y: {
display: true,
ticks: {
color: '#8b949e',
callback: (val) => val.toFixed(4),
},
grid: { color: 'rgba(48, 54, 61, 0.3)' },
}
}
}
ticks: { color: '#8b949e', callback: (v) => v.toFixed(4) },
grid: { color: 'rgba(48,54,61,0.3)' },
},
},
},
});
}
// ---- P3-2: Spread Chart ----
let spreadChart = null;
function initSpreadChart() {
const ctx = els.spreadCanvas.getContext('2d');
spreadChart = new Chart(ctx, {
type: 'line',
data: { datasets: [{
label: 'BG↔HL Spread %',
data: [],
borderColor: '#d29922',
backgroundColor: 'rgba(210, 153, 34, 0.1)',
borderWidth: 2,
pointRadius: 0,
fill: true,
tension: 0.2,
}] },
options: {
responsive: true,
maintainAspectRatio: false,
animation: { duration: 0 },
plugins: {
legend: { display: false },
tooltip: {
mode: 'index', intersect: false,
callbacks: {
title: (items) => items.length ? new Date(items[0].parsed.x).toLocaleTimeString('zh-CN', { hour12: false }) : '',
label: (item) => item.parsed.y.toFixed(4) + '%',
},
},
},
scales: {
x: {
type: 'linear',
ticks: {
color: '#8b949e', maxTicksLimit: 10,
callback: (v) => new Date(v).toLocaleTimeString('zh-CN', { hour12: false, hour: '2-digit', minute: '2-digit' }),
},
grid: { color: 'rgba(48,54,61,0.5)' },
},
y: {
ticks: { color: '#8b949e', callback: (v) => v.toFixed(3) + '%' },
grid: { color: 'rgba(48,54,61,0.3)' },
},
},
},
});
}
// ---- Chart Selectors ----
function updateChartSelectors(prices) {
const coinSel = els.chartCoin;
const exSel = els.chartExch;
const spreadSel = els.spreadCoin;
// Populate coins if empty
// Price chart coin selector
if (coinSel.options.length <= 1) {
const currentCoin = coinSel.value;
const cur = coinSel.value;
coinSel.innerHTML = '<option value="">-- 选择币种 --</option>';
for (const row of prices) {
const opt = document.createElement('option');
opt.value = row.coin;
opt.textContent = row.coin;
opt.value = row.coin; opt.textContent = row.coin;
coinSel.appendChild(opt);
}
// Try to restore selection
if (currentCoin) {
coinSel.value = currentCoin;
} else if (prices.length > 0) {
coinSel.value = prices[0].coin;
}
if (cur) coinSel.value = cur;
else if (prices.length > 0) coinSel.value = prices[0].coin;
}
// Populate exchanges if empty
// Price chart exchange selector
if (exSel.options.length <= 1) {
exSel.innerHTML = '<option value="">-- 选择交易所 --</option>';
for (const ex of EXCHANGES) {
const opt = document.createElement('option');
opt.value = ex;
opt.textContent = ex;
opt.value = ex; opt.textContent = ex;
exSel.appendChild(opt);
}
exSel.value = 'HyperLiquid';
}
// Update chart when selections change
const selectedCoin = coinSel.value;
const selectedEx = exSel.value;
if (selectedCoin && selectedEx) {
updateChart(selectedCoin, selectedEx);
// Spread chart coin selector
if (spreadSel.options.length <= 1) {
const cur = spreadSel.value;
spreadSel.innerHTML = '<option value="">-- 选择币种 --</option>';
for (const row of prices) {
const opt = document.createElement('option');
opt.value = row.coin; opt.textContent = row.coin;
spreadSel.appendChild(opt);
}
if (cur) spreadSel.value = cur;
else if (prices.length > 0) spreadSel.value = prices[0].coin;
}
// Update charts on selection change
const selCoin = coinSel.value, selEx = exSel.value;
if (selCoin && selEx) updatePriceChart(selCoin, selEx);
const spCoin = spreadSel.value;
if (spCoin) updateSpreadChart(spCoin);
}
function updateChart(coin, exchange) {
function updatePriceChart(coin, exchange) {
const key = coin + '.' + exchange;
const cache = priceCache[key];
if (!cache || !cache.points || cache.points.length < 2) {
if (chart) {
chart.data.datasets[0].data = [];
chart.data.datasets[0].label = `${coin} @ ${exchange}`;
chart.update('none');
if (priceChart) {
priceChart.data.datasets[0].data = [];
priceChart.data.datasets[0].label = `${coin} @ ${exchange}`;
priceChart.update('none');
}
return;
}
const data = cache.points.map(p => ({ x: p.t, y: p.p }));
if (priceChart) {
priceChart.data.datasets[0].data = data;
priceChart.data.datasets[0].label = `${coin} @ ${exchange}`;
priceChart.update('none');
}
}
const pts = cache.points;
const data = pts.map(p => ({ x: p.t, y: p.p }));
if (chart) {
chart.data.datasets[0].data = data;
chart.data.datasets[0].label = `${coin} @ ${exchange}`;
chart.update('none');
async function updateSpreadChart(coin) {
try {
const resp = await fetch(`/api/spread-history?coin=${coin}`);
const data = await resp.json();
const pts = data.points || [];
if (pts.length < 2) {
if (spreadChart) {
spreadChart.data.datasets[0].data = [];
spreadChart.data.datasets[0].label = `${coin} BG↔HL`;
spreadChart.update('none');
}
return;
}
const chartData = pts.map(p => ({ x: p.t, y: p.s }));
if (spreadChart) {
spreadChart.data.datasets[0].data = chartData;
spreadChart.data.datasets[0].label = `${coin} BG↔HL`;
spreadChart.update('none');
}
} catch (err) {
// ignore
}
}
@@ -328,29 +431,31 @@ function updateChart(coin, exchange) {
els.chartCoin.addEventListener('change', () => {
const coin = els.chartCoin.value;
const ex = els.chartExch.value;
if (coin && ex) updateChart(coin, ex);
if (coin && ex) updatePriceChart(coin, ex);
});
els.chartExch.addEventListener('change', () => {
const coin = els.chartCoin.value;
const ex = els.chartExch.value;
if (coin && ex) updateChart(coin, ex);
if (coin && ex) updatePriceChart(coin, ex);
});
// ---- Chart auto-refresh ----
let chartRefreshTimer = null;
let chartRefreshInterval = 2000; // refresh chart every 2s
els.spreadCoin.addEventListener('change', () => {
if (els.spreadCoin.value) updateSpreadChart(els.spreadCoin.value);
});
function startChartRefresh() {
if (chartRefreshTimer) return;
chartRefreshTimer = setInterval(() => {
const coin = els.chartCoin.value;
const ex = els.chartExch.value;
if (coin && ex) updateChart(coin, ex);
}, chartRefreshInterval);
}
// ---- Auto-refresh charts ----
setInterval(() => {
const coin = els.chartCoin.value;
const ex = els.chartExch.value;
if (coin && ex) updatePriceChart(coin, ex);
}, 2000);
// ---- Trades loading ----
setInterval(() => {
if (els.spreadCoin.value) updateSpreadChart(els.spreadCoin.value);
}, 3000);
// ---- Trades from API ----
async function loadTrades() {
try {
const resp = await fetch('/api/trades');
@@ -370,8 +475,8 @@ async function loadTrades() {
<td class="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 class="text-right">${t.EntrySpread ? t.EntrySpread.toFixed(4) : '-'}</td>
<td class="text-right">${t.ExitSpread ? t.ExitSpread.toFixed(4) : '-'}</td>
<td class="text-right">${t.EntrySpread != null ? t.EntrySpread.toFixed(4) : '-'}</td>
<td class="text-right">${t.ExitSpread != null ? t.ExitSpread.toFixed(4) : '-'}</td>
<td class="text-right ${pnlCls}"><strong>${t.NetPnl != null ? t.NetPnl.toFixed(4) : '-'}</strong></td>
<td class="${convCls}">${t.Convergence || '-'}</td>
<td>${t.ExitReason || '-'}</td>
@@ -387,15 +492,12 @@ async function loadTrades() {
// ---- Init ----
function init() {
connectSSE();
initChart();
startChartRefresh();
initPriceChart();
initSpreadChart();
loadTrades();
// Refresh trades every 10s
setInterval(loadTrades, 10000);
}
// Start when DOM ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
+20 -36
View File
@@ -29,25 +29,20 @@
<div class="stat"><label>持平</label><span id="stat-flat" class="pct-gray">0</span></div>
<div class="stat"><label>持仓</label><span id="stat-positions" class="pct-yellow">0</span></div>
<div class="stat"><label>币种</label><span id="stat-coins" class="pct-blue">0</span></div>
<div class="stat" id="conn-stats"><label>连接</label><span id="conn-detail"></span></div>
</div>
</section>
<!-- Price Table -->
<section class="card" id="prices-card">
<h2>💰 实时价格</h2>
<h2>💰 实时价格 <span id="prices-age" class="text-dim" style="font-size:11px"></span></h2>
<div class="table-wrap">
<table id="price-table">
<thead>
<tr>
<th>币种</th>
<th>Binance</th>
<th>HyperLiquid</th>
<th>Bitget</th>
<th>dYdX</th>
</tr>
<tr><th>币种</th><th>Binance</th><th>HyperLiquid</th><th>Bitget</th><th>dYdX</th><th>BG↔HL价差</th></tr>
</thead>
<tbody id="price-body">
<tr><td colspan="5" class="loading">等待数据...</td></tr>
<tr><td colspan="6" class="loading">等待数据...</td></tr>
</tbody>
</table>
</div>
@@ -55,17 +50,11 @@
<!-- Arbitrage Opportunities -->
<section class="card" id="arb-card">
<h2>🎯 套利机会</h2>
<h2>🎯 套利机会 (BG↔HL)</h2>
<div class="table-wrap">
<table id="arb-table">
<thead>
<tr>
<th>币种</th>
<th>方向</th>
<th>买价</th>
<th>卖价</th>
<th>净利%</th>
</tr>
<tr><th>币种</th><th>方向</th><th>买价</th><th>卖价</th><th>净利%</th></tr>
</thead>
<tbody id="arb-body">
<tr><td colspan="5" class="loading">等待数据...</td></tr>
@@ -80,17 +69,10 @@
<div class="table-wrap">
<table id="positions-table">
<thead>
<tr>
<th>币种</th>
<th>方向</th>
<th>规模</th>
<th>开仓价差</th>
<th>加仓</th>
<th>时长</th>
</tr>
<tr><th>币种</th><th>方向</th><th>规模</th><th>入价差</th><th>现价差</th><th>估盈亏</th><th>加仓</th><th>时长</th></tr>
</thead>
<tbody id="positions-body">
<tr><td colspan="6" class="loading">等待数据...</td></tr>
<tr><td colspan="8" class="loading">等待数据...</td></tr>
</tbody>
</table>
</div>
@@ -108,22 +90,24 @@
</div>
</section>
<!-- Spread Chart (P3-2) -->
<section class="card card-wide" id="spread-chart-card">
<h2>📉 价差走势 (BG↔HL)</h2>
<div class="chart-controls">
<select id="spread-coin"></select>
</div>
<div class="chart-container">
<canvas id="spreadChart"></canvas>
</div>
</section>
<!-- Recent Trades -->
<section class="card card-wide" id="trades-card">
<h2>📋 历史交易</h2>
<div class="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>
<tr><th>时间</th><th>币种</th><th>方向</th><th>入价差</th><th>出价差</th><th>净利%</th><th>结果</th><th>原因</th></tr>
</thead>
<tbody id="trades-body">
<tr><td colspan="8" class="loading">等待数据...</td></tr>
+5
View File
@@ -91,6 +91,11 @@ header h1 { font-size: 18px; font-weight: 600; }
.pct-yellow { color: var(--yellow); }
.pct-blue { color: var(--blue); }
/* Connection status dots */
#conn-details { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
#conn-detail { font-size: 11px; white-space: nowrap; }
#conn-detail span { margin-right: 4px; font-size: 10px; }
/* Tables */
.table-wrap {
overflow-x: auto;