feat: 手续费改为逐笔USD累算 + Vite React前端 + system_orders表 + README
This commit is contained in:
@@ -25,3 +25,10 @@ trade_stats.txt
|
|||||||
|
|
||||||
# Temp
|
# Temp
|
||||||
/tmp/
|
/tmp/
|
||||||
|
|
||||||
|
# Python scripts
|
||||||
|
check_db.py
|
||||||
|
|
||||||
|
# Frontend
|
||||||
|
frontend/node_modules/
|
||||||
|
frontend/dist/
|
||||||
|
|||||||
@@ -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 图表
|
||||||
|
|||||||
+34
-11
@@ -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))))
|
||||||
@@ -379,19 +387,21 @@ func (d *Dashboard) broadcastLoop() {
|
|||||||
longCurrent, shortCurrent = hlP, bgP
|
longCurrent, shortCurrent = hlP, bgP
|
||||||
}
|
}
|
||||||
longAvg := weightedAvgPrice(pos.LongEntryPrices, pos.AmountUSD/float64(max(1, len(pos.LongEntryPrices))))
|
longAvg := weightedAvgPrice(pos.LongEntryPrices, pos.AmountUSD/float64(max(1, len(pos.LongEntryPrices))))
|
||||||
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 {
|
||||||
// HL→BG: spread positive when bgP > hlP
|
// HL→BG: spread positive when bgP > hlP
|
||||||
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)
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
+29
-1
@@ -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,
|
||||||
@@ -222,4 +250,4 @@ func (d *DB) GetClosedStats() (converged, diverged, flat, total int, err error)
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -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>
|
||||||
Generated
+1568
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
@@ -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>BG↔HL价差</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>🎯 套利机会 (BG↔HL)</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>,
|
||||||
|
)
|
||||||
@@ -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',
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -1074,4 +1231,4 @@ func (t *Trader) RemoveBlacklist(coin string) {
|
|||||||
defer t.mu.Unlock()
|
defer t.mu.Unlock()
|
||||||
delete(t.blacklist, coin)
|
delete(t.blacklist, coin)
|
||||||
log.Printf("[Trader] ✅ %s: Removed from blacklist", coin)
|
log.Printf("[Trader] ✅ %s: Removed from blacklist", coin)
|
||||||
}
|
}
|
||||||
+2
-240
@@ -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
@@ -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 -->
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
Reference in New Issue
Block a user