diff --git a/dashboard.go b/dashboard.go
index bae067e..fa01a88 100644
--- a/dashboard.go
+++ b/dashboard.go
@@ -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
+}
diff --git a/main.go b/main.go
index e129e74..a23137a 100644
--- a/main.go
+++ b/main.go
@@ -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 {
diff --git a/scanner.go b/scanner.go
index 2612ae7..48ac7ca 100644
--- a/scanner.go
+++ b/scanner.go
@@ -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 "??"
-}
diff --git a/trader.go b/trader.go
index 8fb4436..8b9a2b8 100644
--- a/trader.go
+++ b/trader.go
@@ -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 {
diff --git a/web/static/app.js b/web/static/app.js
index 07fb593..7daf7d1 100644
--- a/web/static/app.js
+++ b/web/static/app.js
@@ -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 += `
| ${coin} | ${EXCHANGES.map(() => '- | ').join('')}
`;
+ html += `| ${coin} | ${EXCHANGES.map(() => '- | ').join('')}- |
`;
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 `${display} | `;
});
- html += `| ${coin} | ${cells.join('')}
`;
+ // 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 += `| ${coin} | ${cells.join('')}${spreadStr} |
`;
}
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 = '| 无持仓 |
';
+ els.posBody.innerHTML = '| 无持仓 |
';
return;
}
- const html = positions.map(p => `
- | ${p.coin} |
- ${p.direction} |
- $${p.amount_usd.toFixed(0)} |
- ${p.entry_spread.toFixed(4)}% |
- ${p.scales} |
- ${p.duration} |
-
`).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 `
+ | ${p.coin} |
+ ${p.direction} |
+ $${p.amount_usd.toFixed(0)} |
+ ${p.entry_spread.toFixed(4)}% |
+ ${curSpread} |
+ ${pnlStr} |
+ ${p.scales} |
+ ${p.duration} |
+
`;
+ }).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 `● ${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 = '';
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 = '';
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 = '';
+ 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() {
${t.ClosedAt ? new Date(t.ClosedAt).toLocaleTimeString('zh-CN', { hour12: false }) : '-'} |
${t.Coin} |
${t.Direction} |
- ${t.EntrySpread ? t.EntrySpread.toFixed(4) : '-'} |
- ${t.ExitSpread ? t.ExitSpread.toFixed(4) : '-'} |
+ ${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 || '-'} |
@@ -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 {
diff --git a/web/static/index.html b/web/static/index.html
index a9ed20c..b13e7cc 100644
--- a/web/static/index.html
+++ b/web/static/index.html
@@ -29,25 +29,20 @@
0
0
0
+
- 💰 实时价格
+ 💰 实时价格
-
- | 币种 |
- Binance |
- HyperLiquid |
- Bitget |
- dYdX |
-
+ | 币种 | Binance | HyperLiquid | Bitget | dYdX | BG↔HL价差 |
- | 等待数据... |
+ | 等待数据... |
@@ -55,17 +50,11 @@
- 🎯 套利机会
+ 🎯 套利机会 (BG↔HL)
-
- | 币种 |
- 方向 |
- 买价 |
- 卖价 |
- 净利% |
-
+ | 币种 | 方向 | 买价 | 卖价 | 净利% |
| 等待数据... |
@@ -80,17 +69,10 @@
-
- | 币种 |
- 方向 |
- 规模 |
- 开仓价差 |
- 加仓 |
- 时长 |
-
+ | 币种 | 方向 | 规模 | 入价差 | 现价差 | 估盈亏 | 加仓 | 时长 |
- | 等待数据... |
+ | 等待数据... |
@@ -108,22 +90,24 @@
+
+
+ 📉 价差走势 (BG↔HL)
+
+
+
+
+
+
+
+
📋 历史交易
-
- | 时间 |
- 币种 |
- 方向 |
- 入价差 |
- 出价差 |
- 净利% |
- 结果 |
- 原因 |
-
+ | 时间 | 币种 | 方向 | 入价差 | 出价差 | 净利% | 结果 | 原因 |
| 等待数据... |
diff --git a/web/static/style.css b/web/static/style.css
index 9fdf16f..849260f 100644
--- a/web/static/style.css
+++ b/web/static/style.css
@@ -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;