diff --git a/dashboard.go b/dashboard.go
new file mode 100644
index 0000000..bae067e
--- /dev/null
+++ b/dashboard.go
@@ -0,0 +1,439 @@
+package main
+
+import (
+ "encoding/json"
+ "fmt"
+ "io/fs"
+ "log"
+ "net/http"
+ "sync"
+ "time"
+
+ "exchange-monitor/db"
+)
+
+// ============================================================
+// SSE Hub — manages connected browser clients
+// ============================================================
+
+type sseClient struct {
+ ch chan []byte
+ done chan struct{}
+ filter string // optional coin filter (empty = all)
+}
+
+type SSEHub struct {
+ mu sync.RWMutex
+ clients map[*sseClient]bool
+}
+
+func NewSSEHub() *SSEHub {
+ return &SSEHub{
+ clients: make(map[*sseClient]bool),
+ }
+}
+
+func (h *SSEHub) Subscribe(filter string) *sseClient {
+ c := &sseClient{
+ ch: make(chan []byte, 64),
+ done: make(chan struct{}),
+ filter: filter,
+ }
+ h.mu.Lock()
+ h.clients[c] = true
+ h.mu.Unlock()
+ log.Printf("[Web] SSE client connected (clients=%d)", len(h.clients))
+ return c
+}
+
+func (h *SSEHub) Unsubscribe(c *sseClient) {
+ h.mu.Lock()
+ delete(h.clients, c)
+ count := len(h.clients)
+ h.mu.Unlock()
+ close(c.done)
+ log.Printf("[Web] SSE client disconnected (clients=%d)", count)
+}
+
+func (h *SSEHub) Broadcast(event string, data interface{}) {
+ raw, err := json.Marshal(map[string]interface{}{
+ "event": event,
+ "data": data,
+ "ts": time.Now().UnixMilli(),
+ })
+ if err != nil {
+ return
+ }
+
+ h.mu.RLock()
+ defer h.mu.RUnlock()
+ for c := range h.clients {
+ select {
+ case c.ch <- raw:
+ default:
+ // Client too slow, skip
+ }
+ }
+}
+
+// ============================================================
+// Price History — ring buffer for charting
+// ============================================================
+
+const maxHistoryPoints = 500
+
+type pricePoint struct {
+ T int64 `json:"t"` // unix ms
+ P float64 `json:"p"`
+}
+
+type priceHistory struct {
+ mu sync.RWMutex
+ buffers map[string]map[string][]pricePoint // coin -> exchange -> points
+}
+
+func newPriceHistory() *priceHistory {
+ return &priceHistory{
+ buffers: make(map[string]map[string][]pricePoint),
+ }
+}
+
+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)
+ }
+ buf := ph.buffers[coin][exchange]
+ buf = append(buf, pricePoint{T: time.Now().UnixMilli(), P: price})
+ if len(buf) > maxHistoryPoints {
+ buf = buf[len(buf)-maxHistoryPoints:]
+ }
+ ph.buffers[coin][exchange] = buf
+}
+
+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
+ }
+ result := make([]pricePoint, limit)
+ copy(result, buf[len(buf)-limit:])
+ return result
+}
+
+// ============================================================
+// Dashboard — main orchestrator
+// ============================================================
+
+type Dashboard struct {
+ hub *SSEHub
+ history *priceHistory
+ store *PriceStore
+ trader *Trader
+ db *db.DB
+ addr string
+
+ // cached arb scan results — updated every tick
+ mu sync.RWMutex
+ lastScan []*ArbOpportunity
+ scanTime time.Time
+}
+
+func NewDashboard(store *PriceStore, trader *Trader, database *db.DB, addr string) *Dashboard {
+ return &Dashboard{
+ hub: NewSSEHub(),
+ history: newPriceHistory(),
+ store: store,
+ trader: trader,
+ db: database,
+ addr: addr,
+ }
+}
+
+// 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)
+ } else {
+ 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/trades", d.handleTrades)
+ mux.HandleFunc("GET /api/trade/", d.handleTradeDetail)
+
+ // SSE
+ 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
+ }
+
+ log.Printf("[Web] Dashboard listening on http://%s", d.addr)
+ if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
+ log.Printf("[Web] Server error: %v", err)
+ }
+}
+
+// broadcastLoop pushes data to SSE clients every 1 second.
+func (d *Dashboard) broadcastLoop() {
+ tick := time.NewTicker(1 * time.Second)
+ defer tick.Stop()
+
+ for range tick.C {
+ snap := d.store.GetAll()
+ if len(snap) == 0 {
+ continue
+ }
+
+ // 1. Prices
+ var prices []map[string]interface{}
+ for _, coin := range TrackedCoins {
+ exMap := snap[coin.Name]
+ if exMap == nil {
+ continue
+ }
+ entry := map[string]interface{}{
+ "coin": coin.Name,
+ }
+ 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
+ }
+ }
+ prices = append(prices, entry)
+ }
+ d.hub.Broadcast("prices", prices)
+
+ // 2. Open positions
+ positions := d.trader.GetOpenPositions()
+ if len(positions) > 0 {
+ posList := make([]map[string]interface{}, 0, len(positions))
+ for _, pos := range positions {
+ posList = append(posList, map[string]interface{}{
+ "coin": pos.Coin,
+ "direction": pos.Direction,
+ "amount_usd": pos.AmountUSD,
+ "entry_spread": pos.EntrySpread,
+ "scales": pos.ScaleLevels,
+ "duration": time.Since(pos.StartedAt).Round(time.Second).String(),
+ "started_at": pos.StartedAt.Format("15:04:05"),
+ })
+ }
+ d.hub.Broadcast("positions", posList)
+ }
+
+ // 3. Arb scan results
+ d.mu.RLock()
+ scanCopy := d.lastScan
+ d.mu.RUnlock()
+
+ if len(scanCopy) > 0 {
+ scanList := make([]map[string]interface{}, 0, len(scanCopy))
+ for _, opp := range scanCopy {
+ scanList = append(scanList, map[string]interface{}{
+ "coin": opp.Coin,
+ "direction": opp.Direction,
+ "buy_ex": opp.BuyEx,
+ "sell_ex": opp.SellEx,
+ "buy_price": opp.BuyPrice,
+ "sell_price": opp.SellPrice,
+ "net_profit": opp.NetProfit,
+ "gross": opp.GrossBasis,
+ })
+ }
+ d.hub.Broadcast("arb", scanList)
+ }
+
+ // 4. Stats
+ converged, diverged, flat, total := d.trader.GetClosedStats()
+ stats := map[string]interface{}{
+ "total_trades": total,
+ "converged": converged,
+ "diverged": diverged,
+ "flat": flat,
+ "open_positions": len(positions),
+ "coins": len(prices),
+ }
+ d.hub.Broadcast("stats", stats)
+ }
+}
+
+// UpdateScan caches the latest arb scan results.
+func (d *Dashboard) UpdateScan(opps []*ArbOpportunity) {
+ d.mu.Lock()
+ d.lastScan = opps
+ d.scanTime = time.Now()
+ 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)
+}
+
+// ============================================================
+// HTTP Handlers
+// ============================================================
+
+func (d *Dashboard) handleIndex(w http.ResponseWriter, r *http.Request) {
+ data, err := staticFS.ReadFile("web/static/index.html")
+ if err != nil {
+ http.Error(w, "Not found", 404)
+ return
+ }
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ w.Write(data)
+}
+
+func (d *Dashboard) handleStatus(w http.ResponseWriter, r *http.Request) {
+ snap := d.store.GetAll()
+ positions := d.trader.GetOpenPositions()
+ converged, diverged, flat, total := d.trader.GetClosedStats()
+
+ resp := map[string]interface{}{
+ "prices": snap,
+ "positions": len(positions),
+ "stats": map[string]int{"total": total, "converged": converged, "diverged": diverged, "flat": flat},
+ }
+
+ writeJSON(w, resp)
+}
+
+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 {
+ coins = append(coins, c)
+ }
+ 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,
+ "exchange": exchange,
+ "points": points,
+ })
+}
+
+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,
+ "page": page,
+ "limit": limit,
+ })
+}
+
+func (d *Dashboard) handleTradeDetail(w http.ResponseWriter, r *http.Request) {
+ if d.db == nil {
+ 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,
+ })
+}
+
+func (d *Dashboard) handleSSE(w http.ResponseWriter, r *http.Request) {
+ flusher, ok := w.(http.Flusher)
+ if !ok {
+ http.Error(w, "Streaming not supported", 500)
+ return
+ }
+
+ w.Header().Set("Content-Type", "text/event-stream")
+ w.Header().Set("Cache-Control", "no-cache")
+ w.Header().Set("Connection", "keep-alive")
+ w.Header().Set("Access-Control-Allow-Origin", "*")
+
+ client := d.hub.Subscribe("")
+ defer d.hub.Unsubscribe(client)
+
+ // Send initial heartbeat
+ fmt.Fprintf(w, "event: connected\ndata: {\"status\":\"ok\"}\n\n")
+ flusher.Flush()
+
+ for {
+ select {
+ case <-r.Context().Done():
+ return
+ case msg, ok := <-client.ch:
+ if !ok {
+ return
+ }
+ fmt.Fprintf(w, "data: %s\n\n", msg)
+ flusher.Flush()
+ }
+ }
+}
+
+func writeJSON(w http.ResponseWriter, v interface{}) {
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(v)
+}
diff --git a/main.go b/main.go
index 8455a21..3c73fb6 100644
--- a/main.go
+++ b/main.go
@@ -45,6 +45,10 @@ func main() {
// Initialize trader
trader := NewTrader(cfg, database)
+
+ // Initialize dashboard (web server + SSE)
+ dashboard := NewDashboard(store, trader, database, ":8888")
+ go dashboard.Run()
if trader.IsConfigured() {
modeLabel := trader.ModeLabel()
log.Printf("[Trader] %s mode: automated trading ENABLED (threshold >= %.2f%%, $%.0f/trade)",
@@ -79,6 +83,7 @@ func main() {
for {
err := runner(func(coin string, price, bid, ask float64) {
store.SetWithSpread(coin, name, price, bid, ask)
+ dashboard.RecordPrice(coin, name, price)
})
log.Printf("[%s] WS error: %v (reconnecting...)", name, err)
select {
@@ -155,6 +160,7 @@ func main() {
// Scan for arbitrage entries using maker fees (limit orders)
makerOpps := ScanArbWithFees(store, makerFees)
+ dashboard.UpdateScan(makerOpps)
t2 := time.Now()
for _, opp := range makerOpps {
diff --git a/static.go b/static.go
new file mode 100644
index 0000000..e31748b
--- /dev/null
+++ b/static.go
@@ -0,0 +1,6 @@
+package main
+
+import "embed"
+
+//go:embed web/static/index.html web/static/app.js web/static/style.css
+var staticFS embed.FS
diff --git a/web/static/app.js b/web/static/app.js
new file mode 100644
index 0000000..07fb593
--- /dev/null
+++ b/web/static/app.js
@@ -0,0 +1,405 @@
+/* ============================================================
+ Exchange Monitor Dashboard — Frontend Logic
+ ============================================================ */
+
+(function() {
+'use strict';
+
+// ---- DOM refs ----
+const $ = id => document.getElementById(id);
+
+const els = {
+ clock: $('clock'),
+ connStatus: $('conn-status'),
+ priceBody: $('price-body'),
+ arbBody: $('arb-body'),
+ posBody: $('positions-body'),
+ tradesBody: $('trades-body'),
+ statTotal: $('stat-total'),
+ statConv: $('stat-converged'),
+ statDiv: $('stat-diverged'),
+ statFlat: $('stat-flat'),
+ statPos: $('stat-positions'),
+ statCoins: $('stat-coins'),
+ chartCoin: $('chart-coin'),
+ chartExch: $('chart-exchange'),
+ chartCanvas: $('priceChart'),
+};
+
+// ---- Clock ----
+function updateClock() {
+ const now = new Date();
+ els.clock.textContent = now.toLocaleTimeString('zh-CN', { hour12: false });
+}
+setInterval(updateClock, 1000);
+updateClock();
+
+// ---- Price table helpers ----
+const EXCHANGES = ['Binance', 'HyperLiquid', 'Bitget', 'dYdX'];
+const COINS = ['DOGE', 'LINK', 'ONDO', 'OP', 'WIF', 'ARB'];
+
+function formatPrice(p) {
+ if (p == null || p <= 0) return '-';
+ if (p >= 100) return p.toFixed(2);
+ if (p >= 1) return p.toFixed(4);
+ 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 '';
+}
+
+// ---- Price history for chart ----
+const priceCache = {}; // coin.exchange -> { last: float, points: [{t,p}] }
+
+// ---- SSE Connection ----
+let eventSource = null;
+
+function connectSSE() {
+ if (eventSource) {
+ eventSource.close();
+ }
+
+ eventSource = new EventSource('/events');
+
+ eventSource.addEventListener('connected', () => {
+ els.connStatus.textContent = '● 已连接';
+ els.connStatus.className = 'status-online';
+ });
+
+ eventSource.onerror = () => {
+ els.connStatus.textContent = '● 已断开 (重连中...)';
+ els.connStatus.className = 'status-offline';
+ setTimeout(connectSSE, 3000);
+ };
+
+ eventSource.onmessage = (e) => {
+ try {
+ const msg = JSON.parse(e.data);
+ const handler = eventHandlers[msg.event];
+ if (handler) handler(msg.data);
+ } catch(err) {
+ // ignore parse errors
+ }
+ };
+}
+
+// ---- Event Handlers ----
+const eventHandlers = {};
+
+eventHandlers.prices = (prices) => {
+ if (!prices || prices.length === 0) return;
+
+ // Build table rows
+ let html = '';
+ let coinsOnline = 0;
+
+ for (const coin of COINS) {
+ const row = prices.find(p => p.coin === coin);
+ if (!row) {
+ html += `
| ${coin} | ${EXCHANGES.map(() => '- | ').join('')}
`;
+ continue;
+ }
+ coinsOnline++;
+
+ const cells = EXCHANGES.map(ex => {
+ const p = row[ex];
+ const sp = row[ex + '_spread'];
+ const key = coin + '.' + ex;
+ const prev = priceCache[key];
+ 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);
+ if (priceCache[key].points.length > 500) {
+ priceCache[key].points = priceCache[key].points.slice(-500);
+ }
+ }
+
+ let display = formatPrice(p);
+ if (sp && sp > 0.01) {
+ display += ` (${sp.toFixed(3)}%)`;
+ }
+ return `${display} | `;
+ });
+
+ html += `| ${coin} | ${cells.join('')}
`;
+ }
+
+ els.priceBody.innerHTML = html;
+
+ // Update coin selector if needed
+ updateChartSelectors(prices);
+};
+
+eventHandlers.arb = (opps) => {
+ if (!opps || opps.length === 0) {
+ els.arbBody.innerHTML = '| 暂无套利机会 |
';
+ return;
+ }
+
+ const html = opps.slice(0, 10).map(opp => {
+ const cls = opp.net_profit > 0.1 ? 'text-green' : opp.net_profit > 0.05 ? 'text-yellow' : '';
+ return `
+ | ${opp.coin} |
+ ${opp.direction} |
+ ${formatPrice(opp.buy_price)} |
+ ${formatPrice(opp.sell_price)} |
+ ${opp.net_profit.toFixed(4)} |
+
`;
+ }).join('');
+
+ els.arbBody.innerHTML = html;
+};
+
+eventHandlers.positions = (positions) => {
+ if (!positions || positions.length === 0) {
+ els.posBody.innerHTML = '| 无持仓 |
';
+ return;
+ }
+
+ const html = positions.map(p => `
+ | ${p.coin} |
+ ${p.direction} |
+ $${p.amount_usd.toFixed(0)} |
+ ${p.entry_spread.toFixed(4)}% |
+ ${p.scales} |
+ ${p.duration} |
+
`).join('');
+
+ els.posBody.innerHTML = html;
+};
+
+eventHandlers.stats = (stats) => {
+ els.statTotal.textContent = stats.total_trades || 0;
+ els.statConv.textContent = stats.converged || 0;
+ els.statDiv.textContent = stats.diverged || 0;
+ els.statFlat.textContent = stats.flat || 0;
+ els.statPos.textContent = stats.open_positions || 0;
+ els.statCoins.textContent = stats.coins || 0;
+};
+
+// ---- Chart ----
+let chart = null;
+
+function initChart() {
+ const ctx = els.chartCanvas.getContext('2d');
+ chart = new Chart(ctx, {
+ type: 'line',
+ data: {
+ datasets: [{
+ label: 'Price',
+ data: [],
+ borderColor: '#58a6ff',
+ backgroundColor: 'rgba(88, 166, 255, 0.1)',
+ borderWidth: 2,
+ pointRadius: 0,
+ fill: true,
+ tension: 0.2,
+ }]
+ },
+ options: {
+ responsive: true,
+ maintainAspectRatio: false,
+ animation: { duration: 0 },
+ plugins: {
+ legend: { display: false },
+ tooltip: {
+ 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 '';
+ },
+ 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' });
+ }
+ },
+ grid: { color: 'rgba(48, 54, 61, 0.5)' },
+ },
+ y: {
+ display: true,
+ ticks: {
+ color: '#8b949e',
+ callback: (val) => val.toFixed(4),
+ },
+ grid: { color: 'rgba(48, 54, 61, 0.3)' },
+ }
+ }
+ }
+ });
+}
+
+function updateChartSelectors(prices) {
+ const coinSel = els.chartCoin;
+ const exSel = els.chartExch;
+
+ // Populate coins if empty
+ if (coinSel.options.length <= 1) {
+ const currentCoin = coinSel.value;
+ coinSel.innerHTML = '';
+ for (const row of prices) {
+ const opt = document.createElement('option');
+ 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;
+ }
+ }
+
+ // Populate exchanges if empty
+ if (exSel.options.length <= 1) {
+ exSel.innerHTML = '';
+ for (const ex of EXCHANGES) {
+ const opt = document.createElement('option');
+ 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);
+ }
+}
+
+function updateChart(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');
+ }
+ return;
+ }
+
+ 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');
+ }
+}
+
+// ---- Chart controls ----
+els.chartCoin.addEventListener('change', () => {
+ const coin = els.chartCoin.value;
+ const ex = els.chartExch.value;
+ if (coin && ex) updateChart(coin, ex);
+});
+
+els.chartExch.addEventListener('change', () => {
+ const coin = els.chartCoin.value;
+ const ex = els.chartExch.value;
+ if (coin && ex) updateChart(coin, ex);
+});
+
+// ---- Chart auto-refresh ----
+let chartRefreshTimer = null;
+let chartRefreshInterval = 2000; // refresh chart every 2s
+
+function startChartRefresh() {
+ if (chartRefreshTimer) return;
+ chartRefreshTimer = setInterval(() => {
+ const coin = els.chartCoin.value;
+ const ex = els.chartExch.value;
+ if (coin && ex) updateChart(coin, ex);
+ }, chartRefreshInterval);
+}
+
+// ---- Trades loading ----
+async function loadTrades() {
+ try {
+ const resp = await fetch('/api/trades');
+ const data = await resp.json();
+ const trades = data.trades || [];
+
+ if (trades.length === 0) {
+ els.tradesBody.innerHTML = '| 暂无交易记录 |
';
+ return;
+ }
+
+ const html = trades.slice(0, 20).map(t => {
+ const pnlCls = t.NetPnl > 0 ? 'text-green' : t.NetPnl < 0 ? 'text-red' : '';
+ const convCls = t.Convergence === '价差收敛' ? 'text-green' :
+ t.Convergence === '价差发散' ? 'text-red' : 'text-yellow';
+ return `
+ | ${t.ClosedAt ? new Date(t.ClosedAt).toLocaleTimeString('zh-CN', { hour12: false }) : '-'} |
+ ${t.Coin} |
+ ${t.Direction} |
+ ${t.EntrySpread ? t.EntrySpread.toFixed(4) : '-'} |
+ ${t.ExitSpread ? t.ExitSpread.toFixed(4) : '-'} |
+ ${t.NetPnl != null ? t.NetPnl.toFixed(4) : '-'} |
+ ${t.Convergence || '-'} |
+ ${t.ExitReason || '-'} |
+
`;
+ }).join('');
+
+ els.tradesBody.innerHTML = html;
+ } catch (err) {
+ els.tradesBody.innerHTML = '| 加载失败 |
';
+ }
+}
+
+// ---- Init ----
+function init() {
+ connectSSE();
+ initChart();
+ startChartRefresh();
+ loadTrades();
+
+ // Refresh trades every 10s
+ setInterval(loadTrades, 10000);
+}
+
+// Start when DOM ready
+if (document.readyState === 'loading') {
+ document.addEventListener('DOMContentLoaded', init);
+} else {
+ init();
+}
+
+})();
diff --git a/web/static/index.html b/web/static/index.html
new file mode 100644
index 0000000..a9ed20c
--- /dev/null
+++ b/web/static/index.html
@@ -0,0 +1,139 @@
+
+
+
+
+
+Exchange Monitor Dashboard
+
+
+
+
+
+
+
+
+
+
+ 📊 统计数据
+
+
0
+
0
+
0
+
0
+
0
+
0
+
+
+
+
+
+ 💰 实时价格
+
+
+
+
+ | 币种 |
+ Binance |
+ HyperLiquid |
+ Bitget |
+ dYdX |
+
+
+
+ | 等待数据... |
+
+
+
+
+
+
+
+ 🎯 套利机会
+
+
+
+
+ | 币种 |
+ 方向 |
+ 买价 |
+ 卖价 |
+ 净利% |
+
+
+
+ | 等待数据... |
+
+
+
+
+
+
+
+ 🔒 当前持仓
+
+
+
+
+ | 币种 |
+ 方向 |
+ 规模 |
+ 开仓价差 |
+ 加仓 |
+ 时长 |
+
+
+
+ | 等待数据... |
+
+
+
+
+
+
+
+
+
+
+ 📋 历史交易
+
+
+
+
+ | 时间 |
+ 币种 |
+ 方向 |
+ 入价差 |
+ 出价差 |
+ 净利% |
+ 结果 |
+ 原因 |
+
+
+
+ | 等待数据... |
+
+
+
+
+
+
+
+
+
+
diff --git a/web/static/style.css b/web/static/style.css
new file mode 100644
index 0000000..9fdf16f
--- /dev/null
+++ b/web/static/style.css
@@ -0,0 +1,163 @@
+/* ============================================================
+ Exchange Monitor Dashboard — Dark Theme
+ ============================================================ */
+
+:root {
+ --bg: #0d1117;
+ --card: #161b22;
+ --border: #30363d;
+ --text: #c9d1d9;
+ --text-dim: #8b949e;
+ --accent: #58a6ff;
+ --green: #3fb950;
+ --red: #f85149;
+ --yellow: #d29922;
+ --blue: #58a6ff;
+}
+
+* { margin: 0; padding: 0; box-sizing: border-box; }
+
+body {
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
+ background: var(--bg);
+ color: var(--text);
+ font-size: 14px;
+ line-height: 1.5;
+ min-height: 100vh;
+}
+
+#app { max-width: 1440px; margin: 0 auto; padding: 16px; }
+
+/* Header */
+header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 12px 16px;
+ background: var(--card);
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ margin-bottom: 16px;
+}
+
+header h1 { font-size: 18px; font-weight: 600; }
+.header-meta { display: flex; align-items: center; gap: 8px; font-size: 13px; color: var(--text-dim); }
+.sep { color: var(--border); }
+.status-offline { color: var(--red); }
+.status-online { color: var(--green); }
+
+/* Grid layout */
+.grid {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 12px;
+}
+.card-wide { grid-column: 1 / -1; }
+
+/* Cards */
+.card {
+ background: var(--card);
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ padding: 12px;
+}
+
+.card h2 {
+ font-size: 14px;
+ font-weight: 600;
+ color: var(--text-dim);
+ margin-bottom: 10px;
+ padding-bottom: 8px;
+ border-bottom: 1px solid var(--border);
+}
+
+/* Stats row */
+.stats-row {
+ display: flex;
+ gap: 16px;
+ flex-wrap: wrap;
+}
+.stat {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ min-width: 60px;
+}
+.stat label { font-size: 11px; color: var(--text-dim); margin-bottom: 2px; }
+.stat span { font-size: 20px; font-weight: 700; }
+.pct-green { color: var(--green); }
+.pct-red { color: var(--red); }
+.pct-gray { color: var(--text-dim); }
+.pct-yellow { color: var(--yellow); }
+.pct-blue { color: var(--blue); }
+
+/* Tables */
+.table-wrap {
+ overflow-x: auto;
+ max-height: 320px;
+ overflow-y: auto;
+}
+table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 13px;
+}
+th {
+ text-align: left;
+ padding: 6px 8px;
+ color: var(--text-dim);
+ font-weight: 500;
+ font-size: 11px;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ position: sticky;
+ top: 0;
+ background: var(--card);
+ border-bottom: 1px solid var(--border);
+}
+td {
+ padding: 5px 8px;
+ border-bottom: 1px solid rgba(48, 54, 61, 0.5);
+ white-space: nowrap;
+}
+tr:hover td { background: rgba(88, 166, 255, 0.05); }
+.loading { text-align: center; color: var(--text-dim); padding: 20px !important; }
+
+.text-green { color: var(--green); }
+.text-red { color: var(--red); }
+.text-yellow { color: var(--yellow); }
+.text-dim { color: var(--text-dim); }
+.text-right { text-align: right; }
+
+/* Chart controls */
+.chart-controls {
+ display: flex;
+ gap: 8px;
+ margin-bottom: 10px;
+}
+.chart-controls select {
+ background: var(--bg);
+ color: var(--text);
+ border: 1px solid var(--border);
+ border-radius: 4px;
+ padding: 4px 8px;
+ font-size: 13px;
+ cursor: pointer;
+}
+.chart-container {
+ position: relative;
+ height: 300px;
+}
+
+/* Scrollbar */
+::-webkit-scrollbar { width: 6px; height: 6px; }
+::-webkit-scrollbar-track { background: transparent; }
+::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
+::-webkit-scrollbar-thumb:hover { background: #484f58; }
+
+/* Responsive */
+@media (max-width: 768px) {
+ .grid { grid-template-columns: 1fr; }
+ header { flex-direction: column; gap: 8px; }
+ .stats-row { justify-content: center; }
+}