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