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:
+183
-44
@@ -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
|
||||
// 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,23 +270,31 @@ 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{}{
|
||||
posEntry := map[string]interface{}{
|
||||
"coin": pos.Coin,
|
||||
"direction": pos.Direction,
|
||||
"amount_usd": pos.AmountUSD,
|
||||
@@ -248,10 +302,33 @@ func (d *Dashboard) broadcastLoop() {
|
||||
"scales": pos.ScaleLevels,
|
||||
"duration": time.Since(pos.StartedAt).Round(time.Second).String(),
|
||||
"started_at": pos.StartedAt.Format("15:04:05"),
|
||||
})
|
||||
}
|
||||
|
||||
// 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
|
||||
// ============================================================
|
||||
@@ -326,7 +434,6 @@ func (d *Dashboard) handleStatus(w http.ResponseWriter, r *http.Request) {
|
||||
"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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 "??"
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+209
-107
@@ -1,5 +1,5 @@
|
||||
/* ============================================================
|
||||
Exchange Monitor Dashboard — Frontend Logic
|
||||
Exchange Monitor Dashboard — Frontend Logic v3 (P3)
|
||||
============================================================ */
|
||||
|
||||
(function() {
|
||||
@@ -11,6 +11,8 @@ const $ = id => document.getElementById(id);
|
||||
const els = {
|
||||
clock: $('clock'),
|
||||
connStatus: $('conn-status'),
|
||||
connDetail: $('conn-detail'),
|
||||
pricesAge: $('prices-age'),
|
||||
priceBody: $('price-body'),
|
||||
arbBody: $('arb-body'),
|
||||
posBody: $('positions-body'),
|
||||
@@ -24,6 +26,8 @@ const els = {
|
||||
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>
|
||||
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('');
|
||||
</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,17 +205,44 @@ 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: [{
|
||||
data: { datasets: [{
|
||||
label: 'Price',
|
||||
data: [],
|
||||
borderColor: '#58a6ff',
|
||||
@@ -210,8 +251,7 @@ function initChart() {
|
||||
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),
|
||||
ticks: { color: '#8b949e', callback: (v) => v.toFixed(4) },
|
||||
grid: { color: 'rgba(48,54,61,0.3)' },
|
||||
},
|
||||
},
|
||||
},
|
||||
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(() => {
|
||||
// ---- Auto-refresh charts ----
|
||||
setInterval(() => {
|
||||
const coin = els.chartCoin.value;
|
||||
const ex = els.chartExch.value;
|
||||
if (coin && ex) updateChart(coin, ex);
|
||||
}, chartRefreshInterval);
|
||||
}
|
||||
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
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user