feat: 手续费改为逐笔USD累算 + Vite React前端 + system_orders表 + README

This commit is contained in:
jackyu66git
2026-05-04 04:34:27 +08:00
parent 15a4684208
commit 0288dc8284
18 changed files with 2919 additions and 521 deletions
+7
View File
@@ -25,3 +25,10 @@ trade_stats.txt
# Temp # Temp
/tmp/ /tmp/
# Python scripts
check_db.py
# Frontend
frontend/node_modules/
frontend/dist/
+93 -197
View File
@@ -1,216 +1,112 @@
# Exchange Monitor Go # ⚡ 跨交易所永续合约套利监控
Cross-exchange perpetual futures arbitrage monitoring and automated trading system. Tracks **Bitget ↔ HyperLiquid** spread in real time, executes simulated trades at configurable thresholds. Bitget ↔ HyperLiquid 跨交易所永续合约价差套利系统。支持模拟盘/实盘交易、价差监控、自动开仓/加仓/平仓、Web 仪表盘。
## Architecture ## 功能特点
- **实时价差监控** — 200ms 扫描间隔,追踪 DOGE/LINK/ONDO/OP/WIF/ARB 六个币种
- **自动套利交易** — 价差超过阈值自动开仓,收敛自动平仓,支持多级加仓
- **模拟/实盘双模式** — `TestMode` 控制,模拟模式无需真实 API Key
- **Web 仪表盘** — Go 内置 HTTP Server + Vite React 前端,SSE 实时推送
- **SQLite 持久化** — 交易记录、订单明细、手续费明细全量存储
- **手续费精确计算** — 逐笔累加实际 USD 手续费(开仓费+平仓费),非百分比估算
- **Telegram 通知** — 开仓/平仓/异常实时推送
## 架构
``` ```
──────────────┐ ┌─────────────────────────────────────────────────┐
│ Bitget │◄──── ticker WS (trading exchange) │ scanner.go ← 每 200ms 扫描价差 │
└──────────────┘ │ ↓ 发现机会 (NetProfit > 阈值) │
PriceStore ─────────┼──────────────┤ │ trader.go ← 开仓/加仓/平仓逻辑 │
│ HyperLiquid │◄──── webData2 WS (trading exchange) │ ↓ 持久化 │
└──────────────┘ │ db/ ← SQLite (trades / orders / system) │
│ ↓ SSE 推送
┌─────────▼─────────┐ │ dashboard.go ← HTTP Server :8888 │
│ ScanBGHL (200ms) │ ↓
│ BG ↔ HL only │ frontend/ ← Vite + React 仪表盘
└─────────┬─────────┘ └─────────────────────────────────────────────────┘
┌───────────────▼────────────────┐
│ Trader │
│ TryEntry (async goroutine) │
│ → placeOrder (REST/mock) │
│ Tick / Exit / Scale-in │
│ Config-driven thresholds │
└───────────────┬────────────────┘
┌─────────▼─────────┐
│ SpreadWindowTracker│
│ (opportunity life) │
└─────────┬─────────┘
┌──────────────────┼──────────────────┐
│ │ │
┌─────▼─────┐ ┌────────▼───────┐ ┌─────▼─────┐
│ Notifier │ │ Dashboard │ │ DB │
│ Telegram │ │ :8888 │ │ SQLite │
│ │ │ Stats calc │ │ trades.db │
│ │ │ Blacklist UI │ │ │
└───────────┘ └────────────────┘ └───────────┘
``` ```
## Tracked Coins ## 快速开始
| Coin | Bitget | HyperLiquid | ### 1. 配置
|:----:|:---------:|:-----------:|
| DOGE | DOGEUSDT | DOGE |
| LINK | LINKUSDT | LINK |
| ONDO | ONDOUSDT | ONDO |
| OP | OPUSDT | OP |
| WIF | WIFUSDT | WIF |
| ARB | ARBUSDT | ARB |
> **Note:** Binance and dYdX have been removed — only Bitget and HyperLiquid are monitored. 编辑 `config.json`
## Requirements ```json
{
"scan_interval_ms": 200,
"trade_enabled": true,
"test_mode": true,
"trade_threshold": 0.10,
"take_profit_pct": 0.20,
"trade_amount_usd": 5,
"max_positions": 5,
"taker_fee_bitget": 0.060,
"taker_fee_hyperliquid": 0.045,
"telegram_bot_token": "xxx",
"telegram_chat_id": "xxx"
}
```
- Go 1.25+ ### 2. 启动
- WebSocket connectivity to Bitget and HyperLiquid
## Quick Start
```bash ```bash
cd exchange-monitor-go # 一键启动(自动清理旧进程 + 编译 + 运行)
go build -o exchange-monitor . bash start.sh
# Edit config.json to set parameters
./exchange-monitor # 清空数据库 + 启动
bash start.sh --clean
# 强制重新编译 + 启动
bash start.sh --rebuild
``` ```
Then open [http://localhost:8888](http://localhost:8888) for the Web dashboard. 仪表盘地址:http://localhost:8888
## Configuration ### 3. 前端开发
### config.json (all trading parameters) ```bash
cd frontend
All numerical parameters are defined in `config.json`**no need to edit Go source**: npm install
npm run dev # 开发模式 (Vite HMR :5173)
| Parameter | Default | Description | npm run build # 构建生产版本
|:----------|:-------:|:------------|
| `test_mode` | `true` | Simulate orders with mock fills (no real API calls) |
| `trade_enabled` | `true` | Enable automated trading |
| `scan_interval_ms` | `200` | Scanner loop interval (ms) |
| `arb_threshold` | `0.03` | Min net profit % to trigger alert |
| `trade_threshold` | `0.10` | Min net profit % to execute trade |
| `trade_amount_usd` | `5` | USD per leg (per order) |
| `trade_cooldown_ms` | `30000` | Cooldown between same-coin trades (ms) |
| `max_positions` | `5` | Maximum concurrent open positions |
| `initial_capital` | `1000` | Starting capital in USD (for PnL %) |
| `mock_slippage_pct` | `0.005` | Simulated slippage per leg (%) |
| `blacklist_duration_sec` | `3600` | Coin blacklist duration (seconds) |
| `taker_fee_bitget` | `0.060` | Bitget taker fee rate (%) |
| `taker_fee_hyperliquid` | `0.045` | HyperLiquid taker fee rate (%) |
| `take_profit_pct` | `0.20` | Net profit % threshold for take-profit |
| `spread_reverse_exit_pct` | `0` | Spread convergence/reversal exit (0 = exit when ≤ 0) |
| `position_timeout_sec` | `1800` | Max position hold time before auto-close (30 min) |
| `leg_delay_ms` | `300` | Delay between placing long and short legs |
| `reversal_tolerance_pct` | `0.1` | Price movement tolerance for entry sanity check |
| `scale_step_pct` | `0.10` | Spread widening % to trigger each scale-in level |
| `scale_cooldown_sec` | `5` | Minimum seconds between scale-ins |
### .env (secrets only)
Secrets (API keys) go in `.env` — never checked into git:
| Variable | Description |
|:---------|:------------|
| `TELEGRAM_BOT_TOKEN` | Telegram bot token for notifications |
| `TELEGRAM_CHAT_ID` | Target chat ID for notifications |
| `BITGET_API_KEY` | Bitget API key (skipped if test_mode) |
| `BITGET_API_SECRET` | Bitget API secret |
| `BITGET_PASSPHRASE` | Bitget passphrase |
| `HL_PRIVATE_KEY` | HyperLiquid ed25519 private key hex |
| `HL_ADDRESS` | HyperLiquid wallet address |
> **Priority:** `.env` vars > `config.json` > code defaults.
## Fee Model
All trades use **taker** (market orders). Only Bitget and HyperLiquid:
| Exchange | Taker Fee |
|:---------|:---------:|
| Bitget | configurable (`taker_fee_bitget`, default 0.060%) |
| HyperLiquid | configurable (`taker_fee_hyperliquid`, default 0.045%) |
Round trip (2 legs entry + 2 legs exit): configurable, default **0.21%** total fees.
## Trading Logic
1. **Scanner** runs every `scan_interval_ms`, checks all coins for BG ↔ HL spread
2. **Entry** when net profit ≥ `trade_threshold` (after full round-trip fees)
- Uses scan-time prices directly (no re-read to avoid WS jitter)
- **Async goroutine** — `TryEntry` returns immediately, `executeEntry` runs in background
- Reversal tolerance check prevents entry on flipped spreads
- `entering` map prevents duplicate entries on same coin
3. **Scale-in** adds another leg-worth when spread widens another `scale_step_pct` (default 0.10%)
4. **Exit** conditions (whichever hits first):
- **Net profit ≥ `take_profit_pct`** → **利润止盈**(大盈利退出)
- **Spread narrowed to ≤ 0.02% + netPnl > 0** → **价差收敛止盈**(小盈利退出)
- **Spread flipped negative** → **价差反转平仓**(紧急止损)
- **Position held > `position_timeout_sec`** → **超时平仓**
5. **Direction**: BG → HL (buy BG, sell HL) or HL → BG (buy HL, sell BG)
## Blacklist Mechanism
- Positions held open for > 10 minutes without converging are auto-closed and blacklisted
- Blacklisted coins are skipped for `blacklist_duration_sec` (default 1 hour)
- Blacklist state visible on the dashboard
## Spread Window Monitoring
`SpreadWindowTracker` runs every scan tick and measures how long each coin's spread stays above the trade threshold:
- Records window **start time** when net profit first hits threshold
- Tracks real **peak net profit** during the window
- Logs window **duration + peak** when spread converges below threshold
- Covers both directions (BG→HL and HL→BG) independently
## Web Dashboard
Built-in HTTP server at `:8888` with real-time SSE push (1-second refresh):
- **Price table** — live prices from both exchanges
- **BG↔HL spread** — per-coin arbitrage spread
- **Open positions** — live PnL estimate, scaling level, duration, sorting by coin
- **Trade history** — past trades with detail view
- **Blacklist** — currently blacklisted coins and remaining time
- **Connection status** — exchange health (online / stale / offline)
## DB & Persistence
- SQLite at current directory (auto-deleted on each restart in test mode)
- Tracks open positions across restarts (`restoreOpenPositions`)
- Stores all closed trades with full PnL details
## Signals
| Signal | Action |
|:-------|:-------|
| `Ctrl+C` / `SIGINT` | Graceful shutdown (closes all WS connections) |
| `SIGUSR1` | Dump convergence statistics to `trade_stats.txt` |
## Project Structure
```
exchange-monitor-go/
├── main.go # Entry point, WS startup, main loop
├── config.go # config.json + .env hierarchical config
├── config.json # All trading parameters (editable)
├── types.go # PriceStore, TrackedCoin, ArbOpportunity
├── scanner.go # ScanBGHL — arbitrage scanner
├── trader.go # Position management, entry/exit/scale-in
├── dashboard.go # Web server + SSE + stats calc
├── toaster.go # Telegram notifications
├── static.go # Embedded web static files
├── .env # Secrets only (API keys)
├── exchange/
│ ├── connector.go # Generic WS connector with reconnect
│ ├── hyperliquid.go # HyperLiquid webData2 WS
│ ├── hyperliquid_trade.go # HL REST trade API
│ ├── bitget.go # Bitget ticker WS (TextPing for stability)
│ ├── bitget_trade.go # Bitget REST trade API
│ ├── helpers.go # Package helpers
│ └── ping.go # Accessibility check tools
├── db/
│ ├── db.go # SQLite open/migrate
│ └── trade_repo.go # Trade record queries
└── web/static/
├── index.html # Dashboard HTML
├── app.js # SSE client + UI logic
└── style.css # Dashboard CSS
``` ```
## Disclaimer 后端优先从 `frontend/dist/` 读取静态文件(热加载),回退到 Go embed。
This software is for educational/research purposes. Use at your own risk. Cryptocurrency trading involves substantial risk of loss. ## 配置参数
| 参数 | 说明 | 默认 |
|------|------|------|
| `scan_interval_ms` | 扫描间隔 (ms) | 200 |
| `trade_threshold` | 开仓阈值 (%) | 0.10 |
| `take_profit_pct` | 止盈净利 (%) | 0.20 |
| `trade_amount_usd` | 每腿交易额 ($) | 5 |
| `max_positions` | 最大并行持仓 | 5 |
| `taker_fee_bitget` | Bitget 吃单费率 (%) | 0.060 |
| `taker_fee_hyperliquid` | HyperLiquid 吃单费率 (%) | 0.045 |
| `scale_step_pct` | 加仓步长 (%) | 0.10 |
| `position_timeout` | 最长持仓时间 | 10m |
| `leg_delay` | 两腿下单间隔 | 300ms |
## 数据库
SQLite (`data/trades.db`),三张核心表:
| 表 | 说明 |
|----|------|
| `trades` | 交易主表 — 价差、PnL、手续费 ($) |
| `orders` | 订单明细 — 每腿的开仓/加仓/平仓、手续费 ($) |
| `system_orders` | 系统订单 — 双向关联 long↔short 订单 |
## 版本历史
### v1.2 (当前)
-`system_orders` 表,记录系统级开仓/加仓/平仓
- ✨ 手续费改为逐笔累加 USD,不再用百分比估算
- ✨ Vite + React 前端,支持热加载
- ✨ Web 仪表盘持仓 PnL 美元化显示
- 🐛 修复 `persistTrade` 费用在 `SaveTrade` 后才累加导致 fee=0 的 bug
- 🗑 移除老版 Chart.js 图表
+29 -6
View File
@@ -7,6 +7,7 @@ import (
"log" "log"
"math" "math"
"net/http" "net/http"
"os"
"sync" "sync"
"time" "time"
@@ -217,8 +218,15 @@ func (d *Dashboard) Run() {
mux := http.NewServeMux() mux := http.NewServeMux()
staticSub, err := fs.Sub(staticFS, "web/static") // Try disk-based serving first (hot-reload friendly), fall back to embed
if err != nil { staticSub, err := fs.Sub(staticFS, "frontend/dist")
if diskFS := os.DirFS("frontend/dist"); true {
if _, diskErr := fs.Stat(diskFS, "index.html"); diskErr == nil {
staticSub = diskFS
log.Printf("[Web] Serving from disk: frontend/dist/ (hot reload enabled)")
}
}
if err != nil && staticSub == nil {
log.Printf("[Web] Failed to create static sub-fs: %v", err) log.Printf("[Web] Failed to create static sub-fs: %v", err)
} else { } else {
mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticSub)))) mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticSub))))
@@ -382,8 +390,10 @@ func (d *Dashboard) broadcastLoop() {
shortAvg := weightedAvgPrice(pos.ShortEntryPrices, pos.AmountUSD/float64(max(1, len(pos.ShortEntryPrices)))) shortAvg := weightedAvgPrice(pos.ShortEntryPrices, pos.AmountUSD/float64(max(1, len(pos.ShortEntryPrices))))
longPnl := (longCurrent - longAvg) / longAvg * 100 longPnl := (longCurrent - longAvg) / longAvg * 100
shortPnl := (shortAvg - shortCurrent) / shortAvg * 100 shortPnl := (shortAvg - shortCurrent) / shortAvg * 100
totalFees := 2 * (takerFees[ExBitget] + takerFees[ExHyperLiquid]) feeEntryUSD := float64(1+pos.ScaleLevels) * (pos.AmountUSD / float64(max(1, 1+pos.ScaleLevels))) * (takerFees[ExBitget] + takerFees[ExHyperLiquid]) / 100
netPnl := longPnl + shortPnl - totalFees feeExitUSD := pos.AmountUSD * (takerFees[ExBitget] + takerFees[ExHyperLiquid]) / 100
pricePnLUSD := pos.AmountUSD * (longPnl + shortPnl) / 100
netPnLUSD := pricePnLUSD - feeEntryUSD - feeExitUSD
currentSpread := (hlP - bgP) / bgP * 100 currentSpread := (hlP - bgP) / bgP * 100
if pos.LongLeg.Exchange == ExHyperLiquid { if pos.LongLeg.Exchange == ExHyperLiquid {
@@ -391,7 +401,7 @@ func (d *Dashboard) broadcastLoop() {
currentSpread = (bgP - hlP) / hlP * 100 currentSpread = (bgP - hlP) / hlP * 100
} }
posEntry["current_spread"] = math.Round(currentSpread*10000) / 10000 posEntry["current_spread"] = math.Round(currentSpread*10000) / 10000
posEntry["pnl_est"] = math.Round(netPnl*10000) / 10000 posEntry["pnl_est"] = math.Round(netPnLUSD*10000) / 10000
} }
} }
@@ -519,7 +529,15 @@ func (d *Dashboard) BroadcastEvent(event string, data interface{}) {
// ============================================================ // ============================================================
func (d *Dashboard) handleIndex(w http.ResponseWriter, r *http.Request) { func (d *Dashboard) handleIndex(w http.ResponseWriter, r *http.Request) {
data, err := staticFS.ReadFile("web/static/index.html") var data []byte
var err error
// Try disk first (hot reload)
data, err = os.ReadFile("frontend/dist/index.html")
if err != nil {
// Fall back to embed
data, err = staticFS.ReadFile("frontend/dist/index.html")
}
if err != nil { if err != nil {
http.Error(w, "Not found", 404) http.Error(w, "Not found", 404)
return return
@@ -602,6 +620,11 @@ func (d *Dashboard) handleTrades(w http.ResponseWriter, r *http.Request) {
page := 1 page := 1
limit := 20 limit := 20
coin := r.URL.Query().Get("coin") coin := r.URL.Query().Get("coin")
if l := r.URL.Query().Get("limit"); l != "" {
if n, err := fmt.Sscanf(l, "%d", &limit); err != nil || n != 1 {
limit = 20
}
}
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)
+14
View File
@@ -94,6 +94,20 @@ func (d *DB) migrate() error {
CREATE INDEX IF NOT EXISTS idx_trades_status ON trades(status); CREATE INDEX IF NOT EXISTS idx_trades_status ON trades(status);
CREATE INDEX IF NOT EXISTS idx_trades_opened ON trades(opened_at); CREATE INDEX IF NOT EXISTS idx_trades_opened ON trades(opened_at);
CREATE INDEX IF NOT EXISTS idx_orders_trade_id ON orders(trade_id); CREATE INDEX IF NOT EXISTS idx_orders_trade_id ON orders(trade_id);
CREATE TABLE IF NOT EXISTS system_orders (
id INTEGER PRIMARY KEY AUTOINCREMENT,
trade_id INTEGER NOT NULL REFERENCES trades(id),
type TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'filled',
spread REAL,
long_price REAL,
short_price REAL,
long_order_id INTEGER REFERENCES orders(id),
short_order_id INTEGER REFERENCES orders(id),
created_at DATETIME NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_system_orders_trade ON system_orders(trade_id);
` `
_, err := d.Exec(schema) _, err := d.Exec(schema)
if err != nil { if err != nil {
+28
View File
@@ -48,6 +48,20 @@ type OrderRecord struct {
CreatedAt time.Time CreatedAt time.Time
} }
// SystemOrderRecord represents one system-level arbitrage action (entry/scale/exit).
type SystemOrderRecord struct {
ID int64
TradeID int64
Type string // entry / scale / exit
Status string // filled / failed
Spread *float64
LongPrice *float64
ShortPrice *float64
LongOrderID *int64
ShortOrderID *int64
CreatedAt time.Time
}
// SaveTrade inserts a new trade and returns its ID. // SaveTrade inserts a new trade and returns its ID.
func (d *DB) SaveTrade(t *TradeRecord) (int64, error) { func (d *DB) SaveTrade(t *TradeRecord) (int64, error) {
res, err := d.Exec(`INSERT INTO trades ( res, err := d.Exec(`INSERT INTO trades (
@@ -150,6 +164,20 @@ func (d *DB) SaveOrder(o *OrderRecord) (int64, error) {
return res.LastInsertId() return res.LastInsertId()
} }
// SaveSystemOrder inserts a system-level order record.
func (d *DB) SaveSystemOrder(o *SystemOrderRecord) (int64, error) {
res, err := d.Exec(`INSERT INTO system_orders
(trade_id, type, status, spread, long_price, short_price, long_order_id, short_order_id, created_at)
VALUES (?,?,?,?,?, ?,?,?,?)`,
o.TradeID, o.Type, o.Status, o.Spread,
o.LongPrice, o.ShortPrice, o.LongOrderID, o.ShortOrderID, o.CreatedAt,
)
if err != nil {
return 0, err
}
return res.LastInsertId()
}
// GetTradeByID returns a single trade with its orders. // GetTradeByID returns a single trade with its orders.
func (d *DB) GetTradeByID(id int64) (*TradeRecord, []OrderRecord, error) { func (d *DB) GetTradeByID(id int64) (*TradeRecord, []OrderRecord, error) {
row := d.QueryRow(`SELECT id, coin, direction, status, entry_spread, exit_spread, row := d.QueryRow(`SELECT id, coin, direction, status, entry_spread, exit_spread,
+12
View File
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Exchange Monitor Dashboard</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
+1568
View File
File diff suppressed because it is too large Load Diff
+19
View File
@@ -0,0 +1,19 @@
{
"name": "exchange-monitor-frontend",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.2.1",
"vite": "^5.4.2"
}
}
+226
View File
@@ -0,0 +1,226 @@
: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); }
/* Connection status dots */
#conn-detail { font-size: 11px; white-space: nowrap; }
/* 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); }
.trade-row { cursor: pointer; }
.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; }
/* 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; }
}
/* Blacklist items */
#bl-body { display: flex; gap: 8px; flex-wrap: wrap; }
.bl-item {
background: rgba(248, 81, 73, 0.1);
border: 1px solid rgba(248, 81, 73, 0.3);
border-radius: 4px;
padding: 4px 10px;
font-size: 12px;
color: var(--red);
cursor: default;
}
/* Trade Detail Modal */
.modal-overlay {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.7);
z-index: 1000;
display: flex;
align-items: flex-start;
justify-content: center;
padding: 40px 16px;
overflow-y: auto;
}
.modal-content {
background: var(--card);
border: 1px solid var(--border);
border-radius: 12px;
max-width: 700px;
width: 100%;
box-shadow: 0 8px 32px rgba(0,0,0,0.5);
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
border-bottom: 1px solid var(--border);
}
.modal-header h2 { font-size: 16px; margin: 0; padding: 0; border: none; color: var(--text); }
.modal-close {
background: none;
border: none;
color: var(--text-dim);
font-size: 20px;
cursor: pointer;
padding: 4px 8px;
border-radius: 4px;
line-height: 1;
}
.modal-close:hover { background: rgba(255,255,255,0.1); color: var(--text); }
#trade-detail-body { padding: 0; }
.detail-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0;
}
.detail-section {
padding: 14px 20px;
border-bottom: 1px solid rgba(48,54,61,0.4);
}
.detail-section:last-child { border-bottom: none; }
.detail-section-full { grid-column: 1 / -1; }
.detail-section h3 {
font-size: 12px;
color: var(--text-dim);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 8px;
}
.detail-row {
display: flex;
justify-content: space-between;
padding: 3px 0;
font-size: 13px;
}
.detail-row .label { color: var(--text-dim); }
.detail-row .value { font-weight: 500; }
.detail-orders { width: 100%; font-size: 12px; }
.detail-orders th { background: var(--bg); font-size: 10px; }
.detail-orders td { padding: 4px 6px; }
+698
View File
@@ -0,0 +1,698 @@
import { useState, useEffect, useRef, useCallback } from 'react'
const EXCHANGES = ['HyperLiquid', 'Bitget']
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 pnlClass(val) {
if (val == null) return ''
return val > 0 ? 'text-green' : val < 0 ? 'text-red' : ''
}
export default function App() {
const [clock, setClock] = useState('--:--:--')
const [connStatus, setConnStatus] = useState('● 未连接')
const [connOnline, setConnOnline] = useState(false)
const [connDetail, setConnDetail] = useState('')
const [prices, setPrices] = useState([])
const [pricesAge, setPricesAge] = useState('')
const [opps, setOpps] = useState([])
const [positions, setPositions] = useState([])
const [blacklist, setBlacklist] = useState([])
const [stats, setStats] = useState({})
const [trades, setTrades] = useState([])
const priceCacheRef = useRef({})
// Clock
useEffect(() => {
const tick = () => setClock(new Date().toLocaleTimeString('zh-CN', { hour12: false }))
tick()
const id = setInterval(tick, 1000)
return () => clearInterval(id)
}, [])
// SSE
useEffect(() => {
let es = new EventSource('/events')
es.addEventListener('connected', () => {
setConnStatus('● 已连接')
setConnOnline(true)
})
es.onerror = () => {
setConnStatus('● 已断开 (重连中...)')
setConnOnline(false)
setTimeout(() => {
es = new EventSource('/events')
}, 3000)
}
es.onmessage = (e) => {
try {
const msg = JSON.parse(e.data)
switch (msg.event) {
case 'prices':
handlePrices(msg.data)
break
case 'arb':
setOpps(msg.data || [])
break
case 'positions':
setPositions(msg.data || [])
break
case 'blacklist':
setBlacklist(msg.data || [])
break
case 'stats':
setStats(msg.data || {})
if (msg.data && msg.data.blacklist) {
setBlacklist(msg.data.blacklist)
}
break
case 'trade_close':
loadTrades()
break
}
} catch (err) {
// ignore
}
}
return () => es.close()
}, [])
// Load trades from API
const loadTrades = useCallback(async () => {
try {
const resp = await fetch('/api/trades')
const data = await resp.json()
setTrades(data.trades || [])
} catch (err) {
// ignore
}
}, [])
useEffect(() => {
loadTrades()
const id = setInterval(loadTrades, 10000)
return () => clearInterval(id)
}, [loadTrades])
// Handle prices
function handlePrices(data) {
if (!data || data.length === 0) return
setPrices(data)
setPricesAge(new Date().toLocaleTimeString('zh-CN', { hour12: false }))
// Update price cache for color changes
const cache = priceCacheRef.current
for (const row of data) {
for (const ex of EXCHANGES) {
const key = row.coin + '.' + ex
const p = row[ex] || 0
if (cache[key]) {
cache[key].last = p
} else {
cache[key] = { last: p }
}
}
}
}
// ---- Render helpers ----
function getCoinList() {
const seen = new Set()
const coins = []
if (!prices) return coins
for (const row of prices) {
if (!seen.has(row.coin)) {
seen.add(row.coin)
coins.push(row.coin)
}
}
return coins
}
function getPrevPrice(coin, ex) {
return priceCacheRef.current[coin + '.' + ex]?.last
}
function priceClass(last, cur) {
if (last == null || cur == null) return ''
return cur > last ? 'text-green' : cur < last ? 'text-red' : ''
}
const coins = getCoinList()
return (
<div id="app">
<header>
<h1> 跨交易所套利监控</h1>
<div className="header-meta">
<span>{clock}</span>
<span className="sep">|</span>
<span className={connOnline ? 'status-online' : 'status-offline'}>{connStatus}</span>
</div>
</header>
<div className="grid">
{/* Stats Summary */}
<StatsCard stats={stats} />
{/* Open Positions */}
<PositionsCard positions={positions} />
{/* PnL Growth Chart */}
<PnlChart />
{/* Price Table */}
<PriceTable coins={coins} prices={prices} getPrevPrice={getPrevPrice} priceClass={priceClass} pricesAge={pricesAge} />
{/* Arbitrage Opportunities */}
<ArbTable opps={opps} />
{/* Recent Trades */}
<TradesCard trades={trades} onRefresh={loadTrades} />
{/* Blacklist */}
<BlacklistCard blacklist={blacklist} />
</div>
</div>
)
}
// ============ Components ============
function StatsCard({ stats }) {
const d = stats.detail
const capital = stats.capital
// Format connection status
let connHtml = ''
if (stats.connections) {
connHtml = Object.entries(stats.connections)
.map(([ex, status]) => `${ex}:${status}`).join(' ')
}
return (
<section className="card" id="stats-card">
<h2>📊 统计数据</h2>
<div className="stats-row">
<div className="stat"><label>总交易</label><span id="stat-total">{stats.total_trades || 0}</span></div>
<div className="stat"><label>收敛</label><span className="pct-green">{stats.converged || 0}</span></div>
<div className="stat"><label>发散</label><span className="pct-red">{stats.diverged || 0}</span></div>
<div className="stat"><label>持平</label><span className="pct-gray">{stats.flat || 0}</span></div>
<div className="stat"><label>持仓</label><span className="pct-yellow">{stats.open_positions || 0} / <span>5</span></span></div>
<div className="stat"><label>币种</label><span className="pct-blue">{stats.coins || 0}</span></div>
<div className="stat" id="conn-stats"><label>连接</label><span id="conn-detail" style={{fontSize:11}}>{connHtml}</span></div>
</div>
{d && (
<div className="stats-row detail-stats" style={{ marginTop: 4, fontSize: 12, opacity: 0.85 }}>
<div className="stat"><label>总PnL</label><span>{(d.total_pnl_usd != null ? '$' + d.total_pnl_usd.toFixed(2) : '—') + (d.capital_pnl != null ? ' (' + d.capital_pnl.toFixed(4) + '%)' : '')}</span></div>
<div className="stat"><label>本金</label><span>{capital != null ? '$' + capital.toFixed(0) : '—'}</span></div>
<div className="stat"><label>胜率</label><span>{d.win_rate != null ? d.win_rate.toFixed(1) + '%' : '—'}</span></div>
<div className="stat"><label>最多盈利</label><span className="text-green">{d.max_profit != null ? d.max_profit.toFixed(4) + '%' : '—'}</span></div>
<div className="stat"><label>最多亏损</label><span className="text-red">{d.max_loss != null ? d.max_loss.toFixed(4) + '%' : '—'}</span></div>
<div className="stat"><label>平均持仓</label><span>{d.avg_dur || '—'}</span></div>
</div>
)}
</section>
)
}
function PositionsCard({ positions }) {
return (
<section className="card" id="positions-card">
<h2>🔒 当前持仓</h2>
<div className="table-wrap">
<table id="positions-table">
<thead>
<tr><th>币种</th><th>方向</th><th>规模</th><th>入价差</th><th>现价差</th><th>估盈亏</th><th>加仓</th><th>时长</th></tr>
</thead>
<tbody id="positions-body">
{positions.length === 0 ? (
<tr><td colSpan="8" className="loading">无持仓</td></tr>
) : (
[...positions].sort((a, b) => a.coin.localeCompare(b.coin)).map(p => (
<tr key={p.coin}>
<td><strong>{p.coin}</strong></td>
<td>{p.direction}</td>
<td className="text-right">${(p.amount_usd || 0).toFixed(0)}</td>
<td className="text-right">{(p.entry_spread || 0).toFixed(4)}%</td>
<td className="text-right">{p.current_spread != null ? p.current_spread.toFixed(4) + '%' : '-'}</td>
<td className={'text-right ' + pnlClass(p.pnl_est)}><strong>{p.pnl_est != null ? p.pnl_est.toFixed(4) + '%' : '-'}</strong></td>
<td className="text-right">{p.scales || 0}</td>
<td>{p.duration || '-'}</td>
</tr>
))
)}
</tbody>
</table>
</div>
</section>
)
}
function BlacklistCard({ blacklist }) {
return (
<section className="card" id="bl-card">
<h2> 黑名单</h2>
<div className="stats-row" id="bl-body">
{!blacklist || blacklist.length === 0 ? (
<span className="text-dim">暂无</span>
) : (
blacklist.map((item, i) => {
const sec = item.remaining_sec || 0
const remaining = sec > 0 ? `${Math.floor(sec/60)}m${sec%60}s` : ''
return <span key={i} className="bl-item" title={`${item.coin}: ${remaining}`}> {item.coin}{remaining ? ` (${remaining})` : ''}</span>
})
)}
</div>
</section>
)
}
function PriceTable({ coins, prices, getPrevPrice, priceClass, pricesAge }) {
return (
<section className="card" id="prices-card">
<h2>💰 实时价格 <span className="text-dim" style={{ fontSize: 11 }}>{pricesAge}</span></h2>
<div className="table-wrap">
<table id="price-table">
<thead>
<tr><th>币种</th><th>HyperLiquid</th><th>Bitget</th><th>BGHL价差</th></tr>
</thead>
<tbody id="price-body">
{coins.length === 0 ? (
<tr><td colSpan="4" className="loading">等待数据...</td></tr>
) : coins.map(coin => {
const row = prices.find(p => p.coin === coin)
if (!row) {
return <tr key={coin}><td>{coin}</td><td className="text-dim">-</td><td className="text-dim">-</td><td className="text-dim">-</td></tr>
}
const cells = EXCHANGES.map(ex => {
const p = row[ex]
const prev = getPrevPrice(coin, ex)
const cls = prev ? priceClass(prev, p || 0) : ''
return <td key={ex} className={cls}>{formatPrice(p)}</td>
})
const spread = row['bg_hl_spread']
const spreadCls = spread > 0.2 ? 'text-green' : spread < -0.2 ? 'text-red' : ''
return (
<tr key={coin}>
<td><strong>{coin}</strong></td>
{cells}
<td className={spreadCls}>{spread != null ? spread.toFixed(4) + '%' : '-'}</td>
</tr>
)
})}
</tbody>
</table>
</div>
</section>
)
}
function ArbTable({ opps }) {
return (
<section className="card" id="arb-card">
<h2>🎯 套利机会 (BGHL)</h2>
<div className="table-wrap">
<table id="arb-table">
<thead>
<tr><th>币种</th><th>方向</th><th>买价</th><th>卖价</th><th>净利%</th></tr>
</thead>
<tbody id="arb-body">
{!opps || opps.length === 0 ? (
<tr><td colSpan="5" className="text-dim">暂无套利机会</td></tr>
) : opps.slice(0, 10).map((opp, i) => {
const cls = opp.net_profit > 0.10 ? 'text-green' : opp.net_profit > 0.05 ? 'text-yellow' : ''
return (
<tr key={i}>
<td>{opp.coin}</td>
<td>{opp.direction}</td>
<td className="text-right">{formatPrice(opp.buy_price)}</td>
<td className="text-right">{formatPrice(opp.sell_price)}</td>
<td className={'text-right ' + cls}><strong>{(opp.net_profit || 0).toFixed(4)}</strong></td>
</tr>
)
})}
</tbody>
</table>
</div>
</section>
)
}
function TradesCard({ trades, onRefresh }) {
const [modalTrade, setModalTrade] = useState(null)
const [modalOrders, setModalOrders] = useState([])
const [modalOpen, setModalOpen] = useState(false)
function openTradeDetail(id) {
setModalOpen(true)
setModalTrade(null)
setModalOrders([])
fetch('/api/trade/' + id)
.then(r => r.json())
.then(data => {
setModalTrade(data.trade)
setModalOrders(data.orders || [])
})
.catch(() => {
setModalTrade({ ID: id })
})
}
function closeTradeDetail() {
setModalOpen(false)
}
// Close on Escape
useEffect(() => {
if (!modalOpen) return
function handler(e) {
if (e.key === 'Escape') closeTradeDetail()
}
document.addEventListener('keydown', handler)
return () => document.removeEventListener('keydown', handler)
}, [modalOpen])
return (
<>
<section className="card card-wide" id="trades-card">
<h2>📋 历史交易</h2>
<div className="table-wrap">
<table id="trades-table">
<thead>
<tr><th>时间</th><th>币种</th><th>方向</th><th>入价差</th><th>出价差</th><th>净利%</th><th>结果</th><th>原因</th></tr>
</thead>
<tbody id="trades-body">
{trades.length === 0 ? (
<tr><td colSpan="8" className="text-dim">暂无交易记录</td></tr>
) : 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 (
<tr key={t.ID} className="trade-row" onClick={() => openTradeDetail(t.ID)}>
<td className="text-dim">{t.ClosedAt ? new Date(t.ClosedAt).toLocaleTimeString('zh-CN', { hour12: false }) : '-'}</td>
<td><strong>{t.Coin}</strong></td>
<td>{t.Direction}</td>
<td className="text-right">{t.EntrySpread != null ? t.EntrySpread.toFixed(4) : '-'}</td>
<td className="text-right">{t.ExitSpread != null ? t.ExitSpread.toFixed(4) : '-'}</td>
<td className={'text-right ' + pnlCls}><strong>{t.NetPnl != null ? t.NetPnl.toFixed(4) + '%' : '-'}</strong></td>
<td className={convCls}>{t.Convergence || '-'}</td>
<td>{t.ExitReason || '-'}</td>
</tr>
)
})}
</tbody>
</table>
</div>
</section>
{/* Trade Detail Modal */}
{modalOpen && (
<TradeDetailModal trade={modalTrade} orders={modalOrders} onClose={closeTradeDetail} />
)}
</>
)
}
function TradeDetailModal({ trade, orders, onClose }) {
function handleOverlayClick(e) {
if (e.target === e.currentTarget) onClose()
}
if (!trade) {
return (
<div className="modal-overlay" onClick={handleOverlayClick}>
<div className="modal-content">
<div className="modal-header">
<h2>📋 交易详情</h2>
<button className="modal-close" onClick={onClose}></button>
</div>
<div id="trade-detail-body">
<div className="loading">加载中...</div>
</div>
</div>
</div>
)
}
const opened = new Date(trade.OpenedAt)
const closed = trade.ClosedAt ? new Date(trade.ClosedAt) : null
const dur = closed ? Math.round((closed - opened) / 1000) + 's' : '-'
const pnlCls = trade.NetPnl > 0 ? 'text-green' : trade.NetPnl < 0 ? 'text-red' : ''
return (
<div className="modal-overlay" onClick={handleOverlayClick}>
<div className="modal-content">
<div className="modal-header">
<h2>📋 交易详情</h2>
<button className="modal-close" onClick={onClose}></button>
</div>
<div id="trade-detail-body">
<div className="detail-grid">
<div className="detail-section">
<h3>概览</h3>
<div className="detail-row"><span className="label">币种</span><span className="value"><strong>{trade.Coin}</strong>/USDT</span></div>
<div className="detail-row"><span className="label">方向</span><span className="value">{trade.Direction || '-'}</span></div>
<div className="detail-row"><span className="label">状态</span><span className="value">{trade.Status === 'closed' ? '已平仓' : trade.Status}</span></div>
<div className="detail-row"><span className="label">加仓次数</span><span className="value">{trade.ScaleCount || 0} </span></div>
<div className="detail-row"><span className="label">总规模</span><span className="value">${(trade.AmountUSD || 0).toFixed(0)}</span></div>
</div>
<div className="detail-section">
<h3>时间</h3>
<div className="detail-row"><span className="label">开仓</span><span className="value">{opened.toLocaleString('zh-CN', { hour12: false })}</span></div>
<div className="detail-row"><span className="label">平仓</span><span className="value">{closed ? closed.toLocaleString('zh-CN', { hour12: false }) : '-'}</span></div>
<div className="detail-row"><span className="label">持仓时长</span><span className="value">{dur}</span></div>
</div>
<div className="detail-section">
<h3>价差</h3>
<div className="detail-row"><span className="label">入场价差</span><span className="value">{trade.EntrySpread != null ? trade.EntrySpread.toFixed(4) + '%' : '-'}</span></div>
<div className="detail-row"><span className="label">出场价差</span><span className="value">{trade.ExitSpread != null ? trade.ExitSpread.toFixed(4) + '%' : '-'}</span></div>
<div className="detail-row"><span className="label">收敛情况</span><span className={'value ' + (trade.Convergence === '价差收敛' ? 'text-green' : trade.Convergence === '价差发散' ? 'text-red' : '')}>{trade.Convergence || '-'}</span></div>
<div className="detail-row"><span className="label">平仓原因</span><span className="value">{trade.ExitReason || '-'}</span></div>
</div>
<div className="detail-section">
<h3>手续费</h3>
<div className="detail-row"><span className="label">开仓费</span><span className="value">${(trade.FeeEntry || 0).toFixed(4)}</span></div>
<div className="detail-row"><span className="label">平仓费</span><span className="value">${(trade.FeeExit || 0).toFixed(4)}</span></div>
<div className="detail-row"><span className="label">总手续费</span><span className="value">${((trade.FeeEntry || 0) + (trade.FeeExit || 0)).toFixed(4)}</span></div>
</div>
<div className="detail-section">
<h3>多仓 {trade.LongExchange || '-'}</h3>
<div className="detail-row"><span className="label">入场价</span><span className="value">${(trade.LongEntry || 0).toFixed(6)}</span></div>
<div className="detail-row"><span className="label">出场价</span><span className="value">${(trade.LongExit || 0).toFixed(6)}</span></div>
<div className="detail-row"><span className="label">盈亏</span><span className={'value ' + (trade.LongPnl > 0 ? 'text-green' : trade.LongPnl < 0 ? 'text-red' : '')}>{trade.LongPnl != null ? trade.LongPnl.toFixed(4) + '%' : '-'}</span></div>
</div>
<div className="detail-section">
<h3>空仓 {trade.ShortExchange || '-'}</h3>
<div className="detail-row"><span className="label">入场价</span><span className="value">${(trade.ShortEntry || 0).toFixed(6)}</span></div>
<div className="detail-row"><span className="label">出场价</span><span className="value">${(trade.ShortExit || 0).toFixed(6)}</span></div>
<div className="detail-row"><span className="label">盈亏</span><span className={'value ' + (trade.ShortPnl > 0 ? 'text-green' : trade.ShortPnl < 0 ? 'text-red' : '')}>{trade.ShortPnl != null ? trade.ShortPnl.toFixed(4) + '%' : '-'}</span></div>
</div>
<div className="detail-section detail-section-full">
<h3>净收益</h3>
<div className="detail-row" style={{ fontSize: 16 }}>
<span className="label">总计</span>
<span className={'value ' + pnlCls} style={{ fontWeight: 700 }}>{trade.NetPnl != null ? trade.NetPnl.toFixed(4) + '%' : '-'}</span>
</div>
</div>
</div>
{orders.length > 0 && (
<div className="detail-section detail-section-full" style={{ borderTop: '1px solid var(--border)' }}>
<h3>订单明细 ({orders.length})</h3>
<table className="detail-orders">
<thead>
<tr><th>类型</th><th>方向</th><th>交易所</th><th>价格</th><th>数量</th><th>手续费</th><th>订单ID</th></tr>
</thead>
<tbody>
{orders.map((o, i) => (
<tr key={i}>
<td>{o.Type === 'entry' ? '开仓' : o.Type === 'exit' ? '平仓' : o.Type === 'scale' ? '加仓' : o.Type}</td>
<td>{o.Side === 'buy' ? '买' : '卖'}</td>
<td>{o.Exchange}</td>
<td>${(o.Price || 0).toFixed(6)}</td>
<td>{o.Size || '-'}</td>
<td>{o.Fee != null ? '$' + (o.Fee).toFixed(4) : '-'}</td>
<td>{o.OrderID ? o.OrderID.substring(0, 12) + '...' : '-'}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
</div>
)
}
// ============ PnL Growth Chart ============
function PnlChart() {
const canvasRef = useRef(null)
const [data, setData] = useState([])
const [totalPnl, setTotalPnl] = useState(0)
// Fetch trades for chart
useEffect(() => {
async function fetchTrades() {
try {
const resp = await fetch('/api/trades?limit=1000')
const json = await resp.json()
const trades = (json.trades || [])
.filter(t => t.ClosedAt && t.NetPnl != null)
.sort((a, b) => new Date(a.ClosedAt) - new Date(b.ClosedAt))
setData(trades)
const total = trades.reduce((sum, t) => sum + (t.NetPnl || 0), 0)
setTotalPnl(total)
} catch (e) {}
}
fetchTrades()
const id = setInterval(fetchTrades, 10000)
return () => clearInterval(id)
}, [])
// Draw chart
useEffect(() => {
const canvas = canvasRef.current
if (!canvas || data.length < 2) return
const rect = canvas.parentElement.getBoundingClientRect()
const dpr = window.devicePixelRatio || 1
const W = rect.width
const H = rect.height
canvas.width = W * dpr
canvas.height = H * dpr
canvas.style.width = W + 'px'
canvas.style.height = H + 'px'
const ctx = canvas.getContext('2d')
ctx.scale(dpr, dpr)
const pad = { top: 20, right: 20, bottom: 35, left: 55 }
const plotW = W - pad.left - pad.right
const plotH = H - pad.top - pad.bottom
// Compute cumulative PnL
const points = []
let cum = 0
for (const t of data) {
cum += (t.AmountUSD || 0) * (t.NetPnl || 0) / 100
points.push({ x: new Date(t.ClosedAt).getTime(), y: cum })
}
const minT = points[0].x
const maxT = points[points.length - 1].x
const yVals = points.map(p => p.y)
const minY = Math.min(0, ...yVals)
const maxY = Math.max(0, ...yVals)
const yRange = Math.max(maxY - minY, 0.01)
const yPad = yRange * 0.15
const toX = t => pad.left + (t - minT) / Math.max(maxT - minT, 1) * plotW
const toY = y => pad.top + plotH - (y - (minY - yPad)) / (yRange + 2 * yPad) * plotH
// Clear
ctx.clearRect(0, 0, W, H)
// Grid lines
ctx.strokeStyle = 'rgba(48,54,61,0.5)'
ctx.lineWidth = 1
ctx.font = '11px sans-serif'
ctx.fillStyle = '#8b949e'
const ySteps = 5
for (let i = 0; i <= ySteps; i++) {
const yVal = (minY - yPad) + (yRange + 2 * yPad) * i / ySteps
const yPos = toY(yVal)
ctx.beginPath()
ctx.moveTo(pad.left, yPos)
ctx.lineTo(W - pad.right, yPos)
ctx.stroke()
ctx.fillText('$' + yVal.toFixed(2), 2, yPos + 4)
}
// Zero line
if (minY < 0 && maxY > 0) {
const y0 = toY(0)
ctx.strokeStyle = 'rgba(248,81,73,0.3)'
ctx.lineWidth = 1
ctx.setLineDash([4, 4])
ctx.beginPath()
ctx.moveTo(pad.left, y0)
ctx.lineTo(W - pad.right, y0)
ctx.stroke()
ctx.setLineDash([])
}
// X axis labels
const xSteps = Math.min(6, points.length)
for (let i = 0; i < xSteps; i++) {
const idx = Math.floor(i * (points.length - 1) / (xSteps - 1))
const xPos = toX(points[idx].x)
const date = new Date(points[idx].x)
ctx.fillStyle = '#8b949e'
ctx.textAlign = 'center'
ctx.fillText(date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }), xPos, H - 5)
}
// Line
ctx.beginPath()
ctx.strokeStyle = '#58a6ff'
ctx.lineWidth = 2
for (let i = 0; i < points.length; i++) {
const x = toX(points[i].x)
const y = toY(points[i].y)
if (i === 0) ctx.moveTo(x, y)
else ctx.lineTo(x, y)
}
ctx.stroke()
// Fill gradient
const gradient = ctx.createLinearGradient(0, pad.top, 0, H - pad.bottom)
gradient.addColorStop(0, 'rgba(88,166,255,0.15)')
gradient.addColorStop(1, 'rgba(88,166,255,0.01)')
ctx.lineTo(toX(points[points.length - 1].x), toY(minY - yPad))
ctx.lineTo(toX(points[0].x), toY(minY - yPad))
ctx.closePath()
ctx.fillStyle = gradient
ctx.fill()
// Latest value dot
const last = points[points.length - 1]
const lx = toX(last.x)
const ly = toY(last.y)
ctx.beginPath()
ctx.arc(lx, ly, 4, 0, Math.PI * 2)
ctx.fillStyle = last.y >= 0 ? '#3fb950' : '#f85149'
ctx.fill()
ctx.strokeStyle = '#0d1117'
ctx.lineWidth = 2
ctx.stroke()
// Latest value label
ctx.fillStyle = '#c9d1d9'
ctx.font = 'bold 13px sans-serif'
ctx.textAlign = 'center'
ctx.fillText('$' + last.y.toFixed(2), lx, ly - 12)
}, [data])
return (
<section className="card card-wide" id="pnl-chart-card">
<h2>📈 总PnL成长曲线 <span className="text-dim" style={{fontSize:11}}>{data.length > 0 ? `$${totalPnl.toFixed(2)}` : ''}</span></h2>
<div className="chart-container" style={{height:260}}>
{data.length < 2 ? (
<div className="loading" style={{paddingTop:100}}>暂无数据...</div>
) : (
<canvas ref={canvasRef} />
)}
</div>
</section>
)
}
+10
View File
@@ -0,0 +1,10 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import './App.css'
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)
+17
View File
@@ -0,0 +1,17 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
base: '/static/',
server: {
port: 5173,
proxy: {
'/api': { target: 'http://localhost:8888', changeOrigin: true },
'/events': { target: 'http://localhost:8888', changeOrigin: true },
},
},
build: {
outDir: 'dist',
},
})
+2 -2
View File
@@ -23,8 +23,8 @@ for arg in "$@"; do
esac esac
done done
# 清理旧进程(通过进程名而非端口,更可靠 # 清理旧进程(pkill 按进程名匹配,不会匹配到 start.sh 自身
OLD_PIDS=$(pgrep -f exchange-monitor 2>/dev/null || true) OLD_PIDS=$(pgrep exchange-monitor 2>/dev/null || true)
if [ -n "$OLD_PIDS" ]; then if [ -n "$OLD_PIDS" ]; then
echo "[start] 停止旧进程 PID=$OLD_PIDS..." echo "[start] 停止旧进程 PID=$OLD_PIDS..."
kill $OLD_PIDS 2>/dev/null || true kill $OLD_PIDS 2>/dev/null || true
+4 -2
View File
@@ -1,6 +1,8 @@
package main package main
import "embed" import (
"embed"
)
//go:embed web/static/index.html web/static/app.js web/static/style.css //go:embed frontend/dist
var staticFS embed.FS var staticFS embed.FS
+165 -8
View File
@@ -520,7 +520,7 @@ func (t *Trader) checkExit(pos *ArbPosition, bgP, hlP, diffPct float64, notifier
longPnl := (longCurrent - longAvg) / longAvg * 100 longPnl := (longCurrent - longAvg) / longAvg * 100
shortPnl := (shortAvg - shortCurrent) / shortAvg * 100 shortPnl := (shortAvg - shortCurrent) / shortAvg * 100
totalFees := 2 * (takerFees[ExBitget] + takerFees[ExHyperLiquid]) // 开仓 + 平仓手续费 totalFees := float64(2+pos.ScaleLevels) * (takerFees[ExBitget] + takerFees[ExHyperLiquid]) // 开仓(含加仓) + 平仓手续费
netPnl := longPnl + shortPnl - totalFees netPnl := longPnl + shortPnl - totalFees
elapsed := time.Since(pos.StartedAt) elapsed := time.Since(pos.StartedAt)
@@ -905,15 +905,35 @@ func (t *Trader) GetClosedTrades() []TradeRecord {
return r return r
} }
// persistTrade saves a completed trade to SQLite. // persistTrade saves a completed trade to SQLite, with per-leg orders and system_orders.
func (t *Trader) persistTrade(pos *ArbPosition, exitSpread float64, convergence, exitReason string, netPnl, longPnl, shortPnl, totalFees float64) { func (t *Trader) persistTrade(pos *ArbPosition, exitSpread float64, convergence, exitReason string, netPnl, longPnl, shortPnl, totalFees float64) {
var entrySpread, fe float64 var entrySpread float64
if pos.LongLeg != nil { if pos.LongLeg != nil {
entrySpread = pos.EntrySpread entrySpread = pos.EntrySpread
} }
fe = totalFees / 2 // split into entry/exit halves
now := time.Now() now := time.Now()
tradeUnit := t.cfg.TradeAmountUSD
// Pre-calculate all fees BEFORE saving the trade
totalFeeEntryUSD := 0.0
for range pos.LongEntryPrices {
totalFeeEntryUSD += tradeUnit * takerFees[pos.LongLeg.Exchange] / 100
}
for range pos.ShortEntryPrices {
totalFeeEntryUSD += tradeUnit * takerFees[pos.ShortLeg.Exchange] / 100
}
totalLongShares := 0.0
for _, p := range pos.LongEntryPrices {
totalLongShares += tradeUnit / p
}
totalShortShares := 0.0
for _, p := range pos.ShortEntryPrices {
totalShortShares += tradeUnit / p
}
totalFeeExitUSD := totalLongShares*pos.LongLeg.ExitPrice*takerFees[pos.LongLeg.Exchange]/100 +
totalShortShares*pos.ShortLeg.ExitPrice*takerFees[pos.ShortLeg.Exchange]/100
dbTrade := &db.TradeRecord{ dbTrade := &db.TradeRecord{
Coin: pos.Coin, Coin: pos.Coin,
Direction: pos.Direction, Direction: pos.Direction,
@@ -928,8 +948,8 @@ func (t *Trader) persistTrade(pos *ArbPosition, exitSpread float64, convergence,
ShortExit: &pos.ShortLeg.ExitPrice, ShortExit: &pos.ShortLeg.ExitPrice,
LongPnl: &longPnl, LongPnl: &longPnl,
ShortPnl: &shortPnl, ShortPnl: &shortPnl,
FeeEntry: &fe, FeeEntry: &totalFeeEntryUSD,
FeeExit: &fe, FeeExit: &totalFeeExitUSD,
NetPnl: &netPnl, NetPnl: &netPnl,
AmountUSD: pos.AmountUSD, AmountUSD: pos.AmountUSD,
ScaleCount: pos.ScaleLevels, ScaleCount: pos.ScaleLevels,
@@ -938,8 +958,145 @@ func (t *Trader) persistTrade(pos *ArbPosition, exitSpread float64, convergence,
OpenedAt: pos.StartedAt, OpenedAt: pos.StartedAt,
ClosedAt: &now, ClosedAt: &now,
} }
if _, err := t.db.SaveTrade(dbTrade); err != nil { tradeID, err := t.db.SaveTrade(dbTrade)
if err != nil {
log.Printf("[Trader] Failed to save trade to DB: %v", err) log.Printf("[Trader] Failed to save trade to DB: %v", err)
return
}
// Save per-leg order records
// Long leg: entry (buy), scales (buy), exit (sell)
status := "filled"
var longEntryOrderIDs []int64
for i, p := range pos.LongEntryPrices {
shares := tradeUnit / p
orderType := "entry"
if i > 0 {
orderType = "scale"
}
fee := tradeUnit * takerFees[pos.LongLeg.Exchange] / 100
oid, oErr := t.db.SaveOrder(&db.OrderRecord{
TradeID: tradeID,
Leg: "long",
Type: orderType,
Exchange: pos.LongLeg.Exchange,
Side: "buy",
Price: &p,
Size: &shares,
Fee: &fee,
Status: &status,
CreatedAt: pos.StartedAt,
})
if oErr != nil {
log.Printf("[Trader] Failed to save long entry order: %v", oErr)
} else {
longEntryOrderIDs = append(longEntryOrderIDs, oid)
}
}
// Long exit (sell)
longExitShares := totalLongShares
longExitFee := totalLongShares * pos.LongLeg.ExitPrice * takerFees[pos.LongLeg.Exchange] / 100
status = "filled"
var longExitOrderID int64
if oid, oErr := t.db.SaveOrder(&db.OrderRecord{
TradeID: tradeID,
Leg: "long",
Type: "exit",
Exchange: pos.LongLeg.Exchange,
Side: "sell",
Price: &pos.LongLeg.ExitPrice,
Size: &longExitShares,
Fee: &longExitFee,
Status: &status,
CreatedAt: now,
}); oErr != nil {
log.Printf("[Trader] Failed to save long exit order: %v", oErr)
} else {
longExitOrderID = oid
}
// Short leg: entry (sell), scales (sell), exit (buy)
var shortEntryOrderIDs []int64
for i, p := range pos.ShortEntryPrices {
shares := tradeUnit / p
orderType := "entry"
if i > 0 {
orderType = "scale"
}
fee := tradeUnit * takerFees[pos.ShortLeg.Exchange] / 100
oid, oErr := t.db.SaveOrder(&db.OrderRecord{
TradeID: tradeID,
Leg: "short",
Type: orderType,
Exchange: pos.ShortLeg.Exchange,
Side: "sell",
Price: &p,
Size: &shares,
Fee: &fee,
Status: &status,
CreatedAt: pos.StartedAt,
})
if oErr != nil {
log.Printf("[Trader] Failed to save short entry order: %v", oErr)
} else {
shortEntryOrderIDs = append(shortEntryOrderIDs, oid)
}
}
// Short exit (buy)
shortExitShares := totalShortShares
shortExitFee := totalShortShares * pos.ShortLeg.ExitPrice * takerFees[pos.ShortLeg.Exchange] / 100
var shortExitOrderID int64
if oid, oErr := t.db.SaveOrder(&db.OrderRecord{
TradeID: tradeID,
Leg: "short",
Type: "exit",
Exchange: pos.ShortLeg.Exchange,
Side: "buy",
Price: &pos.ShortLeg.ExitPrice,
Size: &shortExitShares,
Fee: &shortExitFee,
Status: &status,
CreatedAt: now,
}); oErr != nil {
log.Printf("[Trader] Failed to save short exit order: %v", oErr)
} else {
shortExitOrderID = oid
}
// Save system orders linking long+short legs
es := pos.EntrySpread
for i := 0; i < len(longEntryOrderIDs) && i < len(shortEntryOrderIDs); i++ {
sysType := "entry"
if i > 0 {
sysType = "scale"
}
if _, sErr := t.db.SaveSystemOrder(&db.SystemOrderRecord{
TradeID: tradeID,
Type: sysType,
Status: "filled",
Spread: &es,
LongPrice: &pos.LongEntryPrices[i],
ShortPrice: &pos.ShortEntryPrices[i],
LongOrderID: &longEntryOrderIDs[i],
ShortOrderID: &shortEntryOrderIDs[i],
CreatedAt: pos.StartedAt,
}); sErr != nil {
log.Printf("[Trader] Failed to save entry system order: %v", sErr)
}
}
// Exit system order
if _, sErr := t.db.SaveSystemOrder(&db.SystemOrderRecord{
TradeID: tradeID,
Type: "exit",
Status: "filled",
Spread: &exitSpread,
LongPrice: &pos.LongLeg.ExitPrice,
ShortPrice: &pos.ShortLeg.ExitPrice,
LongOrderID: &longExitOrderID,
ShortOrderID: &shortExitOrderID,
CreatedAt: now,
}); sErr != nil {
log.Printf("[Trader] Failed to save exit system order: %v", sErr)
} }
} }
@@ -1004,7 +1161,7 @@ func (t *Trader) blacklistCoin(pos *ArbPosition, bgP, hlP, diffPct float64, noti
shortAvg := weightedAvgPrice(pos.ShortEntryPrices, t.cfg.TradeAmountUSD) shortAvg := weightedAvgPrice(pos.ShortEntryPrices, t.cfg.TradeAmountUSD)
longPnl := (longCurrent - longAvg) / longAvg * 100 longPnl := (longCurrent - longAvg) / longAvg * 100
shortPnl := (shortAvg - shortCurrent) / shortAvg * 100 shortPnl := (shortAvg - shortCurrent) / shortAvg * 100
totalFees := 2 * (takerFees[ExBitget] + takerFees[ExHyperLiquid]) totalFees := float64(2+pos.ScaleLevels) * (takerFees[ExBitget] + takerFees[ExHyperLiquid])
netPnl := longPnl + shortPnl - totalFees netPnl := longPnl + shortPnl - totalFees
pos.ExitDiffPct = diffPct pos.ExitDiffPct = diffPct
+2 -240
View File
@@ -23,11 +23,6 @@ const els = {
statFlat: $('stat-flat'), statFlat: $('stat-flat'),
statPos: $('stat-positions'), statPos: $('stat-positions'),
statCoins: $('stat-coins'), statCoins: $('stat-coins'),
chartCoin: $('chart-coin'),
chartExch: $('chart-exchange'),
chartCanvas: $('priceChart'),
spreadCoin: $('spread-coin'),
spreadCanvas: $('spreadChart'),
}; };
// ---- Clock ---- // ---- Clock ----
@@ -58,7 +53,6 @@ function pnlClass(val) {
return val > 0 ? 'text-green' : val < 0 ? 'text-red' : ''; return val > 0 ? 'text-green' : val < 0 ? 'text-red' : '';
} }
// ---- Price cache for chart data ----
const priceCache = {}; const priceCache = {};
// ---- SSE Connection ---- // ---- SSE Connection ----
@@ -125,15 +119,7 @@ eventHandlers.prices = (prices) => {
if (prev) { if (prev) {
prev.last = curP; prev.last = curP;
} else { } else {
priceCache[key] = { last: curP, points: [] }; priceCache[key] = { last: curP };
}
if (p > 0) {
if (!priceCache[key]) priceCache[key] = { last: p, points: [] };
priceCache[key].points.push({ t: Date.now(), p: p });
if (priceCache[key].points.length > 500) {
priceCache[key].points = priceCache[key].points.slice(-500);
}
} }
let display = formatPrice(p); let display = formatPrice(p);
@@ -150,8 +136,6 @@ eventHandlers.prices = (prices) => {
els.priceBody.innerHTML = html; els.priceBody.innerHTML = html;
els.pricesAge.textContent = new Date().toLocaleTimeString('zh-CN', { hour12: false }); els.pricesAge.textContent = new Date().toLocaleTimeString('zh-CN', { hour12: false });
updateChartSelectors(prices);
}; };
eventHandlers.arb = (opps) => { eventHandlers.arb = (opps) => {
@@ -268,226 +252,6 @@ eventHandlers.trade_close = (trade) => {
setTimeout(loadTrades, 500); setTimeout(loadTrades, 500);
}; };
// ---- Price Chart ----
let priceChart = null;
function initPriceChart() {
const ctx = els.chartCanvas.getContext('2d');
priceChart = new Chart(ctx, {
type: 'line',
data: { datasets: [{
label: 'Price',
data: [],
borderColor: '#58a6ff',
backgroundColor: 'rgba(88, 166, 255, 0.1)',
borderWidth: 2,
pointRadius: 0,
fill: true,
tension: 0.2,
}] },
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(4) },
grid: { color: 'rgba(48,54,61,0.3)' },
},
},
},
});
}
// ---- P3-2: Spread Chart ----
let spreadChart = null;
function initSpreadChart() {
const ctx = els.spreadCanvas.getContext('2d');
spreadChart = new Chart(ctx, {
type: 'line',
data: { datasets: [{
label: 'BG↔HL Spread %',
data: [],
borderColor: '#d29922',
backgroundColor: 'rgba(210, 153, 34, 0.1)',
borderWidth: 2,
pointRadius: 0,
fill: true,
tension: 0.2,
}] },
options: {
responsive: true,
maintainAspectRatio: false,
animation: { duration: 0 },
plugins: {
legend: { display: false },
tooltip: {
mode: 'index', intersect: false,
callbacks: {
title: (items) => items.length ? new Date(items[0].parsed.x).toLocaleTimeString('zh-CN', { hour12: false }) : '',
label: (item) => item.parsed.y.toFixed(4) + '%',
},
},
},
scales: {
x: {
type: 'linear',
ticks: {
color: '#8b949e', maxTicksLimit: 10,
callback: (v) => new Date(v).toLocaleTimeString('zh-CN', { hour12: false, hour: '2-digit', minute: '2-digit' }),
},
grid: { color: 'rgba(48,54,61,0.5)' },
},
y: {
ticks: { color: '#8b949e', callback: (v) => v.toFixed(3) + '%' },
grid: { color: 'rgba(48,54,61,0.3)' },
},
},
},
});
}
// ---- Chart Selectors ----
function updateChartSelectors(prices) {
const coinSel = els.chartCoin;
const exSel = els.chartExch;
const spreadSel = els.spreadCoin;
// Price chart coin selector
if (coinSel.options.length <= 1) {
const cur = coinSel.value;
coinSel.innerHTML = '<option value="">-- 选择币种 --</option>';
for (const row of prices) {
const opt = document.createElement('option');
opt.value = row.coin; opt.textContent = row.coin;
coinSel.appendChild(opt);
}
if (cur) coinSel.value = cur;
else if (prices.length > 0) coinSel.value = prices[0].coin;
}
// Price chart exchange selector
if (exSel.options.length <= 1) {
exSel.innerHTML = '<option value="">-- 选择交易所 --</option>';
for (const ex of EXCHANGES) {
const opt = document.createElement('option');
opt.value = ex; opt.textContent = ex;
exSel.appendChild(opt);
}
exSel.value = 'HyperLiquid';
}
// Spread chart coin selector
if (spreadSel.options.length <= 1) {
const cur = spreadSel.value;
spreadSel.innerHTML = '<option value="">-- 选择币种 --</option>';
for (const row of prices) {
const opt = document.createElement('option');
opt.value = row.coin; opt.textContent = row.coin;
spreadSel.appendChild(opt);
}
if (cur) spreadSel.value = cur;
else if (prices.length > 0) spreadSel.value = prices[0].coin;
}
// Update charts on selection change
const selCoin = coinSel.value, selEx = exSel.value;
if (selCoin && selEx) updatePriceChart(selCoin, selEx);
const spCoin = spreadSel.value;
if (spCoin) updateSpreadChart(spCoin);
}
function updatePriceChart(coin, exchange) {
const key = coin + '.' + exchange;
const cache = priceCache[key];
if (!cache || !cache.points || cache.points.length < 2) {
if (priceChart) {
priceChart.data.datasets[0].data = [];
priceChart.data.datasets[0].label = `${coin} @ ${exchange}`;
priceChart.update('none');
}
return;
}
const data = cache.points.map(p => ({ x: p.t, y: p.p }));
if (priceChart) {
priceChart.data.datasets[0].data = data;
priceChart.data.datasets[0].label = `${coin} @ ${exchange}`;
priceChart.update('none');
}
}
async function updateSpreadChart(coin) {
try {
const resp = await fetch(`/api/spread-history?coin=${coin}`);
const data = await resp.json();
const pts = data.points || [];
if (pts.length < 2) {
if (spreadChart) {
spreadChart.data.datasets[0].data = [];
spreadChart.data.datasets[0].label = `${coin} BG↔HL`;
spreadChart.update('none');
}
return;
}
const chartData = pts.map(p => ({ x: p.t, y: p.s }));
if (spreadChart) {
spreadChart.data.datasets[0].data = chartData;
spreadChart.data.datasets[0].label = `${coin} BG↔HL`;
spreadChart.update('none');
}
} catch (err) {
// ignore
}
}
// ---- Chart controls ----
els.chartCoin.addEventListener('change', () => {
const coin = els.chartCoin.value;
const ex = els.chartExch.value;
if (coin && ex) updatePriceChart(coin, ex);
});
els.chartExch.addEventListener('change', () => {
const coin = els.chartCoin.value;
const ex = els.chartExch.value;
if (coin && ex) updatePriceChart(coin, ex);
});
els.spreadCoin.addEventListener('change', () => {
if (els.spreadCoin.value) updateSpreadChart(els.spreadCoin.value);
});
// ---- Auto-refresh charts ----
setInterval(() => {
const coin = els.chartCoin.value;
const ex = els.chartExch.value;
if (coin && ex) updatePriceChart(coin, ex);
}, 2000);
setInterval(() => {
if (els.spreadCoin.value) updateSpreadChart(els.spreadCoin.value);
}, 3000);
// ---- Trades from API ---- // ---- Trades from API ----
async function loadTrades() { async function loadTrades() {
try { try {
@@ -643,10 +407,8 @@ window.closeTradeDetail = closeTradeDetail;
// ---- Init ---- // ---- Init ----
function init() { function init() {
connectSSE(); connectSSE();
loadTrades(); // run before charts in case Chart CDN is slow loadTrades();
setInterval(loadTrades, 10000); setInterval(loadTrades, 10000);
try { initPriceChart(); } catch(e) { console.warn('Price chart init failed:', e); }
try { initSpreadChart(); } catch(e) { console.warn('Spread chart init failed:', e); }
} }
if (document.readyState === 'loading') { if (document.readyState === 'loading') {
+18 -39
View File
@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Exchange Monitor Dashboard</title> <title>Exchange Monitor Dashboard</title>
<link rel="stylesheet" href="/static/style.css"> <link rel="stylesheet" href="/static/style.css">
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.7/dist/chart.umd.min.js"></script>
</head> </head>
<body> <body>
<div id="app"> <div id="app">
@@ -42,6 +42,21 @@
</div> </div>
</section> </section>
<!-- Open Positions -->
<section class="card" id="positions-card">
<h2>🔒 当前持仓</h2>
<div class="table-wrap">
<table id="positions-table">
<thead>
<tr><th>币种</th><th>方向</th><th>规模</th><th>入价差</th><th>现价差</th><th>估盈亏</th><th>加仓</th><th>时长</th></tr>
</thead>
<tbody id="positions-body">
<tr><td colspan="8" class="loading">等待数据...</td></tr>
</tbody>
</table>
</div>
</section>
<!-- Blacklist --> <!-- Blacklist -->
<section class="card" id="bl-card"> <section class="card" id="bl-card">
<h2>⛔ 黑名单</h2> <h2>⛔ 黑名单</h2>
@@ -80,44 +95,6 @@
</div> </div>
</section> </section>
<!-- Open Positions -->
<section class="card" id="positions-card">
<h2>🔒 当前持仓</h2>
<div class="table-wrap">
<table id="positions-table">
<thead>
<tr><th>币种</th><th>方向</th><th>规模</th><th>入价差</th><th>现价差</th><th>估盈亏</th><th>加仓</th><th>时长</th></tr>
</thead>
<tbody id="positions-body">
<tr><td colspan="8" class="loading">等待数据...</td></tr>
</tbody>
</table>
</div>
</section>
<!-- Price Chart -->
<section class="card card-wide" id="chart-card">
<h2>📈 价格走势</h2>
<div class="chart-controls">
<select id="chart-coin"></select>
<select id="chart-exchange"></select>
</div>
<div class="chart-container">
<canvas id="priceChart"></canvas>
</div>
</section>
<!-- Spread Chart (P3-2) -->
<section class="card card-wide" id="spread-chart-card">
<h2>📉 价差走势 (BG↔HL)</h2>
<div class="chart-controls">
<select id="spread-coin"></select>
</div>
<div class="chart-container">
<canvas id="spreadChart"></canvas>
</div>
</section>
<!-- Recent Trades --> <!-- Recent Trades -->
<section class="card card-wide" id="trades-card"> <section class="card card-wide" id="trades-card">
<h2>📋 历史交易</h2> <h2>📋 历史交易</h2>
@@ -132,6 +109,8 @@
</table> </table>
</div> </div>
</section> </section>
</div> </div>
<!-- Trade Detail Modal --> <!-- Trade Detail Modal -->
-20
View File
@@ -135,26 +135,6 @@ tr:hover td { background: rgba(88, 166, 255, 0.05); }
.text-dim { color: var(--text-dim); } .text-dim { color: var(--text-dim); }
.text-right { text-align: right; } .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 */ /* Scrollbar */
::-webkit-scrollbar { width: 6px; height: 6px; } ::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: transparent; } ::-webkit-scrollbar-track { background: transparent; }