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
+
+
+ + +
+

💰 实时价格

+
+ + + + + + + + + + + + + +
币种BinanceHyperLiquidBitgetdYdX
等待数据...
+
+
+ + +
+

🎯 套利机会

+
+ + + + + + + + + + + + + +
币种方向买价卖价净利%
等待数据...
+
+
+ + +
+

🔒 当前持仓

+
+ + + + + + + + + + + + + + +
币种方向规模开仓价差加仓时长
等待数据...
+
+
+ + +
+

📈 价格走势

+
+ + +
+
+ +
+
+ + +
+

📋 历史交易

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