From beb3611778f6c187b9875302c3e50635169ed3e3 Mon Sep 17 00:00:00 2001 From: jackyu66git Date: Sun, 3 May 2026 18:05:19 +0800 Subject: [PATCH] Phase 3: Real-time enhancements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- dashboard.go | 257 +++++++++++++++++++++------- main.go | 4 + scanner.go | 115 ++++--------- trader.go | 27 +++ web/static/app.js | 380 +++++++++++++++++++++++++++--------------- web/static/index.html | 56 +++---- web/static/style.css | 5 + 7 files changed, 528 insertions(+), 316 deletions(-) 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
+
-

💰 实时价格

+

💰 实时价格

- - - - - - - + - +
币种BinanceHyperLiquidBitgetdYdX
币种BinanceHyperLiquidBitgetdYdXBG↔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;
时间币种方向入价差出价差净利%结果原因
时间币种方向入价差出价差净利%结果原因
等待数据...