feat: 重构为三所价差异动监控系统
删除 HyperLiquid + 全部交易功能,构建自适应 surge 检测器。 - 新增 surge_detector.go: 每币独立滚动窗口基线,检测三所价差异常飙升 - 新增 SpreadCard/SurgeCard 前端组件 - 保留 momentum/trend/cumulative/trend_filter 扫描功能 - 更新文档和配置以反映新系统 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
559d7bb870
commit
d38782490c
+15
-28
@@ -1,39 +1,26 @@
|
||||
# 交易所监控 + 自动套利
|
||||
# 三所价差异动监控
|
||||
# 复制为 .env 并填入实际值
|
||||
|
||||
# Telegram 推送
|
||||
# Telegram 推送 (可选)
|
||||
TELEGRAM_BOT_TOKEN=***
|
||||
TELEGRAM_CHAT_ID=你的聊天ID
|
||||
|
||||
# ============================================
|
||||
# 自动交易开关 (设置为 1 启用)
|
||||
TRADE_ENABLED=0
|
||||
|
||||
# 交易参数
|
||||
TRADE_THRESHOLD=0.15 # 最低套利利润率 (%)
|
||||
TRADE_AMOUNT_USD=10 # 每腿金额 (USDT)
|
||||
TRADE_COOLDOWN_MS=30000 # 同一币种套利冷却 (毫秒)
|
||||
|
||||
# Bitget API (需开通合约API)
|
||||
# Bitget API (WS 行情)
|
||||
BITGET_API_KEY=***
|
||||
BITGET_API_SECRET=***
|
||||
BITGET_PASSPHRASE=你的密码短语
|
||||
|
||||
# HyperLiquid API (钱包私钥)
|
||||
HL_PRIVATE_KEY=你的ed25519私钥(hex)
|
||||
HL_ADDRESS=你的钱包地址
|
||||
# Binance API (WS 行情 + K线 REST)
|
||||
BINANCE_API_KEY=***
|
||||
BINANCE_API_SECRET=***
|
||||
|
||||
# OKX 行情为公开 WS,无需 API Key
|
||||
|
||||
# 网络代理 (国内环境需要)
|
||||
HTTPS_PROXY=http://127.0.0.1:7890
|
||||
|
||||
# ============================================
|
||||
# 以下为 ema-monitor 使用的参数 (保持不变)
|
||||
DATA_API_BASE=http://localhost:80
|
||||
SYMBOL=BTC/USDT:USDT
|
||||
FETCH_LIMIT_BASE=8000
|
||||
POLL_INTERVAL=10
|
||||
PROXIMITY_THRESHOLD_PCT=0.15
|
||||
ALERT_COOLDOWN=3600
|
||||
|
||||
# ============================================
|
||||
# 测试模式 (模拟交易,不需要真实 API Key)
|
||||
# TEST_MODE=true 时,TRADE_ENABLED 被忽略
|
||||
TEST_MODE=false
|
||||
MOCK_SLIPPAGE_PCT=0.005 # 每腿模拟滑点 (%)
|
||||
# 扫描参数 (可选,config.json 已有默认值)
|
||||
# SURGE_ENABLED=true
|
||||
# MOMENTUM_ENABLED=true
|
||||
# TREND_ENABLED=true
|
||||
|
||||
@@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
|
||||
## Project Overview
|
||||
|
||||
Cross-exchange perpetual contract arbitrage system between Bitget and HyperLiquid. Scans ~150 coins for price spreads, executes automated arbitrage trades with scale-in/exit logic, and displays real-time data on a React dashboard.
|
||||
3-exchange spread surge detection system using Binance, OKX, and Bitget. Scans ~150 coins for inter-exchange price spread anomalies, detects surge events with per-coin adaptive baselines, and displays real-time data on a React dashboard.
|
||||
|
||||
## Build & Run Commands
|
||||
|
||||
@@ -23,91 +23,99 @@ cd frontend && npm run dev
|
||||
|
||||
# Frontend production build
|
||||
cd frontend && npm run build
|
||||
|
||||
# IPC commands (talk to running daemon via Unix socket)
|
||||
./exchange-monitor status
|
||||
./exchange-monitor close-all
|
||||
./exchange-monitor close DOGE
|
||||
./exchange-monitor stop
|
||||
./exchange-monitor start
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
### Data Flow
|
||||
```
|
||||
Exchange WS (HL + BG) → PriceStore (in-memory) → scanner → Trader (entry/exit/scale)
|
||||
Exchange WS (Bitget + Binance + OKX) → PriceStore (in-memory)
|
||||
↓
|
||||
scanner (Scan3Ex)
|
||||
↓
|
||||
┌───────────────┼───────────────┐
|
||||
↓ ↓ ↓
|
||||
surge_detector momentum.go trend.go
|
||||
cumulative.go trend_filter.go
|
||||
↓ ↓ ↓
|
||||
dashboard (SSE hub, :8888)
|
||||
↓
|
||||
React frontend (SSE events)
|
||||
```
|
||||
|
||||
### Main Loop (main.go:148-245)
|
||||
Fixed 50ms tick: trader.Tick() → scan.scanBGHL() → TryEntry() for each opportunity. Every 30s: status log. Hourly: Telegram summary.
|
||||
### Main Loop (main.go)
|
||||
Fixed 50ms tick: reads snap from PriceStore → Scan3Ex() → surgeDetector.Tick() → momentum.Tick() etc. Every 30s: status log. Hourly: Telegram summary.
|
||||
|
||||
### Package Layout
|
||||
|
||||
| Package | Files | Responsibility |
|
||||
|---------|-------|---------------|
|
||||
| `main` (root) | `main.go`, `scanner.go`, `trader.go`, `dashboard.go`, `config.go`, `types.go`, `notifier.go`, `ipc.go` | All core logic in a single flat package |
|
||||
| `exchange/` | `connector.go`, `bitget.go`, `hyperliquid.go`, `bitget_trade.go`, `hyperliquid_trade.go`, `helpers.go` | WS reconnector + exchange-specific REST/WS APIs |
|
||||
| `db/` | `db.go`, `trade_repo.go` | SQLite persistence (trades, orders, system_orders, config_log) |
|
||||
| `main` (root) | `main.go`, `scanner.go`, `dashboard.go`, `config.go`, `types.go`, `surge_detector.go`, `momentum.go`, `trend.go`, `cumulative.go`, `trend_filter.go` | All core logic in a single flat package |
|
||||
| `exchange/` | `connector.go`, `bitget.go`, `binance.go`, `okx.go`, `helpers.go` | WS reconnector + exchange-specific REST/WS APIs |
|
||||
| `db/` | `db.go`, `surge_event_repo.go` | SQLite persistence (surge_events, cm_events, trend_events, trend_signals) |
|
||||
| `frontend/` | Vite + React | Real-time dashboard consuming SSE from backend |
|
||||
|
||||
### Key Types
|
||||
|
||||
- **PriceStore** — Thread-safe in-memory map of coin→exchange→price, with bid/ask spread tracking
|
||||
- **ArbOpportunity** — Scanning result: coin, direction (BG→HL or HL→BG), prices, net profit %
|
||||
- **ArbPosition** — Open position with long/short legs, scale-in tracking, entry prices array
|
||||
- **Trader** — Manages positions, entry/exit logic, fund tracking, blacklist, DB persistence
|
||||
- **ThreeExSpread** — 3-exchange scan result: coin, prices, spread %, max/min exchange
|
||||
- **SurgeDetector** — Per-coin adaptive baseline surge detection with rolling window median
|
||||
- **SurgeEvent** — Detected surge: coin, prices, spread, baseline, direction, leading exchange
|
||||
- **MomentumTracker** — Multi-window (1s/5s/15s/60s) price change tracking per exchange
|
||||
- **TrendDetector** — Cross-exchange trend state machine (idle→alert→confirmed→exhausting)
|
||||
- **CumulativeTracker** — 1m/5m/1h consensus change tracking across exchanges
|
||||
- **TrendFilter** — K-line based quiet detection + EMA52 trend filtering
|
||||
|
||||
### Exchange Connector
|
||||
|
||||
`PriceConnector` (exchange/connector.go) is a reusable WebSocket reconnector with exponential backoff (1s-30s), configurable ping interval, and read deadline. Bitget uses text ping frames; HyperLiquid uses standard ping/pong.
|
||||
`PriceConnector` (exchange/connector.go) is a reusable WebSocket reconnector with exponential backoff (1s-30s), configurable ping interval, and read deadline. Bitget uses text ping frames; Binance and OKX use standard ping/pong.
|
||||
|
||||
### Trading Logic
|
||||
### Surge Detection Logic
|
||||
|
||||
- **Entry (TryEntry → executeEntry)**: Checks threshold, margin, blacklist, cooldown, max positions. Places both legs asynchronously with 300ms delay. Persists DB record immediately on "entering" status for crash recovery.
|
||||
- **Scale-in (checkScaleIn)**: Adds position when spread widens by ScaleStepPct per level. Posts additional orders on both legs.
|
||||
- **Exit (checkExit)**: Take profit at threshold, converged spread ≤ 0.02%, or timeout. Retries failed closes up to 30 times.
|
||||
- **Blacklist**: Force-closes position open >10min without convergence, prevents re-entry for BlacklistDuration.
|
||||
|
||||
### Net Profit Calculation
|
||||
|
||||
```go
|
||||
netProfit(buyPrice, sellPrice, buyFee, sellFee) = (revenue/cost - 1)*100 - 2*(buyFee + sellFee)
|
||||
```
|
||||
Where cost = buyPrice * (1 + buyFee/100), revenue = sellPrice * (1 - sellFee/100). Four total fees (2 entry + 2 exit).
|
||||
- **Adaptive baseline**: Per-coin rolling window (600 samples, ~30s at 50ms tick) of 3-exchange max spreads
|
||||
- **Threshold**: median(spreads) × multiplier (default 3.0), with min floor (0.05%)
|
||||
- **Trigger**: currentSpread > threshold AND cooldown (60s) passed
|
||||
- **Direction**: Compare highest exchange deviation from median vs lowest exchange deviation
|
||||
- **Leading exchange**: The exchange furthest from median price (first to reflect price move)
|
||||
|
||||
### Configuration Priority
|
||||
`.env` vars > `config.json` > code defaults. Config struct in `config.go`.
|
||||
|
||||
Key env vars: `BITGET_API_KEY`, `BITGET_API_SECRET`, `BITGET_PASSPHRASE`, `HL_PRIVATE_KEY`, `HL_ADDRESS`, `HL_API_ADDRESS`, `TELEGRAM_BOT_TOKEN`, `TELEGRAM_CHAT_ID`, `TRADE_ENABLED`, `TEST_MODE`.
|
||||
Key env vars: `BITGET_API_KEY`, `BITGET_API_SECRET`, `BITGET_PASSPHRASE`, `BINANCE_API_KEY`, `BINANCE_API_SECRET`, `HTTPS_PROXY`, `TELEGRAM_BOT_TOKEN`, `TELEGRAM_CHAT_ID`.
|
||||
|
||||
### Dashboard API
|
||||
|
||||
| Endpoint | Description |
|
||||
|----------|-------------|
|
||||
| `GET /` | Serves index.html (disk first, fallback embed) |
|
||||
| `GET /api/status` | Prices, positions, stats, exchange funds |
|
||||
| `GET /events` | SSE stream (prices, positions, arb, stats — 1s interval) |
|
||||
| `GET /api/status` | Current prices snapshot |
|
||||
| `GET /events` | SSE stream (prices, spread_3ex, momentum, trend, cumulative, trend_filter, surge, status) |
|
||||
| `GET /api/history?coin=&exchange=` | Price history ring buffer (500 pts) |
|
||||
| `GET /api/spread-history?coin=` | BG↔HL spread history |
|
||||
| `GET /api/trades?page=&limit=&coin=` | Paginated trade history from DB |
|
||||
| `GET /api/trade/{id}` | Trade detail + orders |
|
||||
| `GET /api/spread-history?coin=` | 3-exchange spread history |
|
||||
| `GET /api/surge-events?limit=` | Surge event history from DB or memory |
|
||||
| `GET /api/cm-history` | Cumulative change event history |
|
||||
| `GET /api/trend-signals` | Trend filter signal history |
|
||||
| `GET /api/connections` | Exchange WS health (online/stale/offline) |
|
||||
| `POST /api/stop` | Stop trading + force-close positions |
|
||||
| `POST /api/start` | Resume trading |
|
||||
|
||||
### SSE Events
|
||||
|
||||
| Event | Data | Frequency |
|
||||
|-------|------|-----------|
|
||||
| `prices` | All coin prices + 3-ex spread | Every tick |
|
||||
| `spread_3ex` | Top 3-ex spreads scan results | Every tick |
|
||||
| `momentum` | Multi-window price change % | Every tick |
|
||||
| `trend` | Trend state machine snapshots | Every tick |
|
||||
| `cumulative` | Cumulative consensus changes | Every tick |
|
||||
| `trend_filter` | K-line filter states | Every tick |
|
||||
| `trend_signal` | Individual trend signal (enter/exit) | On event |
|
||||
| `surge` | Current spread/baseline snapshots | Every tick |
|
||||
| `surge_event` | New surge detection event | On detection |
|
||||
| `status` | Connection health + coin count | Every tick |
|
||||
|
||||
### Database
|
||||
|
||||
SQLite at `~/Project/exchange-monitor-go/data/trades.db` (single-writer mode). Tables: `trades` (trade-level), `orders` (per-leg filled orders), `system_orders` (linked long+short order pairs), `config_log`.
|
||||
SQLite at `~/Project/exchange-monitor-go/data/trades.db` (single-writer mode). Tables: `surge_events`, `cm_events`, `trend_events`, `trend_signals`.
|
||||
|
||||
### Coin Tracking
|
||||
|
||||
~150 coins in `TrackedCoins` slice (scanner.go). Each entry has Name, BN (Binance, currently unused), BG (Bitget symbol), HL (HyperLiquid symbol). Only BG+HL are actively connected.
|
||||
|
||||
### IPC (Unix Socket)
|
||||
|
||||
`/tmp/exchange-monitor.sock` — JSON commands from CLI to daemon. Actions: status, close-all, close {coin}, stop, start.
|
||||
~150 coins in `TrackedCoins` slice (scanner.go). Each entry has Name, BN (Binance symbol), BG (Bitget symbol), OKX (OKX symbol). Active WebSocket connections: Bitget + Binance + OKX.
|
||||
|
||||
@@ -1,29 +1,34 @@
|
||||
# ⚡ 跨交易所永续合约套利监控
|
||||
# 三所价差异动监控
|
||||
|
||||
Bitget ↔ HyperLiquid 跨交易所永续合约价差套利系统。支持模拟盘/实盘交易、价差监控、自动开仓/加仓/平仓、Web 仪表盘。
|
||||
Binance + OKX + Bitget 三交易所价差异动实时检测系统。通过追踪不同交易所之间的价差异常,捕捉币价「启动」的瞬间。
|
||||
|
||||
## 功能特点
|
||||
|
||||
- **实时价差监控** — 200ms 扫描间隔,追踪 DOGE/LINK/ONDO/OP/WIF/ARB 六个币种
|
||||
- **自动套利交易** — 价差超过阈值自动开仓,收敛自动平仓,支持多级加仓
|
||||
- **模拟/实盘双模式** — `TestMode` 控制,模拟模式无需真实 API Key
|
||||
- **三所价差扫描** — 实时计算 Binance/OKX/Bitget 之间的最大价差,按价差排序展示
|
||||
- **自适应 Surge 检测** — 每个币维护独立的滚动窗口基线,检测价差异常飙升
|
||||
- **方向判断** — 根据领先交易所判断上涨/下跌启动方向
|
||||
- **动量扫描** — 多时间窗口 (1s/5s/15s/60s) 价格变动率追踪
|
||||
- **趋势检测** — 跨交易所一致性确认的趋势状态机 (idle→alert→confirmed→exhausting)
|
||||
- **累积变动** — 1m/5m/1h 多交易所共识变动追踪
|
||||
- **趋势过滤** — K 线数据 + EMA52 趋势过滤,识别安静后的异动
|
||||
- **Web 仪表盘** — Go 内置 HTTP Server + Vite React 前端,SSE 实时推送
|
||||
- **SQLite 持久化** — 交易记录、订单明细、手续费明细全量存储
|
||||
- **手续费精确计算** — 逐笔累加实际 USD 手续费(开仓费+平仓费),非百分比估算
|
||||
- **交易所独立资金管理** — 每交易所 $500 初始资金,开仓前检查两边余额充足
|
||||
- **模拟滑点** — 可配置 `mock_slippage_pct`,模拟真实滑点对净利的影响
|
||||
- **持仓详情弹窗** — 点击持仓卡片查看完整交易详情(价差、PnL、手续费分腿)
|
||||
- **Telegram 通知** — 开仓/平仓/异常实时推送
|
||||
- **SQLite 持久化** — Surge 事件、累积变动事件全量存储
|
||||
|
||||
## 架构
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ scanner.go ← 每 200ms 扫描价差 │
|
||||
│ ↓ 发现机会 (NetProfit > 阈值) │
|
||||
│ trader.go ← 开仓/加仓/平仓逻辑 │
|
||||
│ ↓ 持久化 │
|
||||
│ db/ ← SQLite (trades / orders / system) │
|
||||
│ Exchange WS (Bitget + Binance + OKX) │
|
||||
│ ↓ 价格推送 │
|
||||
│ PriceStore ← 内存价格存储 │
|
||||
│ ↓ │
|
||||
│ scanner.go ← 三所价差扫描 │
|
||||
│ ↓ │
|
||||
│ surge_detector.go ← 自适应Surge检测 │
|
||||
│ momentum.go ← 动量扫描 │
|
||||
│ trend.go ← 趋势检测 │
|
||||
│ cumulative.go ← 累积变动追踪 │
|
||||
│ trend_filter.go ← K线趋势过滤 │
|
||||
│ ↓ SSE 推送 │
|
||||
│ dashboard.go ← HTTP Server :8888 │
|
||||
│ ↓ │
|
||||
@@ -39,22 +44,29 @@ Bitget ↔ HyperLiquid 跨交易所永续合约价差套利系统。支持模拟
|
||||
|
||||
```json
|
||||
{
|
||||
"arb_threshold": 0.3,
|
||||
"scan_interval_ms": 200,
|
||||
"trade_enabled": true,
|
||||
"test_mode": true,
|
||||
"trade_threshold": 0.20,
|
||||
"take_profit_pct": 0.20,
|
||||
"mock_slippage_pct": 0.05,
|
||||
"trade_amount_usd": 5,
|
||||
"max_positions": 5,
|
||||
"initial_capital": 500,
|
||||
"taker_fee_bitget": 0.060,
|
||||
"taker_fee_hyperliquid": 0.045,
|
||||
"telegram_bot_token": "xxx",
|
||||
"telegram_chat_id": "xxx"
|
||||
"alert_cooldown_sec": 300,
|
||||
"surge_enabled": true,
|
||||
"surge_window_size": 600,
|
||||
"surge_baseline_multiplier": 3.0,
|
||||
"surge_min_abs_spread_pct": 0.05,
|
||||
"surge_cooldown_sec": 60,
|
||||
"momentum_enabled": true,
|
||||
"momentum_threshold_pct": 0.25,
|
||||
"trend_enabled": true,
|
||||
"trend_baseline_window": 600,
|
||||
"trend_anomaly_mul": 3.0,
|
||||
"trend_confirm_ticks": 3,
|
||||
"trend_alert_cooldown_ms": 60000
|
||||
}
|
||||
```
|
||||
|
||||
环境变量 (`.env`):
|
||||
- `BITGET_API_KEY`, `BITGET_API_SECRET`, `BITGET_PASSPHRASE` — Bitget API
|
||||
- `BINANCE_API_KEY`, `BINANCE_API_SECRET` — Binance API (可选,用于K线)
|
||||
- `HTTPS_PROXY` — 网络代理 (国内环境需要)
|
||||
|
||||
### 2. 启动
|
||||
|
||||
```bash
|
||||
@@ -79,54 +91,28 @@ npm run dev # 开发模式 (Vite HMR :5173)
|
||||
npm run build # 构建生产版本
|
||||
```
|
||||
|
||||
后端优先从 `frontend/dist/` 读取静态文件(热加载),回退到 Go embed。
|
||||
|
||||
## 配置参数
|
||||
|
||||
| 参数 | 说明 | 默认 |
|
||||
|------|------|------|
|
||||
| `scan_interval_ms` | 扫描间隔 (ms) | 200 |
|
||||
| `trade_threshold` | 开仓净利阈值 (%) | 0.20 |
|
||||
| `take_profit_pct` | 止盈净利 (%) | 0.20 |
|
||||
| `mock_slippage_pct` | 模拟滑点 (%) | 0.05 |
|
||||
| `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 |
|
||||
| `initial_capital` | 每交易所初始资金 ($) | 500 |
|
||||
| `surge_enabled` | 启用 Surge 检测 | true |
|
||||
| `surge_window_size` | 滚动窗口样本数 | 600 |
|
||||
| `surge_baseline_multiplier` | 基线倍数(阈值 = 基线 × N) | 3.0 |
|
||||
| `surge_min_abs_spread_pct` | 最小绝对价差 % | 0.05 |
|
||||
| `surge_cooldown_sec` | 同币冷却秒数 | 60 |
|
||||
| `momentum_enabled` | 启用动量扫描 | true |
|
||||
| `trend_enabled` | 启用趋势检测 | true |
|
||||
| `trend_baseline_window` | 趋势基线窗口 | 600 |
|
||||
| `trend_anomaly_mul` | 异常检测倍数 | 3.0 |
|
||||
| `trend_confirm_ticks` | 确认所需次数 | 3 |
|
||||
|
||||
## 数据库
|
||||
|
||||
SQLite (`data/trades.db`),三张核心表:
|
||||
SQLite (`data/trades.db`),核心表:
|
||||
|
||||
| 表 | 说明 |
|
||||
|----|------|
|
||||
| `trades` | 交易主表 — 价差、PnL、手续费 ($) |
|
||||
| `orders` | 订单明细 — 每腿的开仓/加仓/平仓、手续费 ($) |
|
||||
| `system_orders` | 系统订单 — 双向关联 long↔short 订单 |
|
||||
|
||||
## 版本历史
|
||||
|
||||
### v1.3 (当前)
|
||||
- ✨ **交易所独立资金管理** — 每交易所 $500 初始资金,开仓前检查余额
|
||||
- ✨ **持仓点击详情弹窗** — 点击持仓卡片弹出完整交易详情
|
||||
- ✨ **模拟滑点** — 新增 `mock_slippage_pct` 配置,模拟真实成交滑点
|
||||
- ✨ **止损硬编码** — 取消 `SpreadReverseExitPct` 配置,价差 <= 0 硬止损
|
||||
- 🐛 修复 DB 迁移 tab 字符损坏导致历史交易数据不显示
|
||||
- 📊 PnL 统计分腿存储(`pnl_long_usd`/`pnl_short_usd`/`fee_long_usd`/`fee_short_usd`)
|
||||
|
||||
### v1.2.1
|
||||
- ✨ scale-in 后 leg.EntryPrice 更新为加权平均(`weightedAvgPrice`)
|
||||
- ✨ 进程重启恢复仓位时,从 `orders` 表加载 scale prices,重建完整价格切片
|
||||
- ✨ 新增 `GetScalePrices()` DB 方法
|
||||
|
||||
### v1.2
|
||||
- ✨ `system_orders` 表,记录系统级开仓/加仓/平仓
|
||||
- ✨ 手续费改为逐笔累加 USD,不再用百分比估算
|
||||
- ✨ Vite + React 前端,支持热加载
|
||||
- ✨ Web 仪表盘持仓 PnL 美元化显示
|
||||
- 🐛 修复 `persistTrade` 费用在 `SaveTrade` 后才累加导致 fee=0 的 bug
|
||||
- 🗑 移除老版 Chart.js 图表
|
||||
| `surge_events` | Surge 异动事件 — 币种、价格、价差、方向、领先交易所 |
|
||||
| `cm_events` | 累积变动事件 — 1m/5m/1h 多所共识变动 |
|
||||
| `trend_events` | 趋势状态变迁 — idle→alert→confirmed→exhausting 全生命周期 |
|
||||
| `trend_signals` | 趋势过滤信号 — K 线安静 + EMA 异动信号记录 |
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Config holds all system configuration.
|
||||
@@ -13,44 +12,15 @@ type Config struct {
|
||||
TelegramBotToken string
|
||||
TelegramChatID string
|
||||
AlertCooldownSec int // seconds between alerts for same coin
|
||||
ArbThreshold float64 // minimum net profit % to trigger alert
|
||||
ArbThreshold float64 // minimum spread % to trigger alert
|
||||
ScanIntervalMs int // how often scanner runs (milliseconds)
|
||||
|
||||
// Automated trading
|
||||
TradeEnabled bool
|
||||
TradeThreshold float64 // minimum profit % to execute trade
|
||||
TradeAmountUSD float64 // amount per leg in USDT
|
||||
TradeCooldownMs int // ms between trades of same coin
|
||||
MaxPositions int // max concurrent open positions (0 = unlimited)
|
||||
|
||||
// Capital
|
||||
InitialCapital float64 // starting capital in USD (for PnL % calculation)
|
||||
|
||||
// Blacklist — stale spread observation
|
||||
BlacklistDuration time.Duration // how long a coin stays blacklisted (0 = permanent)
|
||||
|
||||
// ExcludedCoins — coins to never trade (hard block)
|
||||
ExcludedCoins []string
|
||||
|
||||
// Test mode (no real API keys needed)
|
||||
TestMode bool
|
||||
MockSlippagePct float64 // simulated slippage per order (e.g. 0.01 = 0.01%)
|
||||
|
||||
// Exchange fee rates (% per order)
|
||||
TakerFeeBitget float64
|
||||
TakerFeeHyperLiquid float64
|
||||
|
||||
// Exit/risk parameters
|
||||
TakeProfitPct float64 // net profit % threshold for take-profit
|
||||
PositionTimeout time.Duration // max position hold time before auto-close
|
||||
LegDelay time.Duration // delay between placing long and short legs
|
||||
|
||||
// Scale-in parameters
|
||||
ScaleStepPct float64 // spread widening % trigger for each scale level
|
||||
ScaleCooldown time.Duration // minimum time between scale-ins
|
||||
|
||||
// Entry sanity check: reject if price moved beyond this % in the wrong direction
|
||||
ReversalTolerancePct float64
|
||||
// Surge detection
|
||||
SurgeEnabled bool
|
||||
SurgeWindowSize int // rolling window samples (default: 600 = ~30s)
|
||||
SurgeBaselineMultiplier float64 // baseline * N = threshold (default: 3.0)
|
||||
SurgeMinAbsSpreadPct float64 // minimum absolute spread % (default: 0.05)
|
||||
SurgeCooldownSec int // cooldown seconds per coin (default: 60)
|
||||
|
||||
// Momentum scanning mode
|
||||
MomentumEnabled bool
|
||||
@@ -62,33 +32,20 @@ type Config struct {
|
||||
TrendAnomalyMul float64 // z-score multiplier for alert threshold (default: 3.0)
|
||||
TrendConfirmTicks int // ticks needed for state confirmation (default: 3)
|
||||
TrendAlertCooldown int64 // ms cooldown between alerts for same coin (default: 60000)
|
||||
|
||||
// Bitget API
|
||||
BitgetAPIKey string
|
||||
BitgetAPISecret string
|
||||
BitgetPassphrase string
|
||||
|
||||
// HyperLiquid API
|
||||
HLPrivateKey string // ed25519 private key hex
|
||||
HLAddress string // main account address
|
||||
HLAPIAddress string // API wallet address (signer, auto-derived if empty)
|
||||
}
|
||||
|
||||
// jsonConfig maps config.json fields (non-secret defaults checked into git).
|
||||
type jsonConfig struct {
|
||||
TestMode bool `json:"test_mode"`
|
||||
TradeEnabled bool `json:"trade_enabled"`
|
||||
ArbThreshold float64 `json:"arb_threshold"`
|
||||
ScanIntervalMs int `json:"scan_interval_ms"`
|
||||
TradeThreshold float64 `json:"trade_threshold"`
|
||||
TradeAmountUSD float64 `json:"trade_amount_usd"`
|
||||
TradeCooldownMs int `json:"trade_cooldown_ms"`
|
||||
AlertCooldownSec int `json:"alert_cooldown_sec"`
|
||||
MockSlippagePct float64 `json:"mock_slippage_pct"`
|
||||
MaxPositions int `json:"max_positions"`
|
||||
BlacklistDuration int `json:"blacklist_duration_sec"`
|
||||
InitialCapital float64 `json:"initial_capital"`
|
||||
ExcludedCoins []string `json:"excluded_coins"`
|
||||
|
||||
// Surge detection
|
||||
SurgeEnabled bool `json:"surge_enabled"`
|
||||
SurgeWindowSize int `json:"surge_window_size"`
|
||||
SurgeBaselineMultiplier float64 `json:"surge_baseline_multiplier"`
|
||||
SurgeMinAbsSpreadPct float64 `json:"surge_min_abs_spread_pct"`
|
||||
SurgeCooldownSec int `json:"surge_cooldown_sec"`
|
||||
|
||||
// Momentum scanning
|
||||
MomentumEnabled bool `json:"momentum_enabled"`
|
||||
@@ -100,18 +57,6 @@ type jsonConfig struct {
|
||||
TrendAnomalyMul float64 `json:"trend_anomaly_mul"`
|
||||
TrendConfirmTicks int `json:"trend_confirm_ticks"`
|
||||
TrendAlertCooldown int64 `json:"trend_alert_cooldown_ms"`
|
||||
|
||||
// New: exchange fees
|
||||
TakerFeeBitget float64 `json:"taker_fee_bitget"`
|
||||
TakerFeeHyperLiquid float64 `json:"taker_fee_hyperliquid"`
|
||||
|
||||
// New: exit/risk parameters
|
||||
TakeProfitPct float64 `json:"take_profit_pct"`
|
||||
PositionTimeoutSec int `json:"position_timeout_sec"`
|
||||
LegDelayMs int `json:"leg_delay_ms"`
|
||||
ReversalTolerancePct float64 `json:"reversal_tolerance_pct"`
|
||||
ScaleStepPct float64 `json:"scale_step_pct"`
|
||||
ScaleCooldownSec int `json:"scale_cooldown_sec"`
|
||||
}
|
||||
|
||||
func LoadConfig() *Config {
|
||||
@@ -151,34 +96,12 @@ func LoadConfig() *Config {
|
||||
ArbThreshold: getFloat("ARB_THRESHOLD", jsonCfg.ArbThreshold),
|
||||
ScanIntervalMs: int(getFloat("SCAN_INTERVAL_MS", float64(jsonCfg.ScanIntervalMs))),
|
||||
|
||||
TradeEnabled: getBool("TRADE_ENABLED", jsonCfg.TradeEnabled),
|
||||
TradeThreshold: getFloat("TRADE_THRESHOLD", jsonCfg.TradeThreshold),
|
||||
TradeAmountUSD: getFloat("TRADE_AMOUNT_USD", jsonCfg.TradeAmountUSD),
|
||||
TradeCooldownMs: int(getFloat("TRADE_COOLDOWN_MS", float64(jsonCfg.TradeCooldownMs))),
|
||||
MaxPositions: int(getFloat("MAX_POSITIONS", float64(jsonCfg.MaxPositions))),
|
||||
|
||||
InitialCapital: getFloat("INITIAL_CAPITAL", jsonCfg.InitialCapital),
|
||||
|
||||
BlacklistDuration: time.Duration(getFloat("BLACKLIST_DURATION_SEC", float64(jsonCfg.BlacklistDuration))) * time.Second,
|
||||
|
||||
TestMode: getBool("TEST_MODE", jsonCfg.TestMode),
|
||||
MockSlippagePct: getFloat("MOCK_SLIPPAGE_PCT", jsonCfg.MockSlippagePct),
|
||||
|
||||
// Exchange fee rates
|
||||
TakerFeeBitget: getFloat("TAKER_FEE_BITGET", jsonCfg.TakerFeeBitget),
|
||||
TakerFeeHyperLiquid: getFloat("TAKER_FEE_HYPERLIQUID", jsonCfg.TakerFeeHyperLiquid),
|
||||
|
||||
// Exit/risk parameters
|
||||
TakeProfitPct: getFloat("TAKE_PROFIT_PCT", jsonCfg.TakeProfitPct),
|
||||
PositionTimeout: time.Duration(getFloat("POSITION_TIMEOUT_SEC", float64(jsonCfg.PositionTimeoutSec))) * time.Second,
|
||||
LegDelay: time.Duration(getFloat("LEG_DELAY_MS", float64(jsonCfg.LegDelayMs))) * time.Millisecond,
|
||||
ReversalTolerancePct: getFloat("REVERSAL_TOLERANCE_PCT", jsonCfg.ReversalTolerancePct),
|
||||
|
||||
// Scale-in parameters
|
||||
ScaleStepPct: getFloat("SCALE_STEP_PCT", jsonCfg.ScaleStepPct),
|
||||
ScaleCooldown: time.Duration(getFloat("SCALE_COOLDOWN_SEC", float64(jsonCfg.ScaleCooldownSec))) * time.Second,
|
||||
|
||||
ExcludedCoins: jsonCfg.ExcludedCoins,
|
||||
// Surge detection
|
||||
SurgeEnabled: getBool("SURGE_ENABLED", jsonCfg.SurgeEnabled),
|
||||
SurgeWindowSize: int(getFloat("SURGE_WINDOW_SIZE", float64(jsonCfg.SurgeWindowSize))),
|
||||
SurgeBaselineMultiplier: getFloat("SURGE_BASELINE_MULTIPLIER", jsonCfg.SurgeBaselineMultiplier),
|
||||
SurgeMinAbsSpreadPct: getFloat("SURGE_MIN_ABS_SPREAD_PCT", jsonCfg.SurgeMinAbsSpreadPct),
|
||||
SurgeCooldownSec: int(getFloat("SURGE_COOLDOWN_SEC", float64(jsonCfg.SurgeCooldownSec))),
|
||||
|
||||
// Momentum scanning
|
||||
MomentumEnabled: getBool("MOMENTUM_ENABLED", jsonCfg.MomentumEnabled),
|
||||
@@ -190,14 +113,6 @@ func LoadConfig() *Config {
|
||||
TrendAnomalyMul: getFloat("TREND_ANOMALY_MUL", jsonCfg.TrendAnomalyMul),
|
||||
TrendConfirmTicks: int(getFloat("TREND_CONFIRM_TICKS", float64(jsonCfg.TrendConfirmTicks))),
|
||||
TrendAlertCooldown: int64(getFloat("TREND_ALERT_COOLDOWN_MS", float64(jsonCfg.TrendAlertCooldown))),
|
||||
|
||||
BitgetAPIKey: getEnv("BITGET_API_KEY", ""),
|
||||
BitgetAPISecret: getEnv("BITGET_API_SECRET", ""),
|
||||
BitgetPassphrase: getEnv("BITGET_PASSPHRASE", ""),
|
||||
|
||||
HLPrivateKey: getEnv("HL_PRIVATE_KEY", ""),
|
||||
HLAddress: getEnv("HL_ADDRESS", ""),
|
||||
HLAPIAddress: getEnv("HL_API_ADDRESS", ""),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -205,28 +120,14 @@ func loadJSONConfig() jsonConfig {
|
||||
def := jsonConfig{
|
||||
ArbThreshold: 0.03,
|
||||
ScanIntervalMs: 500,
|
||||
TradeThreshold: 0.15,
|
||||
TradeAmountUSD: 10,
|
||||
TradeCooldownMs: 30000,
|
||||
AlertCooldownSec: 300,
|
||||
MockSlippagePct: 0.005,
|
||||
MaxPositions: 5, // default max 5 concurrent positions
|
||||
BlacklistDuration: 3600, // default 1 hour blacklist observation
|
||||
InitialCapital: 1000, // default $1000 starting capital
|
||||
|
||||
// Exchange fee rates
|
||||
TakerFeeBitget: 0.060, // 0.060%
|
||||
TakerFeeHyperLiquid: 0.045, // 0.045%
|
||||
|
||||
// Exit/risk parameters
|
||||
TakeProfitPct: 0.20, // 0.20% net profit take-profit
|
||||
PositionTimeoutSec: 1800, // 30 minutes
|
||||
LegDelayMs: 300, // 300ms between legs
|
||||
ReversalTolerancePct: 0.1, // 0.1% tolerance for entry sanity check
|
||||
|
||||
// Scale-in parameters
|
||||
ScaleStepPct: 0.10, // 0.10% spread widening per scale level
|
||||
ScaleCooldownSec: 5, // 5 seconds between scales
|
||||
// Surge detection
|
||||
SurgeEnabled: true,
|
||||
SurgeWindowSize: 600, // ~30s at 50ms tick
|
||||
SurgeBaselineMultiplier: 3.0, // baseline * N = threshold
|
||||
SurgeMinAbsSpreadPct: 0.05, // minimum absolute spread %
|
||||
SurgeCooldownSec: 60, // seconds between alerts for same coin
|
||||
|
||||
// Momentum scanning
|
||||
MomentumThresholdPct: 0.25, // 0.25% change flags momentum
|
||||
@@ -255,58 +156,22 @@ func loadJSONConfig() jsonConfig {
|
||||
if cfg.ScanIntervalMs != 0 {
|
||||
def.ScanIntervalMs = cfg.ScanIntervalMs
|
||||
}
|
||||
if cfg.TradeThreshold != 0 {
|
||||
def.TradeThreshold = cfg.TradeThreshold
|
||||
}
|
||||
if cfg.TradeAmountUSD != 0 {
|
||||
def.TradeAmountUSD = cfg.TradeAmountUSD
|
||||
}
|
||||
if cfg.TradeCooldownMs != 0 {
|
||||
def.TradeCooldownMs = cfg.TradeCooldownMs
|
||||
}
|
||||
if cfg.AlertCooldownSec != 0 {
|
||||
def.AlertCooldownSec = cfg.AlertCooldownSec
|
||||
}
|
||||
if cfg.MockSlippagePct != 0 {
|
||||
def.MockSlippagePct = cfg.MockSlippagePct
|
||||
}
|
||||
if cfg.MaxPositions != 0 {
|
||||
def.MaxPositions = cfg.MaxPositions
|
||||
}
|
||||
if cfg.BlacklistDuration != 0 {
|
||||
def.BlacklistDuration = cfg.BlacklistDuration
|
||||
}
|
||||
if cfg.InitialCapital != 0 {
|
||||
def.InitialCapital = cfg.InitialCapital
|
||||
}
|
||||
|
||||
// New config fields
|
||||
if cfg.TakerFeeBitget != 0 {
|
||||
def.TakerFeeBitget = cfg.TakerFeeBitget
|
||||
// Surge detection JSON overrides
|
||||
if cfg.SurgeWindowSize != 0 {
|
||||
def.SurgeWindowSize = cfg.SurgeWindowSize
|
||||
}
|
||||
if cfg.TakerFeeHyperLiquid != 0 {
|
||||
def.TakerFeeHyperLiquid = cfg.TakerFeeHyperLiquid
|
||||
if cfg.SurgeBaselineMultiplier != 0 {
|
||||
def.SurgeBaselineMultiplier = cfg.SurgeBaselineMultiplier
|
||||
}
|
||||
if cfg.TakeProfitPct != 0 {
|
||||
def.TakeProfitPct = cfg.TakeProfitPct
|
||||
if cfg.SurgeMinAbsSpreadPct != 0 {
|
||||
def.SurgeMinAbsSpreadPct = cfg.SurgeMinAbsSpreadPct
|
||||
}
|
||||
if cfg.PositionTimeoutSec != 0 {
|
||||
def.PositionTimeoutSec = cfg.PositionTimeoutSec
|
||||
}
|
||||
if cfg.LegDelayMs != 0 {
|
||||
def.LegDelayMs = cfg.LegDelayMs
|
||||
}
|
||||
if cfg.ReversalTolerancePct != 0 {
|
||||
def.ReversalTolerancePct = cfg.ReversalTolerancePct
|
||||
}
|
||||
if cfg.ScaleStepPct != 0 {
|
||||
def.ScaleStepPct = cfg.ScaleStepPct
|
||||
}
|
||||
if cfg.ScaleCooldownSec != 0 {
|
||||
def.ScaleCooldownSec = cfg.ScaleCooldownSec
|
||||
}
|
||||
if len(cfg.ExcludedCoins) > 0 {
|
||||
def.ExcludedCoins = cfg.ExcludedCoins
|
||||
if cfg.SurgeCooldownSec != 0 {
|
||||
def.SurgeCooldownSec = cfg.SurgeCooldownSec
|
||||
}
|
||||
|
||||
if cfg.MomentumThresholdPct != 0 {
|
||||
@@ -328,12 +193,9 @@ func loadJSONConfig() jsonConfig {
|
||||
}
|
||||
|
||||
// Boolean fields: zero default is false, so use OR logic
|
||||
// When JSON has true → true || false = true (override)
|
||||
// When JSON has false → false || false = false (keep default)
|
||||
def.TestMode = cfg.TestMode || def.TestMode
|
||||
def.TradeEnabled = cfg.TradeEnabled || def.TradeEnabled
|
||||
def.MomentumEnabled = cfg.MomentumEnabled || def.MomentumEnabled
|
||||
def.TrendEnabled = cfg.TrendEnabled || def.TrendEnabled
|
||||
def.SurgeEnabled = cfg.SurgeEnabled || def.SurgeEnabled
|
||||
|
||||
return def
|
||||
}
|
||||
|
||||
+10
-19
@@ -1,26 +1,17 @@
|
||||
{
|
||||
"test_mode": false,
|
||||
"trade_enabled": true,
|
||||
"arb_threshold": 0.3,
|
||||
"scan_interval_ms": 200,
|
||||
"trade_threshold": 0.3,
|
||||
"trade_amount_usd": 20,
|
||||
"trade_cooldown_ms": 30000,
|
||||
"alert_cooldown_sec": 300,
|
||||
"mock_slippage_pct": 0.05,
|
||||
"max_positions": 1,
|
||||
"blacklist_duration_sec": 3600,
|
||||
"initial_capital": 1000,
|
||||
"taker_fee_bitget": 0.060,
|
||||
"taker_fee_hyperliquid": 0.045,
|
||||
"take_profit_pct": 0.3,
|
||||
"spread_reverse_exit_pct": 0,
|
||||
"position_timeout_sec": 1800,
|
||||
"leg_delay_ms": 300,
|
||||
"reversal_tolerance_pct": 0.1,
|
||||
"scale_step_pct": 0.3,
|
||||
"scale_cooldown_sec": 5,
|
||||
"surge_enabled": true,
|
||||
"surge_window_size": 600,
|
||||
"surge_baseline_multiplier": 3.0,
|
||||
"surge_min_abs_spread_pct": 0.05,
|
||||
"surge_cooldown_sec": 60,
|
||||
"momentum_enabled": true,
|
||||
"momentum_threshold_pct": 0.25,
|
||||
"trend_enabled": true
|
||||
"trend_enabled": true,
|
||||
"trend_baseline_window": 600,
|
||||
"trend_anomaly_mul": 3.0,
|
||||
"trend_confirm_ticks": 3,
|
||||
"trend_alert_cooldown_ms": 60000
|
||||
}
|
||||
|
||||
+1
-3
@@ -27,8 +27,6 @@ func shortExName(name string) string {
|
||||
switch name {
|
||||
case ExBitget:
|
||||
return "bg"
|
||||
case ExHyperLiquid:
|
||||
return "hl"
|
||||
case ExBinance:
|
||||
return "bn"
|
||||
case ExOKX:
|
||||
@@ -459,7 +457,7 @@ func (ct *CumulativeTracker) makeEvent(coin, prevState, newState, direction stri
|
||||
ExAgree: exAgree,
|
||||
ExTotal: exTotal,
|
||||
BGChange1m: exChanges[ExBitget],
|
||||
HLChange1m: exChanges[ExHyperLiquid],
|
||||
HLChange1m: 0,
|
||||
BNChange1m: exChanges[ExBinance],
|
||||
OKXChange1m: exChanges[ExOKX],
|
||||
Timestamp: time.Now().UnixMilli(),
|
||||
|
||||
+79
-316
@@ -5,7 +5,6 @@ import (
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log"
|
||||
"math"
|
||||
"net/http"
|
||||
"os"
|
||||
"sync"
|
||||
@@ -132,17 +131,17 @@ func (ph *priceHistory) GetHistory(coin, exchange string, limit int) []pricePoin
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Spread History — tracks BG↔HL spread % per coin (P3-2)
|
||||
// Spread History — tracks 3-exchange max spread % per coin
|
||||
// ============================================================
|
||||
|
||||
type spreadPoint struct {
|
||||
T int64 `json:"t"`
|
||||
Spread float64 `json:"s"` // spread % (positive = BG cheaper than HL for BG->HL direction)
|
||||
Spread float64 `json:"s"` // 3-exchange max spread %
|
||||
}
|
||||
|
||||
type spreadHistory struct {
|
||||
mu sync.RWMutex
|
||||
buffers map[string][]spreadPoint // coin -> spread points
|
||||
buffers map[string][]spreadPoint
|
||||
}
|
||||
|
||||
func newSpreadHistory() *spreadHistory {
|
||||
@@ -186,19 +185,18 @@ type Dashboard struct {
|
||||
history *priceHistory
|
||||
spreads *spreadHistory
|
||||
store *PriceStore
|
||||
trader *Trader
|
||||
db *db.DB
|
||||
addr string
|
||||
cfg *Config
|
||||
|
||||
// cached arb scan results
|
||||
// cached scan results
|
||||
mu sync.RWMutex
|
||||
lastScan []*ArbOpportunity
|
||||
lastScan []ThreeExSpread
|
||||
scanTime time.Time
|
||||
|
||||
// P3-5: connection status — exchange -> last update time
|
||||
// connection status — exchange -> last update time
|
||||
connMu sync.RWMutex
|
||||
connMap map[string]time.Time // exchange name -> last price timestamp
|
||||
connMap map[string]time.Time
|
||||
|
||||
// Momentum tracker
|
||||
momentumTracker *MomentumTracker
|
||||
@@ -211,15 +209,17 @@ type Dashboard struct {
|
||||
|
||||
// Trend filter (K-line based quiet + EMA filter)
|
||||
trendFilter *TrendFilter
|
||||
|
||||
// Surge detector
|
||||
surgeDetector *SurgeDetector
|
||||
}
|
||||
|
||||
func NewDashboard(store *PriceStore, trader *Trader, database *db.DB, addr string, cfg *Config, momentumTracker *MomentumTracker, trendDetector *TrendDetector, cumulativeTracker *CumulativeTracker, trendFilter *TrendFilter) *Dashboard {
|
||||
func NewDashboard(store *PriceStore, database *db.DB, addr string, cfg *Config, momentumTracker *MomentumTracker, trendDetector *TrendDetector, cumulativeTracker *CumulativeTracker, trendFilter *TrendFilter, surgeDetector *SurgeDetector) *Dashboard {
|
||||
d := &Dashboard{
|
||||
hub: NewSSEHub(),
|
||||
history: newPriceHistory(),
|
||||
spreads: newSpreadHistory(),
|
||||
store: store,
|
||||
trader: trader,
|
||||
db: database,
|
||||
addr: addr,
|
||||
cfg: cfg,
|
||||
@@ -228,6 +228,7 @@ func NewDashboard(store *PriceStore, trader *Trader, database *db.DB, addr strin
|
||||
trendDetector: trendDetector,
|
||||
cumulativeTracker: cumulativeTracker,
|
||||
trendFilter: trendFilter,
|
||||
surgeDetector: surgeDetector,
|
||||
}
|
||||
|
||||
// Wire trend event persistence to SQLite
|
||||
@@ -269,28 +270,22 @@ func (d *Dashboard) Run() {
|
||||
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)
|
||||
} else {
|
||||
if err == nil && staticSub != nil {
|
||||
mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticSub))))
|
||||
}
|
||||
|
||||
mux.HandleFunc("GET /", d.handleIndex)
|
||||
mux.HandleFunc("GET /api/status", d.handleStatus)
|
||||
mux.HandleFunc("GET /api/history", d.handleHistory)
|
||||
mux.HandleFunc("GET /api/spread-history", d.handleSpreadHistory) // P3-2
|
||||
mux.HandleFunc("GET /api/trades", d.handleTrades)
|
||||
mux.HandleFunc("GET /api/trade/", d.handleTradeDetail)
|
||||
mux.HandleFunc("GET /api/connections", d.handleConnStatus) // P3-5
|
||||
mux.HandleFunc("GET /api/spread-history", d.handleSpreadHistory)
|
||||
mux.HandleFunc("GET /api/connections", d.handleConnStatus)
|
||||
mux.HandleFunc("GET /api/trend-history", d.handleTrendHistory)
|
||||
mux.HandleFunc("GET /api/cm-history", d.handleCmHistory)
|
||||
mux.HandleFunc("GET /api/trend-signals", d.handleTrendSignals)
|
||||
mux.HandleFunc("GET /api/surge-events", d.handleSurgeEvents)
|
||||
mux.HandleFunc("GET /events", d.handleSSE)
|
||||
mux.HandleFunc("POST /api/stop", d.handleStop)
|
||||
mux.HandleFunc("POST /api/start", d.handleStart)
|
||||
|
||||
server := &http.Server{
|
||||
Addr: d.addr,
|
||||
@@ -305,68 +300,6 @@ func (d *Dashboard) Run() {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Stats computation — kept separate from trading logic
|
||||
// ============================================================
|
||||
|
||||
// DetailedStats holds aggregated PnL and duration statistics.
|
||||
type DetailedStats struct {
|
||||
TotalTrades int `json:"total_trades"`
|
||||
TotalPnlUSD float64 `json:"total_pnl_usd"` // sum of all trade PnL in USD
|
||||
CapitalPnlPct float64 `json:"capital_pnl_pct"` // TotalPnlUSD / InitialCapital * 100
|
||||
AvgPnlPct float64 `json:"avg_pnl_pct"`
|
||||
MaxProfitPct float64 `json:"max_profit_pct"`
|
||||
MaxLossPct float64 `json:"max_loss_pct"`
|
||||
AvgDuration string `json:"avg_duration"`
|
||||
TotalDuration string `json:"total_duration"`
|
||||
WinningTrades int `json:"winning_trades"`
|
||||
LosingTrades int `json:"losing_trades"`
|
||||
WinRate float64 `json:"win_rate"`
|
||||
}
|
||||
|
||||
// calcDetailedStats computes trading statistics from a slice of closed trades.
|
||||
// This is a pure function — no dependency on Trader internals.
|
||||
func calcDetailedStats(trades []TradeRecord, initialCapital float64) DetailedStats {
|
||||
ds := DetailedStats{}
|
||||
if len(trades) == 0 {
|
||||
return ds
|
||||
}
|
||||
var totalDur time.Duration
|
||||
ds.MaxLossPct = 1e9 // sentinel
|
||||
for _, tr := range trades {
|
||||
ds.TotalTrades++
|
||||
ds.TotalPnlUSD += tr.PnlUSD
|
||||
if tr.PnlPct >= 0 {
|
||||
ds.WinningTrades++
|
||||
if tr.PnlPct > ds.MaxProfitPct {
|
||||
ds.MaxProfitPct = tr.PnlPct
|
||||
}
|
||||
} else {
|
||||
ds.LosingTrades++
|
||||
if tr.PnlPct < ds.MaxLossPct {
|
||||
ds.MaxLossPct = tr.PnlPct
|
||||
}
|
||||
}
|
||||
if !tr.ClosedAt.IsZero() && !tr.OpenedAt.IsZero() {
|
||||
totalDur += tr.ClosedAt.Sub(tr.OpenedAt)
|
||||
}
|
||||
}
|
||||
if ds.MaxLossPct == 1e9 {
|
||||
ds.MaxLossPct = 0
|
||||
}
|
||||
if ds.TotalTrades > 0 {
|
||||
ds.CapitalPnlPct = ds.TotalPnlUSD / initialCapital * 100
|
||||
ds.AvgPnlPct = ds.TotalPnlUSD / float64(ds.TotalTrades) / initialCapital * 100
|
||||
ds.WinRate = float64(ds.WinningTrades) / float64(ds.TotalTrades) * 100
|
||||
}
|
||||
if totalDur > 0 {
|
||||
avgDur := totalDur / time.Duration(ds.TotalTrades)
|
||||
ds.AvgDuration = avgDur.Round(time.Second).String()
|
||||
ds.TotalDuration = totalDur.Round(time.Second).String()
|
||||
}
|
||||
return ds
|
||||
}
|
||||
|
||||
// broadcastLoop pushes data to SSE clients every 1 second.
|
||||
func (d *Dashboard) broadcastLoop() {
|
||||
tick := time.NewTicker(1 * time.Second)
|
||||
@@ -378,7 +311,7 @@ func (d *Dashboard) broadcastLoop() {
|
||||
continue
|
||||
}
|
||||
|
||||
// 1. Prices + spreads + connection status
|
||||
// 1. Prices + 3-exchange spreads
|
||||
var prices []map[string]interface{}
|
||||
for _, coin := range TrackedCoins {
|
||||
exMap := snap[coin.Name]
|
||||
@@ -398,135 +331,48 @@ func (d *Dashboard) broadcastLoop() {
|
||||
}
|
||||
}
|
||||
|
||||
// P3-2: Calculate BG↔HL spread and record
|
||||
// Calculate 3-exchange max spread
|
||||
bnP := exMap[ExBinance]
|
||||
okxP := exMap[ExOKX]
|
||||
bgP := exMap[ExBitget]
|
||||
hlP := exMap[ExHyperLiquid]
|
||||
if bgP > 0 && hlP > 0 {
|
||||
spreadPct := (hlP - bgP) / bgP * 100
|
||||
entry["bg_hl_spread"] = spreadPct
|
||||
if bnP > 0 && okxP > 0 && bgP > 0 {
|
||||
prices_ := []float64{bnP, okxP, bgP}
|
||||
minP, maxP := prices_[0], prices_[0]
|
||||
for _, p := range prices_[1:] {
|
||||
if p < minP { minP = p }
|
||||
if p > maxP { maxP = p }
|
||||
}
|
||||
spreadPct := (maxP - minP) / minP * 100
|
||||
entry["spread_3ex"] = spreadPct
|
||||
d.spreads.Record(coin.Name, spreadPct)
|
||||
|
||||
// Both directions net profit after fees (4 taker fees: 2 entry + 2 exit)
|
||||
cost := bgP * (1 + takerFees[ExBitget]/100)
|
||||
revenue := hlP * (1 - takerFees[ExHyperLiquid]/100)
|
||||
netBG := (revenue/cost-1)*100 - 2*(takerFees[ExBitget]+takerFees[ExHyperLiquid])
|
||||
|
||||
cost = hlP * (1 + takerFees[ExHyperLiquid]/100)
|
||||
revenue = bgP * (1 - takerFees[ExBitget]/100)
|
||||
netHL := (revenue/cost-1)*100 - 2*(takerFees[ExHyperLiquid]+takerFees[ExBitget])
|
||||
|
||||
entry["net_bg_to_hl"] = math.Round(netBG*10000) / 10000
|
||||
entry["net_hl_to_bg"] = math.Round(netHL*10000) / 10000
|
||||
}
|
||||
|
||||
prices = append(prices, entry)
|
||||
}
|
||||
d.hub.Broadcast("prices", prices)
|
||||
|
||||
// 2. Open positions with live PnL (P3-3) — read from decoupled snapshot, never blocks trader
|
||||
positions := d.trader.ReadSnapshot()
|
||||
posList := make([]map[string]interface{}, 0, len(positions))
|
||||
for _, pos := range positions {
|
||||
posEntry := map[string]interface{}{
|
||||
"coin": pos.Coin,
|
||||
"direction": pos.Direction,
|
||||
"amount_usd": pos.AmountUSD,
|
||||
"entry_spread": pos.EntrySpread,
|
||||
"scales": pos.ScaleLevels,
|
||||
"duration": time.Since(pos.StartedAt).Round(time.Second).String(),
|
||||
"started_at": pos.StartedAt.Format("15:04:05"),
|
||||
"started_ts": pos.StartedAt.UnixMilli(),
|
||||
"long_exchange": pos.LongLeg.Exchange,
|
||||
"short_exchange": pos.ShortLeg.Exchange,
|
||||
"long_entry": pos.LongLeg.EntryPrice,
|
||||
"short_entry": pos.ShortLeg.EntryPrice,
|
||||
"db_trade_id": pos.DBTradeID,
|
||||
}
|
||||
|
||||
// Calculate live PnL from current prices — use weighted average for scale-ins
|
||||
if exMap := snap[pos.Coin]; exMap != nil {
|
||||
bgP := exMap[ExBitget]
|
||||
hlP := exMap[ExHyperLiquid]
|
||||
if bgP > 0 && hlP > 0 {
|
||||
var longCurrent, shortCurrent float64
|
||||
if pos.LongLeg.Exchange == ExBitget {
|
||||
longCurrent, shortCurrent = bgP, hlP
|
||||
} else {
|
||||
longCurrent, shortCurrent = hlP, bgP
|
||||
}
|
||||
longAvg := weightedAvgPrice(pos.LongEntryPrices, pos.AmountUSD/float64(max(1, len(pos.LongEntryPrices))))
|
||||
shortAvg := weightedAvgPrice(pos.ShortEntryPrices, pos.AmountUSD/float64(max(1, len(pos.ShortEntryPrices))))
|
||||
longPnl := (longCurrent - longAvg) / longAvg * 100
|
||||
shortPnl := (shortAvg - shortCurrent) / shortAvg * 100
|
||||
feeEntryUSD := float64(1+pos.ScaleLevels) * (pos.AmountUSD / float64(max(1, 1+pos.ScaleLevels))) * (takerFees[ExBitget] + takerFees[ExHyperLiquid]) / 100
|
||||
feeExitUSD := pos.AmountUSD * (takerFees[ExBitget] + takerFees[ExHyperLiquid]) / 100
|
||||
pricePnLUSD := pos.AmountUSD * (longPnl + shortPnl) / 100
|
||||
netPnLUSD := pricePnLUSD - feeEntryUSD - feeExitUSD
|
||||
|
||||
currentSpread := (hlP - bgP) / bgP * 100
|
||||
if pos.LongLeg.Exchange == ExHyperLiquid {
|
||||
// HL→BG: spread positive when bgP > hlP
|
||||
currentSpread = (bgP - hlP) / hlP * 100
|
||||
}
|
||||
posEntry["current_spread"] = math.Round(currentSpread*10000) / 10000
|
||||
posEntry["pnl_est"] = math.Round(netPnLUSD*10000) / 10000
|
||||
}
|
||||
}
|
||||
|
||||
posList = append(posList, posEntry)
|
||||
}
|
||||
d.hub.Broadcast("positions", posList)
|
||||
|
||||
// 3. Arb scan results
|
||||
// 2. 3-exchange scan results
|
||||
d.mu.RLock()
|
||||
scanCopy := d.lastScan
|
||||
d.mu.RUnlock()
|
||||
|
||||
if len(scanCopy) > 0 {
|
||||
scanList := make([]map[string]interface{}, 0, len(scanCopy))
|
||||
for _, opp := range scanCopy {
|
||||
for _, s := range scanCopy {
|
||||
scanList = append(scanList, map[string]interface{}{
|
||||
"coin": opp.Coin,
|
||||
"direction": opp.Direction,
|
||||
"buy_ex": opp.BuyEx,
|
||||
"sell_ex": opp.SellEx,
|
||||
"buy_price": opp.BuyPrice,
|
||||
"sell_price": opp.SellPrice,
|
||||
"net_profit": opp.NetProfit,
|
||||
"gross": opp.GrossBasis,
|
||||
"coin": s.Coin,
|
||||
"spread_pct": s.SpreadPct,
|
||||
"bn_price": s.BnPrice,
|
||||
"okx_price": s.OkxPrice,
|
||||
"bg_price": s.BgPrice,
|
||||
"max_ex": s.MaxEx,
|
||||
"min_ex": s.MinEx,
|
||||
})
|
||||
}
|
||||
d.hub.Broadcast("arb", scanList)
|
||||
d.hub.Broadcast("spread_3ex", scanList)
|
||||
}
|
||||
|
||||
// 4. Stats + connection status (P3-5)
|
||||
converged, diverged, flat, total := d.trader.GetClosedStats()
|
||||
detail := calcDetailedStats(d.trader.GetClosedTrades(), d.trader.cfg.InitialCapital)
|
||||
stats := map[string]interface{}{
|
||||
"total_trades": total,
|
||||
"converged": converged,
|
||||
"diverged": diverged,
|
||||
"flat": flat,
|
||||
"open_positions": len(positions),
|
||||
"coins": len(prices),
|
||||
"capital": d.trader.cfg.InitialCapital,
|
||||
|
||||
// Detailed PnL & duration stats (session only)
|
||||
"detail": map[string]interface{}{
|
||||
"total_pnl_usd": math.Round(detail.TotalPnlUSD*100) / 100,
|
||||
"capital_pnl": math.Round(detail.CapitalPnlPct*10000) / 10000,
|
||||
"avg_pnl": detail.AvgPnlPct,
|
||||
"max_profit": detail.MaxProfitPct,
|
||||
"max_loss": detail.MaxLossPct,
|
||||
"avg_dur": detail.AvgDuration,
|
||||
"win_rate": detail.WinRate,
|
||||
"wins": detail.WinningTrades,
|
||||
"losses": detail.LosingTrades,
|
||||
"total_dur": detail.TotalDuration,
|
||||
},
|
||||
}
|
||||
|
||||
// Connection status
|
||||
// 3. Connection status
|
||||
d.connMu.RLock()
|
||||
connInfo := make(map[string]string)
|
||||
for ex, lastTime := range d.connMap {
|
||||
@@ -540,51 +386,14 @@ func (d *Dashboard) broadcastLoop() {
|
||||
}
|
||||
}
|
||||
d.connMu.RUnlock()
|
||||
stats["connections"] = connInfo
|
||||
|
||||
// Trading status
|
||||
stats["trading"] = map[string]interface{}{
|
||||
"active": !d.trader.IsShuttingDown(),
|
||||
"mode": d.trader.ModeLabel(),
|
||||
"test": d.trader.cfg.TestMode,
|
||||
"target": d.trader.realTradesTarget,
|
||||
"done": d.trader.realTradesDone,
|
||||
status := map[string]interface{}{
|
||||
"coins": len(prices),
|
||||
"connections": connInfo,
|
||||
}
|
||||
d.hub.Broadcast("status", status)
|
||||
|
||||
// Per-exchange fund tracking
|
||||
exFunds := d.trader.GetExchangeFunds()
|
||||
exFundsMap := make(map[string]map[string]float64, len(exFunds))
|
||||
for ex, ef := range exFunds {
|
||||
exFundsMap[ex] = map[string]float64{
|
||||
"balance": math.Round(ef.Balance*100) / 100,
|
||||
"total_fee": math.Round(ef.TotalFee*100) / 100,
|
||||
"total_pnl": math.Round(ef.TotalPnl*100) / 100,
|
||||
}
|
||||
}
|
||||
stats["exchange_funds"] = exFundsMap
|
||||
|
||||
// Blacklist — stale spread coins
|
||||
bl := d.trader.GetBlacklist()
|
||||
blList := make([]map[string]interface{}, 0, len(bl))
|
||||
for coin, t := range bl {
|
||||
if d.trader.cfg.BlacklistDuration > 0 && time.Since(t) >= d.trader.cfg.BlacklistDuration {
|
||||
continue // expired, will be cleaned up on next check
|
||||
}
|
||||
remaining := time.Duration(0)
|
||||
if d.trader.cfg.BlacklistDuration > 0 {
|
||||
remaining = d.trader.cfg.BlacklistDuration - time.Since(t)
|
||||
}
|
||||
blList = append(blList, map[string]interface{}{
|
||||
"coin": coin,
|
||||
"since": t.Format("15:04:05"),
|
||||
"remaining_sec": int(remaining.Seconds()),
|
||||
})
|
||||
}
|
||||
stats["blacklist"] = blList
|
||||
|
||||
d.hub.Broadcast("stats", stats)
|
||||
|
||||
// 5. Momentum data (if enabled and tracker is available)
|
||||
// 4. Momentum data (if enabled)
|
||||
if d.momentumTracker != nil && d.cfg.MomentumEnabled {
|
||||
momentumData := d.momentumTracker.Snapshot(d.cfg.MomentumThresholdPct)
|
||||
if len(momentumData) > 0 {
|
||||
@@ -592,7 +401,7 @@ func (d *Dashboard) broadcastLoop() {
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Trend detection (if enabled)
|
||||
// 5. Trend detection (if enabled)
|
||||
if d.trendDetector != nil && d.cfg.TrendEnabled {
|
||||
d.trendDetector.Tick()
|
||||
trendData := d.trendDetector.Snapshot()
|
||||
@@ -601,7 +410,7 @@ func (d *Dashboard) broadcastLoop() {
|
||||
}
|
||||
}
|
||||
|
||||
// 7. Cumulative change tracking (always on if tracker exists)
|
||||
// 6. Cumulative change tracking
|
||||
if d.cumulativeTracker != nil {
|
||||
d.cumulativeTracker.Tick()
|
||||
cmData := d.cumulativeTracker.GetTopCoins(30)
|
||||
@@ -610,7 +419,7 @@ func (d *Dashboard) broadcastLoop() {
|
||||
}
|
||||
}
|
||||
|
||||
// 8. Trend filter (K-line based quiet + EMA)
|
||||
// 7. Trend filter (K-line based quiet + EMA)
|
||||
if d.trendFilter != nil {
|
||||
d.trendFilter.Tick()
|
||||
filterData := d.trendFilter.Snapshot(0)
|
||||
@@ -618,16 +427,24 @@ func (d *Dashboard) broadcastLoop() {
|
||||
d.hub.Broadcast("trend_filter", filterData)
|
||||
}
|
||||
}
|
||||
|
||||
// 8. Surge status (current spread/baseline for all coins)
|
||||
if d.surgeDetector != nil && d.cfg.SurgeEnabled {
|
||||
surgeSnap := d.surgeDetector.Snapshot()
|
||||
if len(surgeSnap) > 0 {
|
||||
d.hub.Broadcast("surge", surgeSnap)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Public methods called from main.go / trader
|
||||
// Public methods called from main.go
|
||||
// ============================================================
|
||||
|
||||
func (d *Dashboard) UpdateScan(opps []*ArbOpportunity) {
|
||||
func (d *Dashboard) UpdateScan(spreads []ThreeExSpread) {
|
||||
d.mu.Lock()
|
||||
d.lastScan = opps
|
||||
d.lastScan = spreads
|
||||
d.scanTime = time.Now()
|
||||
d.mu.Unlock()
|
||||
}
|
||||
@@ -636,14 +453,14 @@ func (d *Dashboard) RecordPrice(coin, exchange string, price float64) {
|
||||
d.history.Record(coin, exchange, price)
|
||||
}
|
||||
|
||||
// RecordConnStatus updates the last-seen time for an exchange (P3-5).
|
||||
// RecordConnStatus updates the last-seen time for an exchange.
|
||||
func (d *Dashboard) RecordConnStatus(exchange string) {
|
||||
d.connMu.Lock()
|
||||
d.connMap[exchange] = time.Now()
|
||||
d.connMu.Unlock()
|
||||
}
|
||||
|
||||
// BroadcastEvent sends an immediate SSE event (P3-4).
|
||||
// BroadcastEvent sends an immediate SSE event.
|
||||
func (d *Dashboard) BroadcastEvent(event string, data interface{}) {
|
||||
d.hub.Broadcast(event, data)
|
||||
}
|
||||
@@ -659,7 +476,6 @@ func (d *Dashboard) handleIndex(w http.ResponseWriter, r *http.Request) {
|
||||
// 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 {
|
||||
@@ -672,25 +488,9 @@ func (d *Dashboard) handleIndex(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (d *Dashboard) handleStatus(w http.ResponseWriter, r *http.Request) {
|
||||
snap := d.store.GetAll()
|
||||
positions := d.trader.ReadSnapshot()
|
||||
converged, diverged, flat, total := d.trader.GetClosedStats()
|
||||
|
||||
// Format exchange funds (snake_case, like SSE)
|
||||
exFunds := d.trader.GetExchangeFunds()
|
||||
exFundsMap := make(map[string]map[string]float64, len(exFunds))
|
||||
for ex, ef := range exFunds {
|
||||
exFundsMap[ex] = map[string]float64{
|
||||
"balance": math.Round(ef.Balance*100) / 100,
|
||||
"total_fee": math.Round(ef.TotalFee*100) / 100,
|
||||
"total_pnl": math.Round(ef.TotalPnl*100) / 100,
|
||||
}
|
||||
}
|
||||
|
||||
resp := map[string]interface{}{
|
||||
"prices": snap,
|
||||
"positions": len(positions),
|
||||
"stats": map[string]int{"total": total, "converged": converged, "diverged": diverged, "flat": flat},
|
||||
"exchange_funds": exFundsMap,
|
||||
"coins": len(snap),
|
||||
}
|
||||
writeJSON(w, resp)
|
||||
}
|
||||
@@ -704,7 +504,7 @@ func (d *Dashboard) handleHistory(w http.ResponseWriter, r *http.Request) {
|
||||
for c := range snap {
|
||||
coins = append(coins, c)
|
||||
}
|
||||
writeJSON(w, map[string]interface{}{"coins": coins, "exchanges": []string{"Binance", "HyperLiquid", "Bitget", "dYdX"}})
|
||||
writeJSON(w, map[string]interface{}{"coins": coins, "exchanges": []string{ExBinance, ExOKX, ExBitget}})
|
||||
return
|
||||
}
|
||||
points := d.history.GetHistory(coin, exchange, 300)
|
||||
@@ -715,7 +515,7 @@ func (d *Dashboard) handleHistory(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
// handleSpreadHistory returns BG↔HL spread history for a coin (P3-2).
|
||||
// handleSpreadHistory returns 3-exchange max spread history for a coin.
|
||||
func (d *Dashboard) handleSpreadHistory(w http.ResponseWriter, r *http.Request) {
|
||||
coin := r.URL.Query().Get("coin")
|
||||
if coin == "" {
|
||||
@@ -729,7 +529,7 @@ func (d *Dashboard) handleSpreadHistory(w http.ResponseWriter, r *http.Request)
|
||||
})
|
||||
}
|
||||
|
||||
// handleConnStatus returns connection health for all exchanges (P3-5).
|
||||
// handleConnStatus returns connection health for all exchanges.
|
||||
func (d *Dashboard) handleConnStatus(w http.ResponseWriter, r *http.Request) {
|
||||
d.connMu.RLock()
|
||||
conns := make(map[string]string)
|
||||
@@ -757,7 +557,6 @@ func (d *Dashboard) handleTrendHistory(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
if events == nil {
|
||||
// Fallback to in-memory ring buffer
|
||||
if d.trendDetector != nil {
|
||||
events = d.trendDetector.GetEvents(200)
|
||||
} else {
|
||||
@@ -796,51 +595,25 @@ func (d *Dashboard) handleTrendSignals(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, map[string]interface{}{"signals": signals})
|
||||
}
|
||||
|
||||
func (d *Dashboard) handleTrades(w http.ResponseWriter, r *http.Request) {
|
||||
if d.db == nil {
|
||||
writeJSON(w, map[string]interface{}{"trades": []interface{}{}, "total": 0})
|
||||
func (d *Dashboard) handleSurgeEvents(w http.ResponseWriter, r *http.Request) {
|
||||
limit := 100
|
||||
|
||||
// Try DB first
|
||||
if d.db != nil {
|
||||
events, err := d.db.GetSurgeEvents(limit)
|
||||
if err == nil {
|
||||
writeJSON(w, map[string]interface{}{"events": events, "total": len(events)})
|
||||
return
|
||||
}
|
||||
page := 1
|
||||
limit := 20
|
||||
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)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), 500)
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"trades": trades,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"limit": limit,
|
||||
})
|
||||
}
|
||||
|
||||
func (d *Dashboard) handleTradeDetail(w http.ResponseWriter, r *http.Request) {
|
||||
if d.db == nil {
|
||||
http.Error(w, "DB not available", 503)
|
||||
return
|
||||
// Fallback to in-memory
|
||||
if d.surgeDetector != nil {
|
||||
events := d.surgeDetector.GetRecentEvents(limit)
|
||||
writeJSON(w, map[string]interface{}{"events": events, "total": len(events)})
|
||||
} else {
|
||||
writeJSON(w, map[string]interface{}{"events": []interface{}{}, "total": 0})
|
||||
}
|
||||
var id int64
|
||||
if _, err := fmt.Sscanf(r.URL.Path, "/api/trade/%d", &id); err != nil {
|
||||
http.Error(w, "Invalid trade ID", 400)
|
||||
return
|
||||
}
|
||||
trade, orders, err := d.db.GetTradeByID(id)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), 404)
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"trade": trade,
|
||||
"orders": orders,
|
||||
})
|
||||
}
|
||||
|
||||
func (d *Dashboard) handleSSE(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -875,16 +648,6 @@ func (d *Dashboard) handleSSE(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
func (d *Dashboard) handleStop(w http.ResponseWriter, r *http.Request) {
|
||||
d.trader.Stop()
|
||||
writeJSON(w, map[string]string{"status": "stopped", "message": "Trading stopped, positions closing"})
|
||||
}
|
||||
|
||||
func (d *Dashboard) handleStart(w http.ResponseWriter, r *http.Request) {
|
||||
d.trader.Start()
|
||||
writeJSON(w, map[string]string{"status": "started", "message": "Trading resumed"})
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, v interface{}) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(v)
|
||||
|
||||
@@ -168,6 +168,25 @@ func (d *DB) migrate() error {
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_trend_signals_coin ON trend_signals(coin);
|
||||
CREATE INDEX IF NOT EXISTS idx_trend_signals_created ON trend_signals(created_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS surge_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
coin TEXT NOT NULL,
|
||||
timestamp DATETIME NOT NULL,
|
||||
bn_price REAL,
|
||||
okx_price REAL,
|
||||
bg_price REAL,
|
||||
spread_pct REAL NOT NULL,
|
||||
baseline_pct REAL,
|
||||
threshold_pct REAL,
|
||||
ratio REAL,
|
||||
direction TEXT NOT NULL,
|
||||
leading_exchange TEXT NOT NULL,
|
||||
mid_price REAL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_surge_events_coin ON surge_events(coin);
|
||||
CREATE INDEX IF NOT EXISTS idx_surge_events_created ON surge_events(created_at);
|
||||
`
|
||||
_, err := d.Exec(schema)
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
package db
|
||||
|
||||
import "time"
|
||||
|
||||
// SurgeEventRecord represents a persisted surge detection event.
|
||||
type SurgeEventRecord struct {
|
||||
ID int64 `json:"id"`
|
||||
Coin string `json:"coin"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
BnPrice float64 `json:"bn_price"`
|
||||
OkxPrice float64 `json:"okx_price"`
|
||||
BgPrice float64 `json:"bg_price"`
|
||||
SpreadPct float64 `json:"spread_pct"`
|
||||
BaselinePct float64 `json:"baseline_pct"`
|
||||
ThresholdPct float64 `json:"threshold_pct"`
|
||||
Ratio float64 `json:"ratio"`
|
||||
Direction string `json:"direction"`
|
||||
LeadingExchange string `json:"leading_exchange"`
|
||||
MidPrice float64 `json:"mid_price"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
// InsertSurgeEvent saves a surge event to the database.
|
||||
func (d *DB) InsertSurgeEvent(coin string, ts time.Time, bnPrice, okxPrice, bgPrice, spreadPct, baselinePct, thresholdPct, ratio float64, direction, leadingExchange string, midPrice float64) error {
|
||||
_, err := d.Exec(`
|
||||
INSERT INTO surge_events (coin, timestamp, bn_price, okx_price, bg_price, spread_pct, baseline_pct, threshold_pct, ratio, direction, leading_exchange, mid_price, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
coin, ts.Format(time.RFC3339), bnPrice, okxPrice, bgPrice, spreadPct, baselinePct, thresholdPct, ratio, direction, leadingExchange, midPrice, Now().Format(time.RFC3339))
|
||||
return err
|
||||
}
|
||||
|
||||
// GetSurgeEvents returns surge events ordered by creation time descending.
|
||||
func (d *DB) GetSurgeEvents(limit int) ([]SurgeEventRecord, error) {
|
||||
if limit <= 0 {
|
||||
limit = 100
|
||||
}
|
||||
rows, err := d.Query(`
|
||||
SELECT id, coin, timestamp, bn_price, okx_price, bg_price, spread_pct, baseline_pct, threshold_pct, ratio, direction, leading_exchange, mid_price, created_at
|
||||
FROM surge_events
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ?`, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var result []SurgeEventRecord
|
||||
for rows.Next() {
|
||||
var r SurgeEventRecord
|
||||
if err := rows.Scan(&r.ID, &r.Coin, &r.Timestamp, &r.BnPrice, &r.OkxPrice, &r.BgPrice,
|
||||
&r.SpreadPct, &r.BaselinePct, &r.ThresholdPct, &r.Ratio, &r.Direction,
|
||||
&r.LeadingExchange, &r.MidPrice, &r.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, r)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
@@ -1,325 +0,0 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TradeRecord mirrors the database row for trades table.
|
||||
type TradeRecord struct {
|
||||
ID int64
|
||||
Coin string
|
||||
Direction string
|
||||
Status string // open / closed
|
||||
EntrySpread *float64
|
||||
ExitSpread *float64
|
||||
LongExchange string
|
||||
ShortExchange string
|
||||
LongEntry *float64
|
||||
LongExit *float64
|
||||
ShortEntry *float64
|
||||
ShortExit *float64
|
||||
LongPnl *float64
|
||||
ShortPnl *float64
|
||||
FeeEntry *float64
|
||||
FeeExit *float64
|
||||
NetPnl *float64
|
||||
AmountUSD float64
|
||||
ScaleCount int
|
||||
ExitReason *string
|
||||
Convergence *string
|
||||
OpenedAt time.Time
|
||||
ClosedAt *time.Time
|
||||
PnlLongUSD *float64 // per-exchange PnL in USD
|
||||
PnlShortUSD *float64
|
||||
FeeLongUSD *float64 // per-exchange fee in USD
|
||||
FeeShortUSD *float64
|
||||
}
|
||||
|
||||
// OrderRecord mirrors the database row for orders table.
|
||||
type OrderRecord struct {
|
||||
ID int64
|
||||
TradeID int64
|
||||
Leg string // long / short
|
||||
Type string // entry / exit / scale
|
||||
Exchange string
|
||||
Side string // buy / sell
|
||||
Price *float64
|
||||
Size *float64
|
||||
Fee *float64
|
||||
OrderID *string
|
||||
Status *string
|
||||
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.
|
||||
func (d *DB) SaveTrade(t *TradeRecord) (int64, error) {
|
||||
res, err := d.Exec(`INSERT INTO trades (
|
||||
coin, direction, status, entry_spread, exit_spread,
|
||||
long_exchange, short_exchange, long_entry, long_exit, short_entry, short_exit,
|
||||
long_pnl, short_pnl, fee_entry, fee_exit, net_pnl,
|
||||
amount_usd, scale_count, exit_reason, convergence, opened_at, closed_at,
|
||||
pnl_long_usd, pnl_short_usd, fee_long_usd, fee_short_usd
|
||||
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
t.Coin, t.Direction, t.Status, t.EntrySpread, t.ExitSpread,
|
||||
t.LongExchange, t.ShortExchange, t.LongEntry, t.LongExit, t.ShortEntry, t.ShortExit,
|
||||
t.LongPnl, t.ShortPnl, t.FeeEntry, t.FeeExit, t.NetPnl,
|
||||
t.AmountUSD, t.ScaleCount, t.ExitReason, t.Convergence, t.OpenedAt, t.ClosedAt,
|
||||
t.PnlLongUSD, t.PnlShortUSD, t.FeeLongUSD, t.FeeShortUSD,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.LastInsertId()
|
||||
}
|
||||
|
||||
// UpdateTradeStatus updates an existing trade's close data.
|
||||
func (d *DB) UpdateTradeStatus(id int64, t *TradeRecord) error {
|
||||
_, err := d.Exec(`UPDATE trades SET
|
||||
status=?, exit_spread=?, long_exit=?, short_exit=?,
|
||||
long_pnl=?, short_pnl=?, fee_entry=?, fee_exit=?, net_pnl=?,
|
||||
amount_usd=?, scale_count=?, exit_reason=?, convergence=?, closed_at=?,
|
||||
pnl_long_usd=?, pnl_short_usd=?, fee_long_usd=?, fee_short_usd=?
|
||||
WHERE id=?`,
|
||||
t.Status, t.ExitSpread,
|
||||
t.LongExit, t.ShortExit,
|
||||
t.LongPnl, t.ShortPnl, t.FeeEntry, t.FeeExit, t.NetPnl,
|
||||
t.AmountUSD, t.ScaleCount, t.ExitReason, t.Convergence, t.ClosedAt,
|
||||
t.PnlLongUSD, t.PnlShortUSD, t.FeeLongUSD, t.FeeShortUSD,
|
||||
id,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// SetTradeStatus updates only the status field of a trade.
|
||||
func (d *DB) SetTradeStatus(id int64, status string) error {
|
||||
_, err := d.Exec("UPDATE trades SET status=? WHERE id=?", status, id)
|
||||
return err
|
||||
}
|
||||
|
||||
// UpdateTradeEntry updates entry-related fields on an existing trade (prices, exchanges, spread).
|
||||
func (d *DB) UpdateTradeEntry(id int64, t *TradeRecord) error {
|
||||
_, err := d.Exec(`UPDATE trades SET long_entry=?, short_entry=?, long_exchange=?, short_exchange=?, entry_spread=? WHERE id=?`,
|
||||
t.LongEntry, t.ShortEntry, t.LongExchange, t.ShortExchange, t.EntrySpread, id)
|
||||
return err
|
||||
}
|
||||
|
||||
// UpdateTradeScale updates scale-in fields on an existing trade (amount_usd, scale_count).
|
||||
func (d *DB) UpdateTradeScale(id int64, amountUSD float64, scaleCount int) error {
|
||||
_, err := d.Exec("UPDATE trades SET amount_usd=?, scale_count=? WHERE id=?", amountUSD, scaleCount, id)
|
||||
return err
|
||||
}
|
||||
|
||||
// GetOpenTrades returns all non-closed trades (status='open' or status='entering').
|
||||
func (d *DB) GetOpenTrades() ([]TradeRecord, error) {
|
||||
rows, err := d.Query(`SELECT id, coin, direction, status, entry_spread, exit_spread,
|
||||
long_exchange, short_exchange, long_entry, long_exit, short_entry, short_exit,
|
||||
long_pnl, short_pnl, fee_entry, fee_exit, net_pnl,
|
||||
amount_usd, scale_count, exit_reason, convergence, opened_at, closed_at,
|
||||
pnl_long_usd, pnl_short_usd, fee_long_usd, fee_short_usd
|
||||
FROM trades WHERE status IN ('open','entering')`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanTrades(rows)
|
||||
}
|
||||
|
||||
// GetTrades returns paginated closed trades.
|
||||
func (d *DB) GetTrades(page, limit int, coin string) ([]TradeRecord, int, error) {
|
||||
// Count total
|
||||
var total int
|
||||
countSQL := "SELECT COUNT(*) FROM trades WHERE status='closed'"
|
||||
args := []interface{}{}
|
||||
if coin != "" {
|
||||
countSQL += " AND coin=?"
|
||||
args = append(args, coin)
|
||||
}
|
||||
if err := d.QueryRow(countSQL, args...).Scan(&total); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
// Fetch page
|
||||
offset := (page - 1) * limit
|
||||
query := `SELECT id, coin, direction, status, entry_spread, exit_spread,
|
||||
long_exchange, short_exchange, long_entry, long_exit, short_entry, short_exit,
|
||||
long_pnl, short_pnl, fee_entry, fee_exit, net_pnl,
|
||||
amount_usd, scale_count, exit_reason, convergence, opened_at, closed_at,
|
||||
pnl_long_usd, pnl_short_usd, fee_long_usd, fee_short_usd
|
||||
FROM trades WHERE status='closed'`
|
||||
if coin != "" {
|
||||
query += " AND coin=?"
|
||||
}
|
||||
query += " ORDER BY closed_at DESC LIMIT ? OFFSET ?"
|
||||
|
||||
allArgs := args
|
||||
allArgs = append(allArgs, limit, offset)
|
||||
|
||||
rows, err := d.Query(query, allArgs...)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
trades, err := scanTrades(rows)
|
||||
return trades, total, err
|
||||
}
|
||||
|
||||
// SaveOrder inserts an order record.
|
||||
func (d *DB) SaveOrder(o *OrderRecord) (int64, error) {
|
||||
res, err := d.Exec(`INSERT INTO orders
|
||||
(trade_id, leg, type, exchange, side, price, size, fee, order_id, status, created_at)
|
||||
VALUES (?,?,?,?,?, ?,?,?,?,?, ?)`,
|
||||
o.TradeID, o.Leg, o.Type, o.Exchange, o.Side,
|
||||
o.Price, o.Size, o.Fee, o.OrderID, o.Status, o.CreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
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.
|
||||
func (d *DB) GetTradeByID(id int64) (*TradeRecord, []OrderRecord, error) {
|
||||
row := d.QueryRow(`SELECT id, coin, direction, status, entry_spread, exit_spread,
|
||||
long_exchange, short_exchange, long_entry, long_exit, short_entry, short_exit,
|
||||
long_pnl, short_pnl, fee_entry, fee_exit, net_pnl,
|
||||
amount_usd, scale_count, exit_reason, convergence, opened_at, closed_at,
|
||||
pnl_long_usd, pnl_short_usd, fee_long_usd, fee_short_usd
|
||||
FROM trades WHERE id=?`, id)
|
||||
|
||||
var t TradeRecord
|
||||
err := row.Scan(
|
||||
&t.ID, &t.Coin, &t.Direction, &t.Status, &t.EntrySpread, &t.ExitSpread,
|
||||
&t.LongExchange, &t.ShortExchange, &t.LongEntry, &t.LongExit, &t.ShortEntry, &t.ShortExit,
|
||||
&t.LongPnl, &t.ShortPnl, &t.FeeEntry, &t.FeeExit, &t.NetPnl,
|
||||
&t.AmountUSD, &t.ScaleCount, &t.ExitReason, &t.Convergence, &t.OpenedAt, &t.ClosedAt,
|
||||
&t.PnlLongUSD, &t.PnlShortUSD, &t.FeeLongUSD, &t.FeeShortUSD,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
// Fetch orders
|
||||
oRows, err := d.Query(`SELECT id, trade_id, leg, type, exchange, side,
|
||||
price, size, fee, order_id, status, created_at
|
||||
FROM orders WHERE trade_id=? ORDER BY id`, id)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
defer oRows.Close()
|
||||
|
||||
var orders []OrderRecord
|
||||
for oRows.Next() {
|
||||
var o OrderRecord
|
||||
if err := oRows.Scan(&o.ID, &o.TradeID, &o.Leg, &o.Type, &o.Exchange, &o.Side,
|
||||
&o.Price, &o.Size, &o.Fee, &o.OrderID, &o.Status, &o.CreatedAt); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
orders = append(orders, o)
|
||||
}
|
||||
return &t, orders, nil
|
||||
}
|
||||
|
||||
func scanTrades(rows *sql.Rows) ([]TradeRecord, error) {
|
||||
var trades []TradeRecord
|
||||
for rows.Next() {
|
||||
var t TradeRecord
|
||||
if err := rows.Scan(
|
||||
&t.ID, &t.Coin, &t.Direction, &t.Status, &t.EntrySpread, &t.ExitSpread,
|
||||
&t.LongExchange, &t.ShortExchange, &t.LongEntry, &t.LongExit, &t.ShortEntry, &t.ShortExit,
|
||||
&t.LongPnl, &t.ShortPnl, &t.FeeEntry, &t.FeeExit, &t.NetPnl,
|
||||
&t.AmountUSD, &t.ScaleCount, &t.ExitReason, &t.Convergence, &t.OpenedAt, &t.ClosedAt,
|
||||
&t.PnlLongUSD, &t.PnlShortUSD, &t.FeeLongUSD, &t.FeeShortUSD,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
trades = append(trades, t)
|
||||
}
|
||||
return trades, rows.Err()
|
||||
}
|
||||
|
||||
// GetScalePrices returns scale-in order prices for a trade, grouped by leg.
|
||||
func (d *DB) GetScalePrices(tradeID int64) (longPrices, shortPrices []float64, err error) {
|
||||
rows, err := d.Query(`SELECT leg, price FROM orders
|
||||
WHERE trade_id=? AND type='scale' AND price IS NOT NULL
|
||||
ORDER BY id`, tradeID)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var leg string
|
||||
var price float64
|
||||
if err := rows.Scan(&leg, &price); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
switch leg {
|
||||
case "long":
|
||||
longPrices = append(longPrices, price)
|
||||
case "short":
|
||||
shortPrices = append(shortPrices, price)
|
||||
}
|
||||
}
|
||||
return longPrices, shortPrices, rows.Err()
|
||||
}
|
||||
|
||||
// GetAllClosedTrades returns all closed trades for PnL history restoration.
|
||||
func (d *DB) GetAllClosedTrades() ([]TradeRecord, error) {
|
||||
rows, err := d.Query(`SELECT id, coin, direction, status, entry_spread, exit_spread,
|
||||
long_exchange, short_exchange, long_entry, long_exit, short_entry, short_exit,
|
||||
long_pnl, short_pnl, fee_entry, fee_exit, net_pnl,
|
||||
amount_usd, scale_count, exit_reason, convergence, opened_at, closed_at,
|
||||
pnl_long_usd, pnl_short_usd, fee_long_usd, fee_short_usd
|
||||
FROM trades WHERE status='closed' ORDER BY id`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanTrades(rows)
|
||||
}
|
||||
|
||||
// GetClosedStats returns convergence counts from the database.
|
||||
func (d *DB) GetClosedStats() (converged, diverged, flat, total int, err error) {
|
||||
if err = d.QueryRow("SELECT COUNT(*) FROM trades WHERE status='closed'").Scan(&total); err != nil {
|
||||
return
|
||||
}
|
||||
if err = d.QueryRow("SELECT COUNT(*) FROM trades WHERE status='closed' AND convergence='价差收敛'").Scan(&converged); err != nil {
|
||||
return
|
||||
}
|
||||
if err = d.QueryRow("SELECT COUNT(*) FROM trades WHERE status='closed' AND convergence='价差发散'").Scan(&diverged); err != nil {
|
||||
return
|
||||
}
|
||||
if err = d.QueryRow("SELECT COUNT(*) FROM trades WHERE status='closed' AND (convergence IS NULL OR convergence NOT IN ('价差收敛','价差发散'))").Scan(&flat); err != nil {
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -1,279 +0,0 @@
|
||||
package exchange
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type BitgetTrade struct {
|
||||
APIKey string
|
||||
APISecret string
|
||||
Passphrase string
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func NewBitgetTrade(apiKey, apiSecret, passphrase string) *BitgetTrade {
|
||||
return &BitgetTrade{
|
||||
APIKey: apiKey,
|
||||
APISecret: apiSecret,
|
||||
Passphrase: passphrase,
|
||||
client: &http.Client{Timeout: 10 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
func (b *BitgetTrade) PlaceMarketOrder(side, symbol, size, tradeSide, holdSide string) (string, error) {
|
||||
ts := fmt.Sprintf("%d", time.Now().UnixMilli())
|
||||
method := "POST"
|
||||
|
||||
requestPath := "/api/v2/mix/order/place-order"
|
||||
host := "https://api.bitget.com"
|
||||
|
||||
body := map[string]interface{}{
|
||||
"marginCoin": "USDT",
|
||||
"symbol": symbol,
|
||||
"productType": "USDT-FUTURES",
|
||||
"side": side,
|
||||
"orderType": "market",
|
||||
"timeInForce": "IOC",
|
||||
"marginMode": "crossed",
|
||||
"tradeSide": tradeSide,
|
||||
"size": size,
|
||||
}
|
||||
// Close orders require holdSide to identify which position to close
|
||||
if tradeSide == "close" && holdSide != "" {
|
||||
body["holdSide"] = holdSide
|
||||
}
|
||||
bodyJSON, _ := json.Marshal(body)
|
||||
|
||||
sign := b.sign(method, requestPath, ts, string(bodyJSON))
|
||||
url := host + requestPath
|
||||
req, _ := http.NewRequest(method, url, strings.NewReader(string(bodyJSON)))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("ACCESS-KEY", b.APIKey)
|
||||
req.Header.Set("ACCESS-SIGN", sign)
|
||||
req.Header.Set("ACCESS-TIMESTAMP", ts)
|
||||
req.Header.Set("ACCESS-PASSPHRASE", b.Passphrase)
|
||||
|
||||
resp, err := b.client.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("http request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
|
||||
var result struct {
|
||||
Code string `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
Data struct {
|
||||
OrderID string `json:"orderId"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(respBody, &result); err != nil {
|
||||
return "", fmt.Errorf("parse: %s", string(respBody))
|
||||
}
|
||||
if result.Code != "00000" {
|
||||
return "", fmt.Errorf("bitget error: %s - %s", result.Code, result.Msg)
|
||||
}
|
||||
return result.Data.OrderID, nil
|
||||
}
|
||||
// GetTradeFee queries the fills endpoint for actual fee charged and average fill price.
|
||||
// Retries up to 5 times with 500ms intervals because Bitget's fills API may lag.
|
||||
// Returns (average fill price, fee in USD, error). avgPrice=0 on any fills issue.
|
||||
func (b *BitgetTrade) GetTradeFee(symbol, orderID string) (avgPrice, feeUSD float64, err error) {
|
||||
for i := 0; i < 5; i++ {
|
||||
if i > 0 {
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
}
|
||||
ts := fmt.Sprintf("%d", time.Now().UnixMilli())
|
||||
method := "GET"
|
||||
requestPath := "/api/v2/mix/order/fills?symbol=" + symbol + "&orderId=" + orderID + "&productType=USDT-FUTURES"
|
||||
host := "https://api.bitget.com"
|
||||
|
||||
sign := b.sign(method, requestPath, ts, "")
|
||||
url := host + requestPath
|
||||
req, _ := http.NewRequest(method, url, nil)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("ACCESS-KEY", b.APIKey)
|
||||
req.Header.Set("ACCESS-SIGN", sign)
|
||||
req.Header.Set("ACCESS-TIMESTAMP", ts)
|
||||
req.Header.Set("ACCESS-PASSPHRASE", b.Passphrase)
|
||||
|
||||
resp, err := b.client.Do(req)
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("http: %w", err)
|
||||
}
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
|
||||
var raw struct {
|
||||
Code string `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
Data struct {
|
||||
FillList []json.RawMessage `json:"fillList"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(respBody, &raw); err != nil {
|
||||
return 0, 0, fmt.Errorf("parse: %s", string(respBody))
|
||||
}
|
||||
if raw.Code != "00000" {
|
||||
return 0, 0, fmt.Errorf("bitget error: %s - %s", raw.Code, raw.Msg)
|
||||
}
|
||||
|
||||
var totalFee, totalQty, totalCost float64
|
||||
for _, item := range raw.Data.FillList {
|
||||
var fill struct {
|
||||
FillPrice string `json:"fillPrice"`
|
||||
FillSize string `json:"fillBaseSize"`
|
||||
FillFee string `json:"fillFee"`
|
||||
}
|
||||
if err := json.Unmarshal(item, &fill); err != nil {
|
||||
continue
|
||||
}
|
||||
f, _ := strconv.ParseFloat(fill.FillFee, 64)
|
||||
p, _ := strconv.ParseFloat(fill.FillPrice, 64)
|
||||
q, _ := strconv.ParseFloat(fill.FillSize, 64)
|
||||
totalFee += math.Abs(f)
|
||||
totalCost += p * q
|
||||
totalQty += q
|
||||
}
|
||||
if totalQty > 0 {
|
||||
return totalCost / totalQty, totalFee, nil
|
||||
}
|
||||
}
|
||||
return 0, 0, fmt.Errorf("no fill data after 5 attempts")
|
||||
}
|
||||
|
||||
// CheckPosition returns the available position size for a coin, or 0 if no position.
|
||||
// Returns (total as float64, raw total string from API) — the raw string can be used
|
||||
// for close orders to ensure correct precision.
|
||||
func (b *BitgetTrade) CheckPosition(symbol string) (float64, string) {
|
||||
ts := fmt.Sprintf("%d", time.Now().UnixMilli())
|
||||
method := "GET"
|
||||
requestPath := "/api/v2/mix/position/single-position?symbol=" + symbol + "&productType=USDT-FUTURES&marginCoin=USDT"
|
||||
host := "https://api.bitget.com"
|
||||
sign := b.sign(method, requestPath, ts, "")
|
||||
url := host + requestPath
|
||||
req, _ := http.NewRequest(method, url, nil)
|
||||
req.Header.Set("ACCESS-KEY", b.APIKey)
|
||||
req.Header.Set("ACCESS-SIGN", sign)
|
||||
req.Header.Set("ACCESS-TIMESTAMP", ts)
|
||||
req.Header.Set("ACCESS-PASSPHRASE", b.Passphrase)
|
||||
|
||||
resp, err := b.client.Do(req)
|
||||
if err != nil {
|
||||
return 0, ""
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
|
||||
var raw struct {
|
||||
Code string `json:"code"`
|
||||
Data []struct {
|
||||
Total string `json:"total"`
|
||||
} `json:"data"`
|
||||
}
|
||||
json.Unmarshal(respBody, &raw)
|
||||
if raw.Code != "00000" || len(raw.Data) == 0 {
|
||||
return 0, ""
|
||||
}
|
||||
total, _ := strconv.ParseFloat(raw.Data[0].Total, 64)
|
||||
return total, raw.Data[0].Total
|
||||
}
|
||||
|
||||
func (b *BitgetTrade) sign(method, requestPath, timestamp, body string) string {
|
||||
raw := timestamp + method + requestPath + body
|
||||
mac := hmac.New(sha256.New, []byte(b.APISecret))
|
||||
mac.Write([]byte(raw))
|
||||
return base64.StdEncoding.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
|
||||
func (b *BitgetTrade) GetBalance() (float64, error) {
|
||||
ts := fmt.Sprintf("%d", time.Now().UnixMilli())
|
||||
method := "GET"
|
||||
host := "https://api.bitget.com"
|
||||
requestPath := "/api/v2/mix/account/accounts?productType=USDT-FUTURES"
|
||||
|
||||
sign := b.sign(method, requestPath, ts, "")
|
||||
url := host + requestPath
|
||||
req, _ := http.NewRequest(method, url, nil)
|
||||
req.Header.Set("ACCESS-KEY", b.APIKey)
|
||||
req.Header.Set("ACCESS-SIGN", sign)
|
||||
req.Header.Set("ACCESS-TIMESTAMP", ts)
|
||||
req.Header.Set("ACCESS-PASSPHRASE", b.Passphrase)
|
||||
|
||||
resp, err := b.client.Do(req)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("http: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
|
||||
var raw map[string]interface{}
|
||||
if err := json.Unmarshal(respBody, &raw); err != nil {
|
||||
return 0, fmt.Errorf("parse: %s", string(respBody))
|
||||
}
|
||||
code, _ := raw["code"].(string)
|
||||
if code != "00000" && code != "" {
|
||||
msg, _ := raw["msg"].(string)
|
||||
return 0, fmt.Errorf("bitget error: %s - %s", code, msg)
|
||||
}
|
||||
|
||||
dataRaw, ok := raw["data"]
|
||||
if !ok || dataRaw == nil {
|
||||
return 0, fmt.Errorf("no data in response")
|
||||
}
|
||||
dataArr, ok := dataRaw.([]interface{})
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("unexpected data format")
|
||||
}
|
||||
for _, item := range dataArr {
|
||||
acct, ok := item.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if acct["marginCoin"] == "USDT" {
|
||||
bal, _ := strconv.ParseFloat(fmt.Sprint(acct["available"]), 64)
|
||||
return bal, nil
|
||||
}
|
||||
}
|
||||
return 0, fmt.Errorf("no USDT account found")
|
||||
}
|
||||
|
||||
func GetBitgetSize(symbol string, amountUSD, price float64) string {
|
||||
if amountUSD < 5 {
|
||||
amountUSD = 5
|
||||
}
|
||||
sz := amountUSD / price
|
||||
switch symbol {
|
||||
case "DOGEUSDT":
|
||||
if sz < 1 { sz = 1 }
|
||||
return fmt.Sprintf("%.0f", math.Floor(sz))
|
||||
case "ONDOUSDT":
|
||||
sz = math.Floor(sz*10)/10
|
||||
if sz < 0.1 { sz = 0.1 }
|
||||
return fmt.Sprintf("%.1f", sz)
|
||||
case "OPUSDT":
|
||||
sz = math.Floor(sz*10)/10
|
||||
if sz < 0.1 { sz = 0.1 }
|
||||
return fmt.Sprintf("%.1f", sz)
|
||||
case "WIFUSDT":
|
||||
sz = math.Floor(sz*10)/10
|
||||
if sz < 0.1 { sz = 0.1 }
|
||||
return fmt.Sprintf("%.1f", sz)
|
||||
case "ARBUSDT":
|
||||
sz = math.Floor(sz*100)/100
|
||||
if sz < 0.01 { sz = 0.01 }
|
||||
return fmt.Sprintf("%.2f", sz)
|
||||
default:
|
||||
return fmt.Sprintf("%.4f", sz)
|
||||
}
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
package exchange
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
type HyperLiquidWS struct {
|
||||
Tracked []string
|
||||
}
|
||||
|
||||
type hlAllMidsMsg struct {
|
||||
Channel string `json:"channel"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
}
|
||||
|
||||
type hlMidsData struct {
|
||||
Mids map[string]string `json:"mids"`
|
||||
}
|
||||
|
||||
func NewHyperLiquidWS(tracked []string) *HyperLiquidWS {
|
||||
return &HyperLiquidWS{Tracked: tracked}
|
||||
}
|
||||
// Run connects to HyperLiquid WS and streams mid prices.
|
||||
func (h *HyperLiquidWS) Run(updateFn func(coin string, price, bid, ask float64)) error {
|
||||
conn := NewPriceConnector("wss://api.hyperliquid.xyz/ws", "HyperLiquid", 120*time.Second, 30*time.Second)
|
||||
conn.PingInterval = 45 * time.Second
|
||||
|
||||
conn.OnConnect = func() {
|
||||
log.Printf("[HL WS] Connected")
|
||||
sub := map[string]interface{}{
|
||||
"method": "subscribe",
|
||||
"subscription": map[string]string{
|
||||
"type": "allMids",
|
||||
},
|
||||
}
|
||||
if err := conn.SendJSON(sub); err != nil {
|
||||
log.Printf("[HL WS] Subscribe error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
conn.OnMessage = func(msg []byte) {
|
||||
var raw hlAllMidsMsg
|
||||
if err := json.Unmarshal(msg, &raw); err != nil {
|
||||
return
|
||||
}
|
||||
if raw.Channel != "allMids" {
|
||||
return
|
||||
}
|
||||
var data hlMidsData
|
||||
if err := json.Unmarshal(raw.Data, &data); err != nil {
|
||||
return
|
||||
}
|
||||
for coin, priceStr := range data.Mids {
|
||||
price, err := strconv.ParseFloat(priceStr, 64)
|
||||
if err != nil || price <= 0 {
|
||||
continue
|
||||
}
|
||||
updateFn(coin, price, 0, 0)
|
||||
}
|
||||
}
|
||||
|
||||
return conn.Run()
|
||||
}
|
||||
@@ -1,266 +0,0 @@
|
||||
package exchange
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/ethereum/go-ethereum/crypto"
|
||||
hl "github.com/sonirico/go-hyperliquid"
|
||||
)
|
||||
|
||||
type HyperLiquidTrade struct {
|
||||
exchange *hl.Exchange
|
||||
info *hl.Info
|
||||
privateKey *ecdsa.PrivateKey
|
||||
mainAddress string
|
||||
nonceMu sync.Mutex
|
||||
lastNonce int64
|
||||
configured bool
|
||||
|
||||
// szDecimals maps coin name -> decimal places for size formatting
|
||||
// Populated from HL Meta on initExchange()
|
||||
szDecimals map[string]int
|
||||
}
|
||||
|
||||
func NewHyperLiquidTrade(privateKeyHex, mainAddress, apiAddress string) (*HyperLiquidTrade, error) {
|
||||
if privateKeyHex == "" {
|
||||
return &HyperLiquidTrade{}, nil
|
||||
}
|
||||
|
||||
keyHex := strings.TrimPrefix(privateKeyHex, "0x")
|
||||
keyBytes, err := hex.DecodeString(keyHex)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decode private key: %w", err)
|
||||
}
|
||||
|
||||
privKey, err := crypto.ToECDSA(keyBytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("to ECDSA: %w", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
info := hl.NewInfo(ctx, hl.MainnetAPIURL, true, nil, nil, nil)
|
||||
|
||||
return &HyperLiquidTrade{
|
||||
privateKey: privKey,
|
||||
mainAddress: mainAddress,
|
||||
info: info,
|
||||
configured: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// InitExchange ensures the HL exchange is initialized (fetches metadata, szDecimals, etc.).
|
||||
// Safe to call multiple times — no-op after first initialization.
|
||||
// Must be called before GetSize or PlaceMarketOrder for accurate size formatting.
|
||||
func (h *HyperLiquidTrade) InitExchange() error {
|
||||
return h.initExchange()
|
||||
}
|
||||
|
||||
func (h *HyperLiquidTrade) initExchange() error {
|
||||
if h.exchange != nil {
|
||||
return nil
|
||||
}
|
||||
if !h.configured {
|
||||
return fmt.Errorf("HL not configured")
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
meta, err := h.info.Meta(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("meta: %w", err)
|
||||
}
|
||||
spotMeta, err := h.info.SpotMeta(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("spot meta: %w", err)
|
||||
}
|
||||
|
||||
h.exchange = hl.NewExchange(ctx, h.privateKey, hl.MainnetAPIURL, meta, "", h.mainAddress, spotMeta, nil)
|
||||
|
||||
// Build szDecimals map from HL Meta for correct size formatting
|
||||
h.szDecimals = make(map[string]int, len(meta.Universe))
|
||||
for _, asset := range meta.Universe {
|
||||
h.szDecimals[asset.Name] = asset.SzDecimals
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetSize returns a formatted size string for HL orders using the correct szDecimals.
|
||||
func (h *HyperLiquidTrade) GetSize(coin string, amountUSD, price float64) string {
|
||||
sz := amountUSD / price
|
||||
decimals, ok := h.szDecimals[coin]
|
||||
if !ok {
|
||||
// Fallback: 4 decimal places
|
||||
return fmt.Sprintf("%.4f", math.Floor(sz*10000)/10000)
|
||||
}
|
||||
switch decimals {
|
||||
case 0:
|
||||
sz = math.Floor(sz)
|
||||
if sz < 1 {
|
||||
sz = 1
|
||||
}
|
||||
return fmt.Sprintf("%.0f", sz)
|
||||
case 1:
|
||||
sz = math.Floor(sz*10) / 10
|
||||
if sz < 0.1 {
|
||||
sz = 0.1
|
||||
}
|
||||
return fmt.Sprintf("%.1f", sz)
|
||||
case 2:
|
||||
sz = math.Floor(sz*100) / 100
|
||||
if sz < 0.01 {
|
||||
sz = 0.01
|
||||
}
|
||||
return fmt.Sprintf("%.2f", sz)
|
||||
default:
|
||||
mult := math.Pow10(decimals)
|
||||
sz = math.Floor(sz*mult) / mult
|
||||
if sz < 1/mult {
|
||||
sz = 1 / mult
|
||||
}
|
||||
return fmt.Sprintf("%."+strconv.Itoa(decimals)+"f", sz)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *HyperLiquidTrade) IsConfigured() bool {
|
||||
return h.configured
|
||||
}
|
||||
|
||||
// PlaceMarketOrder places a market order and returns the raw JSON response.
|
||||
func (h *HyperLiquidTrade) PlaceMarketOrder(coin, side, sz string) (string, error) {
|
||||
if !h.configured {
|
||||
return "", fmt.Errorf("HL not configured")
|
||||
}
|
||||
if err := h.initExchange(); err != nil {
|
||||
return "", fmt.Errorf("init: %w", err)
|
||||
}
|
||||
|
||||
isBuy := side == "buy"
|
||||
size, _ := strconv.ParseFloat(sz, 64)
|
||||
|
||||
// Find szDecimals for this coin
|
||||
decimals := 4
|
||||
if d, ok := h.szDecimals[coin]; ok {
|
||||
decimals = d
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
log.Printf("[Order] HL MarketOpen | coin=%s isBuy=%v size=%.*f szDecimals=%d slippage=0.05 px=nil", coin, isBuy, decimals, size, decimals)
|
||||
result, err := h.exchange.MarketOpen(ctx, coin, isBuy, size, nil, 0.05, nil, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("market open: %w", err)
|
||||
}
|
||||
respJSON, _ := json.Marshal(result)
|
||||
return string(respJSON), nil
|
||||
}
|
||||
|
||||
// PlaceMarketCloseOrder closes a position on HL with reduceOnly protection.
|
||||
// Uses the SDK's MarketClose which sets ReduceOnly=true to prevent accidental reversals.
|
||||
// sz is the size string (same format as PlaceMarketOrder). Pass "0" or "" to close full position.
|
||||
func (h *HyperLiquidTrade) PlaceMarketCloseOrder(coin, sz string) (string, error) {
|
||||
if !h.configured {
|
||||
return "", fmt.Errorf("HL not configured")
|
||||
}
|
||||
if err := h.initExchange(); err != nil {
|
||||
return "", fmt.Errorf("init: %w", err)
|
||||
}
|
||||
|
||||
var size *float64
|
||||
if f, err := strconv.ParseFloat(sz, 64); err == nil && f > 0 {
|
||||
size = &f
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
log.Printf("[Order] HL MarketClose | coin=%s size=%v reduceOnly=true slippage=0.05", coin, size)
|
||||
result, err := h.exchange.MarketClose(ctx, coin, size, nil, 0.05, nil, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("market close: %w", err)
|
||||
}
|
||||
respJSON, _ := json.Marshal(result)
|
||||
return string(respJSON), nil
|
||||
}
|
||||
|
||||
// EstimateFeeFromResponse calculates the fee using the response's filled size × price
|
||||
// × configured taker rate. This is NOT an actual fee from HL — HL does not return
|
||||
// fee amounts in the order response. The result is equivalent to estimating from
|
||||
// TradeAmountUSD, but more accurate for partial fills since it uses actual filled sz/px.
|
||||
func (h *HyperLiquidTrade) EstimateFeeFromResponse(orderResponseJSON string, takerFeePct float64) (feeUSD float64, err error) {
|
||||
var resp struct {
|
||||
Filled *struct {
|
||||
TotalSz string `json:"totalSz"`
|
||||
AvgPx string `json:"avgPx"`
|
||||
} `json:"filled,omitempty"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(orderResponseJSON), &resp); err != nil || resp.Filled == nil {
|
||||
return 0, fmt.Errorf("no filled data in response")
|
||||
}
|
||||
sz, _ := strconv.ParseFloat(resp.Filled.TotalSz, 64)
|
||||
px, _ := strconv.ParseFloat(resp.Filled.AvgPx, 64)
|
||||
if sz > 0 && px > 0 {
|
||||
return sz * px * takerFeePct / 100, nil
|
||||
}
|
||||
return 0, fmt.Errorf("no filled status in response")
|
||||
}
|
||||
|
||||
// ParseFillFromResponse extracts the average fill price and total filled size
|
||||
// from an HL MarketOpen/MarketClose response. Returns (avgFillPrice, filledSize, error).
|
||||
func (h *HyperLiquidTrade) ParseFillFromResponse(orderResponseJSON string) (avgPrice, filledSize float64, err error) {
|
||||
var resp struct {
|
||||
Filled *struct {
|
||||
TotalSz string `json:"totalSz"`
|
||||
AvgPx string `json:"avgPx"`
|
||||
} `json:"filled,omitempty"`
|
||||
Error *string `json:"error,omitempty"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(orderResponseJSON), &resp); err != nil {
|
||||
return 0, 0, fmt.Errorf("parse: %w", err)
|
||||
}
|
||||
if resp.Filled != nil {
|
||||
sz, _ := strconv.ParseFloat(resp.Filled.TotalSz, 64)
|
||||
px, _ := strconv.ParseFloat(resp.Filled.AvgPx, 64)
|
||||
if sz > 0 && px > 0 {
|
||||
return px, sz, nil
|
||||
}
|
||||
}
|
||||
return 0, 0, fmt.Errorf("no filled data in response")
|
||||
}
|
||||
|
||||
func (h *HyperLiquidTrade) GetBalance() (float64, error) {
|
||||
if !h.configured {
|
||||
return 0, fmt.Errorf("HL not configured")
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// HL testnet USDC is on spot, not perp. Use SpotUserState.
|
||||
state, err := h.info.SpotUserState(ctx, h.mainAddress)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("spot user state: %w", err)
|
||||
}
|
||||
|
||||
for _, b := range state.Balances {
|
||||
if b.Coin == "USDC" {
|
||||
total, _ := strconv.ParseFloat(b.Total, 64)
|
||||
hold, _ := strconv.ParseFloat(b.Hold, 64)
|
||||
return total - hold, nil
|
||||
}
|
||||
}
|
||||
return 0, fmt.Errorf("USDC balance not found in spot state")
|
||||
}
|
||||
-40
File diff suppressed because one or more lines are too long
-1
File diff suppressed because one or more lines are too long
+40
File diff suppressed because one or more lines are too long
+1
@@ -0,0 +1 @@
|
||||
: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{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{display:grid;grid-template-columns:1fr 1fr;gap:12px}.card-wide{grid-column:1 / -1}.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)}.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:.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,.5);white-space:nowrap}tr:hover td{background:#58a6ff0d}.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}.text-orange{color:var(--yellow)}::-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}@media (max-width: 768px){.grid{grid-template-columns:1fr}header{flex-direction:column;gap:8px}}.tab-btn{background:none;border:1px solid var(--border);color:var(--text-dim);padding:3px 10px;border-radius:4px;cursor:pointer;font-size:12px;margin-left:6px}.tab-btn.active{background:var(--accent);color:#fff;border-color:var(--accent)}.tab-btn:hover:not(.active){color:var(--text);border-color:var(--text-dim)}#surge-card{grid-column:1 / -1}#surge-snapshot-table td,#surge-events-table td{font-variant-numeric:tabular-nums}.surge-active td{background:#3fb9500f}.surge-active:hover td{background:#3fb9501f!important}.surge-up td{background:#3fb9500a}.surge-up:hover td{background:#3fb9501a!important}.surge-down td{background:#f851490a}.surge-down:hover td{background:#f851491a!important}#spread-card #spread-table td{font-variant-numeric:tabular-nums}#momentum-card{grid-column:1 / -1}#momentum-table th{cursor:pointer;-webkit-user-select:none;user-select:none}#momentum-table th:hover{color:var(--accent)}#momentum-table td{font-variant-numeric:tabular-nums}#trend-card{grid-column:1 / -1}#trend-table th{-webkit-user-select:none;user-select:none}#trend-table td{font-variant-numeric:tabular-nums}.trend-state{font-weight:600;font-size:12px}.trend-alert{background:#d299220d}.trend-alert:hover td{background:#d299221a!important}.trend-confirmed{background:#3fb95014}.trend-confirmed:hover td{background:#3fb95026!important}.trend-exhausting{background:#8b949e0d}.trend-exhausting:hover td{background:#8b949e1a!important}#trend-filter-card{grid-column:1 / -1}#trend-filter-table td{font-variant-numeric:tabular-nums}.filter-pass td{background:#3fb9500f}.filter-pass:hover td{background:#3fb9501f!important}#trend-signal-card{grid-column:1 / -1}#trend-signal-table td{font-variant-numeric:tabular-nums}#high-score-card{grid-column:1 / -1}#high-score-table td{font-variant-numeric:tabular-nums}.signal-enter td{background:#3fb95014}.signal-enter:hover td{background:#3fb95026!important}.signal-exit td{background:#8b949e0d}.signal-exit:hover td{background:#8b949e1a!important}#cm-card,#cm-history-card{grid-column:1 / -1}#cm-table td,#cm-history-table td{font-variant-numeric:tabular-nums}
|
||||
Vendored
+2
-2
@@ -4,8 +4,8 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Exchange Monitor Dashboard</title>
|
||||
<script type="module" crossorigin src="/static/assets/index-B9OruCsy.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/static/assets/index-CDE5zNyv.css">
|
||||
<script type="module" crossorigin src="/static/assets/index-Czt_9K6K.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/static/assets/index-vvNDQq2K.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
+34
-105
@@ -67,29 +67,6 @@ header h1 { font-size: 18px; font-weight: 600; }
|
||||
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;
|
||||
@@ -120,7 +97,6 @@ td {
|
||||
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); }
|
||||
@@ -128,6 +104,7 @@ tr:hover td { background: rgba(88, 166, 255, 0.05); }
|
||||
.text-yellow { color: var(--yellow); }
|
||||
.text-dim { color: var(--text-dim); }
|
||||
.text-right { text-align: right; }
|
||||
.text-orange { color: var(--yellow); }
|
||||
|
||||
/* Scrollbar */
|
||||
::-webkit-scrollbar { width: 6px; height: 6px; }
|
||||
@@ -139,91 +116,42 @@ tr:hover td { background: rgba(88, 166, 255, 0.05); }
|
||||
@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 {
|
||||
/* Tab buttons (SurgeCard) */
|
||||
.tab-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-dim);
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
padding: 3px 10px;
|
||||
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 {
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 8px;
|
||||
margin-left: 6px;
|
||||
}
|
||||
.detail-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 3px 0;
|
||||
font-size: 13px;
|
||||
.tab-btn.active {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.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; }
|
||||
.tab-btn:hover:not(.active) {
|
||||
color: var(--text);
|
||||
border-color: var(--text-dim);
|
||||
}
|
||||
|
||||
/* Surge Card */
|
||||
#surge-card { grid-column: 1 / -1; }
|
||||
#surge-snapshot-table td,
|
||||
#surge-events-table td { font-variant-numeric: tabular-nums; }
|
||||
.surge-active td { background: rgba(63, 185, 80, 0.06); }
|
||||
.surge-active:hover td { background: rgba(63, 185, 80, 0.12) !important; }
|
||||
.surge-up td { background: rgba(63, 185, 80, 0.04); }
|
||||
.surge-up:hover td { background: rgba(63, 185, 80, 0.1) !important; }
|
||||
.surge-down td { background: rgba(248, 81, 73, 0.04); }
|
||||
.surge-down:hover td { background: rgba(248, 81, 73, 0.1) !important; }
|
||||
|
||||
/* Spread Card */
|
||||
#spread-card #spread-table td { font-variant-numeric: tabular-nums; }
|
||||
|
||||
/* Momentum Card */
|
||||
#momentum-card { grid-column: 1 / -1; }
|
||||
@@ -244,15 +172,11 @@ tr:hover td { background: rgba(88, 166, 255, 0.05); }
|
||||
.trend-exhausting:hover td { background: rgba(139, 148, 158, 0.1) !important; }
|
||||
|
||||
/* Trend Filter Card */
|
||||
.text-orange { color: var(--yellow); }
|
||||
#trend-filter-card { grid-column: 1 / -1; }
|
||||
#trend-filter-table td { font-variant-numeric: tabular-nums; }
|
||||
.filter-pass td { background: rgba(63, 185, 80, 0.06); }
|
||||
.filter-pass:hover td { background: rgba(63, 185, 80, 0.12) !important; }
|
||||
|
||||
/* blue text for categories */
|
||||
.text-blue { color: #58a6ff; }
|
||||
|
||||
/* Trend Signal Card */
|
||||
#trend-signal-card { grid-column: 1 / -1; }
|
||||
#trend-signal-table td { font-variant-numeric: tabular-nums; }
|
||||
@@ -262,3 +186,8 @@ tr:hover td { background: rgba(88, 166, 255, 0.05); }
|
||||
.signal-enter:hover td { background: rgba(63, 185, 80, 0.15) !important; }
|
||||
.signal-exit td { background: rgba(139, 148, 158, 0.05); }
|
||||
.signal-exit:hover td { background: rgba(139, 148, 158, 0.1) !important; }
|
||||
|
||||
/* Cumulative Change Cards */
|
||||
#cm-card { grid-column: 1 / -1; }
|
||||
#cm-history-card { grid-column: 1 / -1; }
|
||||
#cm-table td, #cm-history-table td { font-variant-numeric: tabular-nums; }
|
||||
|
||||
+164
-621
File diff suppressed because it is too large
Load Diff
@@ -5,7 +5,7 @@ go 1.25.3
|
||||
require (
|
||||
github.com/ethereum/go-ethereum v1.17.2
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/sonirico/go-hyperliquid v0.36.0
|
||||
|
||||
modernc.org/sqlite v1.50.0
|
||||
)
|
||||
|
||||
|
||||
@@ -1,123 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
const sockPath = "/tmp/exchange-monitor.sock"
|
||||
|
||||
// IPCCommand is sent from CLI client to daemon.
|
||||
type IPCCommand struct {
|
||||
Action string `json:"action"` // status, close-all, close, stop, start
|
||||
Coin string `json:"coin,omitempty"`
|
||||
}
|
||||
|
||||
// IPCResponse is sent back from daemon to CLI client.
|
||||
type IPCResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Data interface{} `json:"data,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// startIPCServer starts the Unix socket listener for CLI commands.
|
||||
func (t *Trader) startIPCServer() {
|
||||
os.Remove(sockPath) // clean up stale socket
|
||||
|
||||
ln, err := net.Listen("unix", sockPath)
|
||||
if err != nil {
|
||||
log.Printf("[IPC] Failed to create socket: %v", err)
|
||||
return
|
||||
}
|
||||
log.Printf("[IPC] Listening on %s", sockPath)
|
||||
|
||||
go func() {
|
||||
defer ln.Close()
|
||||
for {
|
||||
conn, err := ln.Accept()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
go t.handleIPC(conn)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (t *Trader) handleIPC(conn net.Conn) {
|
||||
defer conn.Close()
|
||||
conn.SetDeadline(time.Now().Add(5 * time.Second))
|
||||
|
||||
var cmd IPCCommand
|
||||
if err := json.NewDecoder(conn).Decode(&cmd); err != nil {
|
||||
json.NewEncoder(conn).Encode(IPCResponse{Success: false, Error: "invalid command: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
var resp IPCResponse
|
||||
switch cmd.Action {
|
||||
case "status":
|
||||
positions := t.ReadSnapshot()
|
||||
c, d, f, tot := t.GetClosedStats()
|
||||
resp = IPCResponse{Success: true, Data: map[string]interface{}{
|
||||
"positions": positions,
|
||||
"converged": c, "diverged": d, "flat": f, "total": tot,
|
||||
}}
|
||||
case "close-all":
|
||||
count := t.CloseAllPositions()
|
||||
resp = IPCResponse{Success: true, Data: map[string]interface{}{
|
||||
"closed": count, "message": fmt.Sprintf("Closed %d positions", count),
|
||||
}}
|
||||
case "close":
|
||||
if cmd.Coin == "" {
|
||||
resp = IPCResponse{Success: false, Error: "missing coin name"}
|
||||
} else if err := t.ClosePosition(cmd.Coin); err != nil {
|
||||
resp = IPCResponse{Success: false, Error: err.Error()}
|
||||
} else {
|
||||
resp = IPCResponse{Success: true, Data: map[string]string{"closed": cmd.Coin}}
|
||||
}
|
||||
case "stop":
|
||||
t.Stop()
|
||||
resp = IPCResponse{Success: true, Data: map[string]string{"status": "stopped"}}
|
||||
case "start":
|
||||
t.Start()
|
||||
resp = IPCResponse{Success: true, Data: map[string]string{"status": "started"}}
|
||||
default:
|
||||
resp = IPCResponse{Success: false, Error: "unknown action: " + cmd.Action}
|
||||
}
|
||||
json.NewEncoder(conn).Encode(resp)
|
||||
}
|
||||
|
||||
// runIPCClient sends a command to the running daemon and prints the response.
|
||||
func runIPCClient(action, coin string) {
|
||||
conn, err := net.DialTimeout("unix", sockPath, 2*time.Second)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: daemon not running? (%v)\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
cmd := IPCCommand{Action: action, Coin: coin}
|
||||
if err := json.NewEncoder(conn).Encode(cmd); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
var resp IPCResponse
|
||||
if err := json.NewDecoder(conn).Decode(&resp); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error reading response: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if !resp.Success {
|
||||
fmt.Fprintf(os.Stderr, "Error: %s\n", resp.Error)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Pretty-print response
|
||||
data, _ := json.MarshalIndent(resp.Data, "", " ")
|
||||
fmt.Println(string(data))
|
||||
}
|
||||
@@ -3,7 +3,6 @@ package main
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"math/rand"
|
||||
@@ -18,25 +17,6 @@ import (
|
||||
)
|
||||
|
||||
func main() {
|
||||
// CLI subcommand mode: talk to running daemon via IPC
|
||||
if len(os.Args) > 1 {
|
||||
switch os.Args[1] {
|
||||
case "status", "close-all", "stop", "start":
|
||||
runIPCClient(os.Args[1], "")
|
||||
case "close":
|
||||
if len(os.Args) < 3 {
|
||||
fmt.Fprintln(os.Stderr, "Usage: exchange-monitor close <COIN>")
|
||||
os.Exit(1)
|
||||
}
|
||||
runIPCClient("close", os.Args[2])
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "Unknown command: %s\n", os.Args[1])
|
||||
fmt.Fprintln(os.Stderr, "Commands: status, close-all, close <COIN>, stop, start")
|
||||
os.Exit(1)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
log.SetFlags(log.Ldate | log.Ltime | log.Lshortfile)
|
||||
|
||||
// Set up multi-writer: stdout + log file
|
||||
@@ -48,17 +28,12 @@ func main() {
|
||||
} else {
|
||||
log.SetOutput(os.Stdout)
|
||||
}
|
||||
log.Println("[Exchange Monitor] Starting...")
|
||||
log.Println("[Exchange Monitor] Starting surge detection mode...")
|
||||
|
||||
loadDotEnv()
|
||||
cfg := LoadConfig()
|
||||
|
||||
// Populate package-level taker fees from config (so scanner/dashboard/trader all use it)
|
||||
takerFees[ExBitget] = cfg.TakerFeeBitget
|
||||
takerFees[ExHyperLiquid] = cfg.TakerFeeHyperLiquid
|
||||
|
||||
store := NewPriceStore()
|
||||
notifier := NewNotifier(cfg.TelegramBotToken, cfg.TelegramChatID)
|
||||
|
||||
// Initialize momentum tracker (for momentum scanning mode)
|
||||
momentumTracker := NewMomentumTracker()
|
||||
@@ -73,7 +48,6 @@ func main() {
|
||||
|
||||
// Initialize cumulative tracker (1min/5min multi-exchange consensus change)
|
||||
cumulativeTracker := NewCumulativeTracker()
|
||||
log.Printf("[CM] Cumulative change tracking enabled (1m >= %.1f%%, 3+ exchanges)", cumulativeTracker.surgePct1m)
|
||||
|
||||
// Initialize trend filter (Binance K-line based quiet + EMA52 filter)
|
||||
trendFilter := NewTrendFilter(store, trendDetector)
|
||||
@@ -88,35 +62,28 @@ func main() {
|
||||
defer database.Close()
|
||||
}
|
||||
|
||||
// Initialize trader
|
||||
trader := NewTrader(cfg, database)
|
||||
// Initialize surge detector
|
||||
surgeDetector := NewSurgeDetector()
|
||||
if cfg.SurgeEnabled {
|
||||
surgeDetector.Configure(cfg.SurgeWindowSize, cfg.SurgeBaselineMultiplier, cfg.SurgeMinAbsSpreadPct, cfg.SurgeCooldownSec)
|
||||
log.Printf("[Surge] Adaptive detection enabled (window=%d ticks, multiplier=%.1fx, min_spread=%.2f%%, cooldown=%ds)",
|
||||
cfg.SurgeWindowSize, cfg.SurgeBaselineMultiplier, cfg.SurgeMinAbsSpreadPct, cfg.SurgeCooldownSec)
|
||||
|
||||
// Start Unix socket IPC for CLI commands
|
||||
trader.startIPCServer()
|
||||
// Wire surge event persistence to SQLite
|
||||
if database != nil {
|
||||
surgeDetector.SetOnEvent(func(ev SurgeEvent) {
|
||||
database.InsertSurgeEvent(ev.Coin, ev.Timestamp, ev.BnPrice, ev.OkxPrice, ev.BgPrice,
|
||||
ev.SpreadPct, ev.BaselinePct, ev.ThresholdPct, ev.Ratio,
|
||||
ev.Direction, ev.LeadingExchange, ev.MidPrice)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize dashboard (web server + SSE)
|
||||
dashboard := NewDashboard(store, trader, database, ":8888", cfg, momentumTracker, trendDetector, cumulativeTracker, trendFilter)
|
||||
dashboard := NewDashboard(store, database, ":8888", cfg, momentumTracker, trendDetector, cumulativeTracker, trendFilter, surgeDetector)
|
||||
go dashboard.Run()
|
||||
|
||||
// Spread window tracker — measures how long spreads stay above threshold
|
||||
spreadTracker := NewSpreadWindowTracker()
|
||||
|
||||
// P3-4: wire real-time trade event broadcast
|
||||
trader.OnTradeEvent = dashboard.BroadcastEvent
|
||||
if cfg.MomentumEnabled {
|
||||
log.Printf("[Trader] MOMENTUM SCAN mode: arbitrage trading disabled, momentum detection active (threshold >= %.2f%%)", cfg.MomentumThresholdPct)
|
||||
} else if trader.IsConfigured() {
|
||||
log.Printf("[Trader] %s mode: automated trading ENABLED (threshold >= %.2f%%, $%.0f/leg, max %d positions, $%.0f capital)",
|
||||
trader.ModeLabel(), cfg.TradeThreshold, cfg.TradeAmountUSD, cfg.MaxPositions, cfg.InitialCapital)
|
||||
if cfg.TestMode {
|
||||
log.Printf("[Trader] Using mock orders with %.3f%% slippage per leg", cfg.MockSlippagePct)
|
||||
}
|
||||
log.Printf("[Trader] Bitget+HL: BG->HL / HL->BG only")
|
||||
} else {
|
||||
log.Printf("[Trader] Automated trading DISABLED (set TRADE_ENABLED=1 or TEST_MODE=true in .env)")
|
||||
}
|
||||
|
||||
// Context for graceful shutdown — replaces shared sigCh (B#1)
|
||||
// Context for graceful shutdown
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
@@ -124,14 +91,11 @@ func main() {
|
||||
signal.Notify(sigCh, os.Interrupt, syscall.SIGUSR1)
|
||||
|
||||
// Collect symbols for all exchanges
|
||||
var bgSymbols, hlSymbols, bnSymbols, okxSymbols []string
|
||||
var bgSymbols, bnSymbols, okxSymbols []string
|
||||
for _, c := range TrackedCoins {
|
||||
if c.BG != "" {
|
||||
bgSymbols = append(bgSymbols, c.BG)
|
||||
}
|
||||
if c.HL != "" {
|
||||
hlSymbols = append(hlSymbols, c.HL)
|
||||
}
|
||||
if c.BN != "" {
|
||||
bnSymbols = append(bnSymbols, c.BN)
|
||||
}
|
||||
@@ -147,7 +111,7 @@ func main() {
|
||||
err := runner(func(coin string, price, bid, ask float64) {
|
||||
store.SetWithSpread(coin, name, price, bid, ask)
|
||||
dashboard.RecordPrice(coin, name, price)
|
||||
dashboard.RecordConnStatus(name) // P3-5
|
||||
dashboard.RecordConnStatus(name)
|
||||
})
|
||||
log.Printf("[%s] WS error: %v (reconnecting...)", name, err)
|
||||
select {
|
||||
@@ -159,7 +123,6 @@ func main() {
|
||||
}()
|
||||
}
|
||||
|
||||
startExchange("HyperLiquid", exchange.NewHyperLiquidWS(hlSymbols).Run)
|
||||
startExchange("Bitget", exchange.NewBitgetWS(bgSymbols).Run)
|
||||
startExchange("Binance", exchange.NewBinanceWS(bnSymbols).Run)
|
||||
startExchange("OKX", exchange.NewOKXWS(okxSymbols).Run)
|
||||
@@ -167,10 +130,7 @@ func main() {
|
||||
log.Println("[Monitor] Waiting for initial data...")
|
||||
time.Sleep(10 * time.Second)
|
||||
|
||||
// Main loop
|
||||
lastHour := -1
|
||||
|
||||
// Fixed 50ms scan interval
|
||||
// Main loop — fixed 50ms scan interval
|
||||
jitterMin, jitterMax := 50, 50
|
||||
randInterval := func() time.Duration {
|
||||
return time.Duration(jitterMin+rand.Intn(jitterMax-jitterMin+1)) * time.Millisecond
|
||||
@@ -185,29 +145,13 @@ func main() {
|
||||
select {
|
||||
case sig := <-sigCh:
|
||||
if sig == syscall.SIGUSR1 {
|
||||
// Dump stats on request
|
||||
converged, diverged, flat, total := trader.GetClosedStats()
|
||||
stats := fmt.Sprintf("=== 收敛统计 === %s\n", time.Now().Format("2006-01-02 15:04"))
|
||||
stats += fmt.Sprintf(" 总交易数: %d\n", total)
|
||||
stats += fmt.Sprintf(" 价差收敛: %d\n", converged)
|
||||
stats += fmt.Sprintf(" 价差持平: %d\n", flat)
|
||||
stats += fmt.Sprintf(" 价差发散: %d\n", diverged)
|
||||
if total > 0 {
|
||||
stats += fmt.Sprintf(" 收敛率: %.1f%%\n", float64(converged)/float64(total)*100)
|
||||
}
|
||||
log.Printf("[Monitor] SIGUSR1 received — wrote stats to trade_stats.txt")
|
||||
statsPath := os.ExpandEnv("$HOME/Project/exchange-monitor-go/trade_stats.txt")
|
||||
os.WriteFile(statsPath, []byte(stats), 0644)
|
||||
log.Printf("[Monitor] SIGUSR1 received — stats dump")
|
||||
continue
|
||||
}
|
||||
log.Println("[Monitor] Shutting down...")
|
||||
cancel() // B#1: cancel context to stop all WS goroutines
|
||||
cancel()
|
||||
runLoop = false
|
||||
|
||||
case <-trader.StopCh:
|
||||
log.Println("[Monitor] 5 real trades completed — trading stopped. System still running (dashboard active)")
|
||||
log.Println("[Monitor] Use POST /api/start to resume trading, POST /api/stop to stop manually")
|
||||
|
||||
case <-statusTick.C:
|
||||
snap := store.GetAll()
|
||||
count := 0
|
||||
@@ -216,25 +160,16 @@ func main() {
|
||||
}
|
||||
log.Printf("[Status] %d prices / %d coins connected", count, len(snap))
|
||||
|
||||
// Show open positions (read from decoupled snapshot)
|
||||
if positions := trader.ReadSnapshot(); len(positions) > 0 {
|
||||
for _, pos := range positions {
|
||||
log.Printf(" [Position] %s %s open %d scales $%.0f since %s",
|
||||
pos.Coin, pos.Direction, pos.ScaleLevels, pos.AmountUSD,
|
||||
time.Since(pos.StartedAt).Round(time.Second).String())
|
||||
// Show surge events in last 30s
|
||||
events := surgeDetector.GetRecentEvents(3)
|
||||
for _, ev := range events {
|
||||
if time.Since(ev.Timestamp) < 30*time.Second {
|
||||
log.Printf(" [Surge] %s %s spread=%.4f%% leading=%s", ev.Coin, ev.Direction, ev.SpreadPct, ev.LeadingExchange)
|
||||
}
|
||||
}
|
||||
|
||||
case <-scannerTick.C:
|
||||
now := time.Now()
|
||||
t0 := now
|
||||
|
||||
// Tick the trader (monitor open positions for exit)
|
||||
trader.Tick(store, notifier)
|
||||
trader.RefreshSnapshot() // decoupled snapshot for display
|
||||
t1 := time.Now()
|
||||
|
||||
// Scan for arbitrage entries using maker fees (limit orders)
|
||||
snap := store.GetAll()
|
||||
|
||||
// Feed prices to momentum tracker (for momentum scanning or trend detection)
|
||||
@@ -255,43 +190,20 @@ func main() {
|
||||
cumulativeTracker.Record(tc.Name, exMap)
|
||||
}
|
||||
|
||||
makerOpps := ScanBGHL(snap)
|
||||
dashboard.UpdateScan(makerOpps)
|
||||
t2 := time.Now()
|
||||
// Run 3-exchange spread scan
|
||||
spreads := Scan3Ex(snap)
|
||||
dashboard.UpdateScan(spreads)
|
||||
|
||||
// Track spread window durations (how long each opportunity stays alive)
|
||||
spreadTracker.Tick(snap, cfg.TradeThreshold)
|
||||
|
||||
// In momentum mode, arbitrage trading is disabled
|
||||
if !cfg.MomentumEnabled {
|
||||
for _, opp := range makerOpps {
|
||||
if opp.NetProfit < cfg.ArbThreshold {
|
||||
continue
|
||||
// Run surge detection
|
||||
if cfg.SurgeEnabled {
|
||||
newEvents := surgeDetector.Tick(snap)
|
||||
for _, ev := range newEvents {
|
||||
dashboard.BroadcastEvent("surge_event", ev)
|
||||
}
|
||||
if trader.TryEntry(opp, store, notifier) {
|
||||
log.Printf("[Trader] %s: entry initiated for %.4f%%", opp.Coin, opp.NetProfit)
|
||||
}
|
||||
}
|
||||
}
|
||||
t3 := time.Now()
|
||||
|
||||
// Profile: warn if any step is slow
|
||||
tickDur := t3.Sub(t0)
|
||||
tickMs := tickDur.Milliseconds()
|
||||
if tickMs > 100 || t1.Sub(t0) > 50*time.Millisecond || t2.Sub(t1) > 50*time.Millisecond || t3.Sub(t2) > 50*time.Millisecond {
|
||||
log.Printf("[Profile] tick=%dms trader=%dms scan=%dms entry=%dms",
|
||||
tickMs, t1.Sub(t0).Milliseconds(), t2.Sub(t1).Milliseconds(), t3.Sub(t2).Milliseconds())
|
||||
}
|
||||
|
||||
// Hourly trade summary — use hour-based tracking (wider window than second-granularity)
|
||||
hour := now.Hour()
|
||||
if hour != lastHour && now.Minute() < 1 {
|
||||
positions := trader.ReadSnapshot()
|
||||
notifier.SendTradeSummary(positions, now.Format("2006-01-02 15:04"))
|
||||
lastHour = hour
|
||||
}
|
||||
|
||||
scannerTick.Reset(randInterval())
|
||||
_ = now
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
-14
@@ -37,10 +37,6 @@ type MomentumEntry struct {
|
||||
BG5s float64 `json:"bg_5s"`
|
||||
BG15s float64 `json:"bg_15s"`
|
||||
BG60s float64 `json:"bg_60s"`
|
||||
HL1s float64 `json:"hl_1s"`
|
||||
HL5s float64 `json:"hl_5s"`
|
||||
HL15s float64 `json:"hl_15s"`
|
||||
HL60s float64 `json:"hl_60s"`
|
||||
BN1s float64 `json:"bn_1s"`
|
||||
BN5s float64 `json:"bn_5s"`
|
||||
BN15s float64 `json:"bn_15s"`
|
||||
@@ -95,10 +91,9 @@ func (mt *MomentumTracker) Snapshot(thresholdPct float64) []MomentumEntry {
|
||||
var result []MomentumEntry
|
||||
for coin, exMap := range mt.buffers {
|
||||
bgBuf, hasBG := exMap[ExBitget]
|
||||
hlBuf, hasHL := exMap[ExHyperLiquid]
|
||||
bnBuf, hasBN := exMap[ExBinance]
|
||||
okBuf, hasOK := exMap[ExOKX]
|
||||
if !hasBG && !hasHL && !hasBN && !hasOK {
|
||||
if !hasBG && !hasBN && !hasOK {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -113,14 +108,6 @@ func (mt *MomentumTracker) Snapshot(thresholdPct float64) []MomentumEntry {
|
||||
entry.BG60s = changes[3]
|
||||
allChanges = append(allChanges, changes[:]...)
|
||||
}
|
||||
if hasHL {
|
||||
changes := calcWindows(hlBuf)
|
||||
entry.HL1s = changes[0]
|
||||
entry.HL5s = changes[1]
|
||||
entry.HL15s = changes[2]
|
||||
entry.HL60s = changes[3]
|
||||
allChanges = append(allChanges, changes[:]...)
|
||||
}
|
||||
if hasBN {
|
||||
changes := calcWindows(bnBuf)
|
||||
entry.BN1s = changes[0]
|
||||
|
||||
-92
@@ -1,92 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Notifier struct {
|
||||
BotToken string
|
||||
ChatID string
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func NewNotifier(botToken, chatID string) *Notifier {
|
||||
return &Notifier{
|
||||
BotToken: botToken,
|
||||
ChatID: chatID,
|
||||
client: &http.Client{Timeout: 10 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
// Send sends a text message to Telegram.
|
||||
func (n *Notifier) Send(text string) error {
|
||||
if n.BotToken == "" || n.ChatID == "" {
|
||||
log.Printf("[Notifier] Skipped (not configured): %.80s", text)
|
||||
return nil
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", n.BotToken)
|
||||
payload := map[string]string{
|
||||
"chat_id": n.ChatID,
|
||||
"text": text,
|
||||
"parse_mode": "HTML",
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(payload)
|
||||
resp, err := n.client.Post(url, "application/json", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return fmt.Errorf("telegram send error: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return fmt.Errorf("telegram status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
log.Printf("[Notifier] Sent (%d bytes)", len(text))
|
||||
return nil
|
||||
}
|
||||
|
||||
// SendAlert sends an arbitrage alert notification.
|
||||
func (n *Notifier) SendAlert(opp *ArbOpportunity) {
|
||||
msg := fmt.Sprintf(
|
||||
"<b>[套利信号]</b> %s/USDT\n"+
|
||||
" %s %.4f -> %s %.4f\n"+
|
||||
" 净利: <b>%+.4f%%</b>\n",
|
||||
opp.Coin,
|
||||
opp.BuyEx, opp.BuyPrice,
|
||||
opp.SellEx, opp.SellPrice,
|
||||
opp.NetProfit,
|
||||
)
|
||||
if opp.NetProfit > 0.10 {
|
||||
msg += " 高价值机会!\n"
|
||||
}
|
||||
|
||||
if err := n.Send(msg); err != nil {
|
||||
log.Printf("[Notifier] Alert error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// SendTradeSummary sends a summary of open positions at each hour.
|
||||
func (n *Notifier) SendTradeSummary(positions []ArbPosition, timeStr string) {
|
||||
if n.BotToken == "" || n.ChatID == "" {
|
||||
return
|
||||
}
|
||||
lines := fmt.Sprintf("=== 持仓汇总 === %s\n", timeStr)
|
||||
if len(positions) == 0 {
|
||||
lines += " 当前无持仓\n"
|
||||
} else {
|
||||
for i, p := range positions {
|
||||
dur := time.Since(p.StartedAt).Round(time.Second).String()
|
||||
lines += fmt.Sprintf("%d. %s %s %.0f %s\n", i+1, p.Coin, p.Direction, p.AmountUSD, dur)
|
||||
}
|
||||
}
|
||||
if err := n.Send(lines); err != nil {
|
||||
log.Printf("[Notifier] Hourly error: %v", err)
|
||||
}
|
||||
}
|
||||
-395
@@ -1,395 +0,0 @@
|
||||
# Dashboard Design
|
||||
|
||||
## 1. 目录结构
|
||||
|
||||
```
|
||||
exchange-monitor-go/
|
||||
├── main.go # 入口:启动 engine + web server
|
||||
├── config.go # 配置加载(不变)
|
||||
├── types.go # 公共类型(不变)
|
||||
│
|
||||
├── engine/ # 核心交易引擎(从 main.go 拆分)
|
||||
│ ├── engine.go # Engine 结构体:组合所有模块
|
||||
│ ├── scanner.go # 价差扫描(从 scanner.go 移入)
|
||||
│ ├── trader.go # 交易执行(从 trader.go 移入)
|
||||
│ ├── notifier.go # Telegram 通知(从 notifier.go 移入)
|
||||
│ └── portfolio.go # 资金管理 + PnL 聚合
|
||||
│
|
||||
├── exchange/ # 交易所连接(不变)
|
||||
│ └── ...
|
||||
│
|
||||
├── db/ # SQLite 持久化层(新增)
|
||||
│ ├── db.go # DB 初始化、迁移
|
||||
│ ├── trade_repo.go # 交易记录 CRUD
|
||||
│ ├── order_repo.go # 订单明细 CRUD
|
||||
│ └── config_repo.go # 配置快照
|
||||
│
|
||||
├── web/ # Web 仪表盘(新增)
|
||||
│ ├── server.go # HTTP 服务器 + 路由
|
||||
│ ├── handler_dashboard.go # 页面渲染
|
||||
│ ├── handler_api.go # REST API
|
||||
│ ├── handler_sse.go # SSE 实时推送
|
||||
│ ├── static/ # 前端静态资源(go:embed)
|
||||
│ │ ├── index.html
|
||||
│ │ ├── app.js
|
||||
│ │ └── style.css
|
||||
│ └── ws_monitor.go # WS 状态监控
|
||||
│
|
||||
├── risk/ # 风控层(新增)
|
||||
│ └── risk.go # 风控规则引擎
|
||||
│
|
||||
├── persistence.md # 本设计文档
|
||||
└── ...
|
||||
```
|
||||
|
||||
## 2. 数据模型 (SQLite)
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ trades │
|
||||
├──────────────┬──────────┬───────────────────────────┤
|
||||
│ id │ INTEGER │ PRIMARY KEY AUTOINCREMENT │
|
||||
│ coin │ TEXT │ NOT NULL │
|
||||
│ direction │ TEXT │ BG->HL / HL->BG │
|
||||
│ status │ TEXT │ open / closed │
|
||||
│ entry_spread │ REAL │ 进场价差 % │
|
||||
│ exit_spread │ REAL │ 出场价差 % │
|
||||
│ long_ex │ TEXT │ 多腿交易所 │
|
||||
│ short_ex │ TEXT │ 空腿交易所 │
|
||||
│ long_entry │ REAL │ 多腿进场价 │
|
||||
│ long_exit │ REAL │ 多腿出场价 │
|
||||
│ short_entry │ REAL │ 空腿进场价 │
|
||||
│ short_exit │ REAL │ 空腿出场价 │
|
||||
│ long_pnl │ REAL │ 多腿 PnL % │
|
||||
│ short_pnl │ REAL │ 空腿 PnL % │
|
||||
│ fee_entry │ REAL │ 开仓手续费 % │
|
||||
│ fee_exit │ REAL │ 平仓手续费 % │
|
||||
│ net_pnl │ REAL │ 净利 % │
|
||||
│ amount_usd │ REAL │ 总金额 $ │
|
||||
│ scale_count │ INTEGER │ 加仓次数 │
|
||||
│ exit_reason │ TEXT │ 止盈/止损/超时 │
|
||||
│ convergence │ TEXT │ 收敛/发散/持平 │
|
||||
│ opened_at │ DATETIME │ │
|
||||
│ closed_at │ DATETIME │ │
|
||||
└──────────────┴──────────┴───────────────────────────┘
|
||||
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ orders (每腿一条) │
|
||||
├──────────────┬──────────┬───────────────────────────┤
|
||||
│ id │ INTEGER │ │
|
||||
│ trade_id │ INTEGER │ FK → trades.id │
|
||||
│ leg │ TEXT │ long / short │
|
||||
│ type │ TEXT │ entry / exit / scale │
|
||||
│ exchange │ TEXT │ │
|
||||
│ side │ TEXT │ buy / sell │
|
||||
│ price │ REAL │ 成交价 │
|
||||
│ size │ REAL │ 数量 │
|
||||
│ fee │ REAL │ 手续费 │
|
||||
│ order_id │ TEXT │ 交易所订单 ID │
|
||||
│ status │ TEXT │ filled / cancelled │
|
||||
│ created_at │ DATETIME │ │
|
||||
└──────────────┴──────────┴───────────────────────────┘
|
||||
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ price_snapshots │
|
||||
├──────────────┬──────────┬───────────────────────────┤
|
||||
│ id │ INTEGER │ │
|
||||
│ coin │ TEXT │ │
|
||||
│ exchange │ TEXT │ │
|
||||
│ price │ REAL │ │
|
||||
│ bid │ REAL │ │
|
||||
│ ask │ REAL │ │
|
||||
│ spread_basis │ REAL │ bid-ask spread % │
|
||||
│ recorded_at │ DATETIME │ │
|
||||
└──────────────┴──────────┴───────────────────────────┘
|
||||
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ config_snapshots │
|
||||
├──────────────┬──────────┬───────────────────────────┤
|
||||
│ id │ INTEGER │ │
|
||||
│ key │ TEXT │ 参数名 │
|
||||
│ value │ TEXT │ 参数值 │
|
||||
│ changed_at │ DATETIME │ │
|
||||
│ changed_by │ TEXT │ web / cli │
|
||||
└──────────────┴──────────┴───────────────────────────┘
|
||||
```
|
||||
|
||||
## 3. REST API
|
||||
|
||||
```
|
||||
Base URL: http://localhost:8080/api/v1
|
||||
|
||||
┌────────┬────────────────────────┬────────────────────────────┐
|
||||
│ Method │ Path │ 说明 │
|
||||
├────────┼────────────────────────┼────────────────────────────┤
|
||||
│ GET │ /api/v1/stats/summary │ 总览指标 │
|
||||
│ GET │ /api/v1/stats/coins │ 各币种明细 │
|
||||
│ GET │ /api/v1/stats/pnl │ PnL 曲线(按天/时) │
|
||||
│ GET │ /api/v1/stats/daily │ 每日统计 │
|
||||
├────────┼────────────────────────┼────────────────────────────┤
|
||||
│ GET │ /api/v1/trades │ 交易列表(分页) │
|
||||
│ GET │ /api/v1/trades/:id │ 单笔交易详情 + 订单明细 │
|
||||
│ GET │ /api/v1/trades/active │ 当前持仓 │
|
||||
├────────┼────────────────────────┼────────────────────────────┤
|
||||
│ GET │ /api/v1/exchanges │ 交易所连接状态 │
|
||||
│ GET │ /api/v1/prices │ 所有币种实时价差 │
|
||||
├────────┼────────────────────────┼────────────────────────────┤
|
||||
│ GET │ /api/v1/config │ 当前配置 │
|
||||
│ PUT │ /api/v1/config │ 更新配置 │
|
||||
├────────┼────────────────────────┼────────────────────────────┤
|
||||
│ GET │ /api/v1/status │ 系统运行状态(uptime等) │
|
||||
│ POST │ /api/v1/action/restart │ 重启扫描器 │
|
||||
└────────┴────────────────────────┴────────────────────────────┘
|
||||
|
||||
GET /api/v1/stats/summary 响应:
|
||||
{
|
||||
"total_trades": 387,
|
||||
"total_pnl_pct": 4.27,
|
||||
"total_pnl_usd": 0.85,
|
||||
"win_rate": 56.5,
|
||||
"avg_pnl_pct": 0.011,
|
||||
"max_drawdown": -2.1,
|
||||
"active_positions": 3,
|
||||
"running_time": "13h 22m",
|
||||
"exchanges_connected": 4,
|
||||
"mode": "simulation"
|
||||
}
|
||||
|
||||
GET /api/v1/stats/coins 响应:
|
||||
[
|
||||
{
|
||||
"coin": "ONDO",
|
||||
"trades": 115,
|
||||
"pnl_pct": 3.11,
|
||||
"win_rate": 56.5,
|
||||
"avg_pnl": 0.027,
|
||||
"best_trade": 0.18,
|
||||
"worst_trade": -0.05,
|
||||
"long_pct": 94,
|
||||
"short_pct": 6,
|
||||
"active": true
|
||||
},
|
||||
...
|
||||
]
|
||||
|
||||
GET /api/v1/trades?page=1&limit=20&coin=ONDO 响应:
|
||||
{
|
||||
"trades": [
|
||||
{
|
||||
"id": 1,
|
||||
"coin": "ONDO",
|
||||
"direction": "BG->HL",
|
||||
"entry_spread": 0.17,
|
||||
"exit_spread": 0.01,
|
||||
"net_pnl": 0.10,
|
||||
"duration": "52s",
|
||||
"opened_at": "2026-05-03T15:42:00+08:00",
|
||||
"scale_count": 0
|
||||
}
|
||||
],
|
||||
"total": 115,
|
||||
"page": 1
|
||||
}
|
||||
```
|
||||
|
||||
## 4. SSE (Server-Sent Events) 实时推送
|
||||
|
||||
```
|
||||
Endpoint: GET /api/v1/stream
|
||||
|
||||
────────────── 连接建立 ──────────────→
|
||||
|
||||
←── event: snapshot ── 全量数据推送 ──
|
||||
{ prices: {...}, positions: [...], summary: {...} }
|
||||
|
||||
←── event: price_update ── 价差变化 ── (每 500ms)
|
||||
{ coin: "ONDO", spread: 0.15, bg: 0.28, hl: 0.2815 }
|
||||
|
||||
←── event: trade_opened ── 新开仓 ──
|
||||
{ id: 42, coin: "ONDO", direction: "BG->HL", spread: 0.17, ... }
|
||||
|
||||
←── event: trade_closed ── 平仓 ──
|
||||
{ id: 42, net_pnl: 0.10, exit_spread: 0.01, ... }
|
||||
|
||||
←── event: exchange_status ── WS 状态变化 ──
|
||||
{ exchange: "Bitget", connected: true, latency_ms: 120 }
|
||||
|
||||
←── event: alert ── 系统告警 ──
|
||||
{ level: "warn", message: "WS reconnected", ... }
|
||||
```
|
||||
|
||||
## 5. 前端页面布局
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────┐
|
||||
│ [logo] 套利机器人仪表盘 [模拟/实盘] [设置] │
|
||||
├──────────────────────────────────────────────────────┤
|
||||
│ ┌───────┐ ┌───────┐ ┌───────┐ ┌───────┐ ┌────────┐ │
|
||||
│ │总交易 │ │总净利 │ │胜率 │ │当前持仓│ │运行时间 │ │
|
||||
│ │ 387 │ │+4.27% │ │56.5% │ │ 3 │ │ 13h22m │ │
|
||||
│ └───────┘ └───────┘ └───────┘ └───────┘ └────────┘ │
|
||||
├──────────────────────────────────────────────────────┤
|
||||
│ Tab: [📈 概览] [📋 交易记录] [⚙️ 配置] [🔌 连接] │
|
||||
├──────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ Tab: 概览 │
|
||||
│ ┌──────────────────────────────────────────────────┐ │
|
||||
│ │ 价差实时折线图(可切换币种) │ │
|
||||
│ │ ╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲ │ │
|
||||
│ │ ──── 0.1% 阈值线 ──── │ │
|
||||
│ │ ╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱ │ │
|
||||
│ │ [ONDO] [WIF] [OP] [DOGE] [ARB] [LINK] │ │
|
||||
│ └──────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌────┬──────┬──────┬──────┬──────┬──────┬───────┐ │
|
||||
│ │币种 │方向 │持仓$ │价差% │已持 │浮动% │ P&L$ │ │
|
||||
│ ├────┼──────┼──────┼──────┼──────┼──────┼───────┤ │
|
||||
│ │ONDO│BG→HL │$5 │0.15 │12s │+0.10 │+0.005 │ │
|
||||
│ │WIF │BG→HL │$10 │0.08 │2m30s │+0.22 │+0.022 │ │
|
||||
│ │OP │HL→BG │$5 │0.22 │1m │+0.05 │+0.003 │ │
|
||||
│ └────┴──────┴──────┴──────┴──────┴──────┴───────┘ │
|
||||
│ │
|
||||
│ 最近成交 │
|
||||
│ 15:42:23 ONDO BG→HL 入场0.17% 出场0.01% +0.10% │
|
||||
│ 15:41:55 WIF BG→HL 入场0.15% 出场0.02% +0.08% │
|
||||
│ 15:41:30 OP HL→BG 入场0.22% 出场0.01% +0.15% │
|
||||
│ │
|
||||
│ Tab: 交易记录 │
|
||||
│ ┌────┬──────┬──────┬──────┬──────┬──────┬──────┬───┐ │
|
||||
│ │时间│币种 │方向 │入场 │出场 │净利% │持仓 │详情│ │
|
||||
│ ├────┼──────┼──────┼──────┼──────┼──────┼──────┼───┤ │
|
||||
│ │... │ │ │ │ │ │ │ >│ │
|
||||
│ └────┴──────┴──────┴──────┴──────┴──────┴──────┴───┘ │
|
||||
│ [上一页] [1/23] [下一页] │
|
||||
│ │
|
||||
│ Tab: 配置 │
|
||||
│ 阈值: [0.1% ] 每腿金额: [$5 ] 模式: ○模拟 │
|
||||
│ 冷却时间: [30000]ms 最大持仓: [3 ] ●实盘 │
|
||||
│ [保存配置] │
|
||||
│ │
|
||||
│ Tab: 连接 │
|
||||
│ ┌──────────┬──────────┬───────┬──────────┐ │
|
||||
│ │交易所 │状态 │延迟 │最后更新 │ │
|
||||
│ ├──────────┼──────────┼───────┼──────────┤ │
|
||||
│ │Bitget │● 已连接 │120ms │15:42:23 │ │
|
||||
│ │HL │● 已连接 │85ms │15:42:23 │ │
|
||||
│ │Binance │● 已连接 │90ms │15:42:22 │ │
|
||||
│ │dYdX │⚠ 重连中 │-- │15:41:48 │ │
|
||||
│ └──────────┴──────────┴───────┴──────────┘ │
|
||||
└──────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## 6. 前端技术选型
|
||||
|
||||
```
|
||||
框架: 无框架,纯 HTML + CSS + vanilla JS
|
||||
原因:零构建步骤,单文件嵌入
|
||||
|
||||
图表: Chart.js (CDN https://cdn.jsdelivr.net/npm/chart.js)
|
||||
原因:轻量、灵活、CDN 无需 npm
|
||||
|
||||
实时通: EventSource (浏览器原生 SSE)
|
||||
原因:比 WebSocket 简单,自动重连
|
||||
|
||||
UI: 纯 CSS Grid + Flexbox
|
||||
深色主题(适合交易屏长时间看)
|
||||
|
||||
体积: < 300KB 总大小(含 Chart.js CDN)
|
||||
```
|
||||
|
||||
## 7. Web Server 设计 (Go)
|
||||
|
||||
```
|
||||
// web/server.go
|
||||
|
||||
package web
|
||||
|
||||
type Server struct {
|
||||
engine *engine.Engine
|
||||
db *db.DB
|
||||
mux *http.ServeMux
|
||||
sse *SSEHub // SSE 连接管理器
|
||||
}
|
||||
|
||||
// SSEHub 管理所有 SSE 客户端连接
|
||||
type SSEHub struct {
|
||||
clients map[chan SSEEvent]struct{}
|
||||
register chan chan SSEEvent
|
||||
unregister chan chan SSEEvent
|
||||
broadcast chan SSEEvent
|
||||
}
|
||||
|
||||
// 从 Engine 接收事件并广播
|
||||
func (h *SSEHub) Broadcast(eventType string, data interface{})
|
||||
```
|
||||
|
||||
## 8. 数据流
|
||||
|
||||
### 三层数据分层
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────┐
|
||||
│ HOT (内存 only, 500ms) │
|
||||
│ PriceStore: 6币×4所 实时价 │
|
||||
│ Trader.positions: 当前持仓 │
|
||||
│ Scanner: 扫描结果 │
|
||||
│ 不落盘,重启丢失,但重连 WS 秒恢复 │
|
||||
├──────────────────────────────────────────────┤
|
||||
│ WARM (SQLite, 事件驱动) │
|
||||
│ 平仓 → INSERT trades │
|
||||
│ 每腿成交 → INSERT orders │
|
||||
│ 配置修改 → INSERT/UPSERT config_snapshots │
|
||||
│ WS 重连 → INSERT exchange_events(可选) │
|
||||
│ 写入频率: < 1次/秒 │
|
||||
├──────────────────────────────────────────────┤
|
||||
│ COLD (时序方案待定, 未来) │
|
||||
│ 价格存档:每分钟采样 × 6币 × 4所 │
|
||||
│ 日产量: ~34,560行 → 可存 SQLite 也可用 │
|
||||
│ TimescaleDB / InfluxDB (如果要做回测平台) │
|
||||
└──────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 实时数据 → 网页
|
||||
|
||||
```
|
||||
WS 数据流 (500ms):
|
||||
┌─────────┐ price ┌──────────┐ SSE push ┌─────────┐
|
||||
│ Exchange│──────────►│ Engine │─────────────►│ Browser │
|
||||
│ WS │ │ (HOT层) │ │(实时更新)│
|
||||
└─────────┘ └────┬─────┘ └─────────┘
|
||||
│ 仅事件写入
|
||||
┌─────▼──────┐
|
||||
│ SQLite │
|
||||
│ (WARM层) │
|
||||
└────────────┘
|
||||
|
||||
API 请求 (读 WARM 层):
|
||||
┌─────────┐ GET /api/... ┌──────────┐ SQL ┌────────┐
|
||||
│ Browser │────────────────►│ Server │──────────►│ SQLite │
|
||||
│ (页面) │◄────────────────│(REST API)│◄──────────┘ │
|
||||
└─────────┘ JSON └──────────┘
|
||||
```
|
||||
|
||||
## 9. 实现顺序
|
||||
|
||||
```
|
||||
Phase 1 — 基础设施
|
||||
1. db/ 包:SQLite 初始化 + schema 迁移
|
||||
2. 程序启动时保存交易记录到 SQLite
|
||||
3. 重启时从 SQLite 恢复历史数据
|
||||
|
||||
Phase 2 — Web Server
|
||||
1. web/server.go:路由 + SSE Hub
|
||||
2. REST API:summary, trades, prices, config
|
||||
3. 前端 index.html:概览页(指标卡片 + 当前持仓 + 最近成交)
|
||||
|
||||
Phase 3 — 实时
|
||||
1. SSE stream:价格、持仓、交易实时推送
|
||||
2. Chart.js 实时价差折线图
|
||||
|
||||
Phase 4 — 完善
|
||||
1. 交易记录页(分页、筛选、详情弹窗)
|
||||
2. 配置页(在线修改参数)
|
||||
3. 连接状态页
|
||||
4. PnL 曲线图
|
||||
```
|
||||
+223
-229
@@ -4,263 +4,257 @@ import (
|
||||
"sort"
|
||||
)
|
||||
|
||||
// Exchange names — Bitget and HyperLiquid are trading exchanges; Binance and OKX are for momentum/display
|
||||
// Exchange names
|
||||
const (
|
||||
ExHyperLiquid = "HyperLiquid"
|
||||
ExBitget = "Bitget"
|
||||
ExBinance = "Binance"
|
||||
ExOKX = "OKX"
|
||||
)
|
||||
|
||||
// Taker fee rates (%) — for IOC market orders on trading exchanges
|
||||
var takerFees = map[string]float64{
|
||||
ExHyperLiquid: 0.045,
|
||||
ExBitget: 0.060,
|
||||
}
|
||||
|
||||
var TrackedCoins = []TrackedCoin{
|
||||
{Name: "DOGE", BN: "DOGEUSDT", BG: "DOGEUSDT", HL: "DOGE", OK: "DOGE-USDT-SWAP"},
|
||||
{Name: "LINK", BN: "LINKUSDT", BG: "LINKUSDT", HL: "LINK", OK: "LINK-USDT-SWAP"},
|
||||
{Name: "ONDO", BN: "ONDOUSDT", BG: "ONDOUSDT", HL: "ONDO", OK: "ONDO-USDT-SWAP"},
|
||||
{Name: "OP", BN: "OPUSDT", BG: "OPUSDT", HL: "OP", OK: "OP-USDT-SWAP"},
|
||||
{Name: "WIF", BN: "WIFUSDT", BG: "WIFUSDT", HL: "WIF", OK: "WIF-USDT-SWAP"},
|
||||
{Name: "ARB", BN: "ARBUSDT", BG: "ARBUSDT", HL: "ARB", OK: "ARB-USDT-SWAP"},
|
||||
{Name: "0G", BN: "0GUSDT", BG: "0GUSDT", HL: "0G", OK: "0G-USDT-SWAP"},
|
||||
{Name: "2Z", BN: "2ZUSDT", BG: "2ZUSDT", HL: "2Z", OK: "2Z-USDT-SWAP"},
|
||||
{Name: "AAVE", BN: "AAVEUSDT", BG: "AAVEUSDT", HL: "AAVE", OK: "AAVE-USDT-SWAP"},
|
||||
{Name: "ACE", BN: "ACEUSDT", BG: "ACEUSDT", HL: "ACE", OK: "ACE-USDT-SWAP"},
|
||||
{Name: "ADA", BN: "ADAUSDT", BG: "ADAUSDT", HL: "ADA", OK: "ADA-USDT-SWAP"},
|
||||
{Name: "AIXBT", BN: "AIXBTUSDT", BG: "AIXBTUSDT", HL: "AIXBT", OK: "AIXBT-USDT-SWAP"},
|
||||
{Name: "ALGO", BN: "ALGOUSDT", BG: "ALGOUSDT", HL: "ALGO", OK: "ALGO-USDT-SWAP"},
|
||||
{Name: "ALT", BN: "ALTUSDT", BG: "ALTUSDT", HL: "ALT", OK: "ALT-USDT-SWAP"},
|
||||
{Name: "ANIME", BN: "ANIMEUSDT", BG: "ANIMEUSDT", HL: "ANIME", OK: "ANIME-USDT-SWAP"},
|
||||
{Name: "APE", BN: "APEUSDT", BG: "APEUSDT", HL: "APE", OK: "APE-USDT-SWAP"},
|
||||
{Name: "APT", BN: "APTUSDT", BG: "APTUSDT", HL: "APT", OK: "APT-USDT-SWAP"},
|
||||
{Name: "AR", BN: "ARUSDT", BG: "ARUSDT", HL: "AR", OK: "AR-USDT-SWAP"},
|
||||
{Name: "ARK", BN: "ARKUSDT", BG: "ARKUSDT", HL: "ARK", OK: "ARK-USDT-SWAP"},
|
||||
{Name: "ASTER", BN: "ASTERUSDT", BG: "ASTERUSDT", HL: "ASTER", OK: "ASTER-USDT-SWAP"},
|
||||
{Name: "ATOM", BN: "ATOMUSDT", BG: "ATOMUSDT", HL: "ATOM", OK: "ATOM-USDT-SWAP"},
|
||||
{Name: "AVAX", BN: "AVAXUSDT", BG: "AVAXUSDT", HL: "AVAX", OK: "AVAX-USDT-SWAP"},
|
||||
{Name: "AVNT", BN: "AVNTUSDT", BG: "AVNTUSDT", HL: "AVNT", OK: "AVNT-USDT-SWAP"},
|
||||
{Name: "AXS", BN: "AXSUSDT", BG: "AXSUSDT", HL: "AXS", OK: "AXS-USDT-SWAP"},
|
||||
{Name: "AZTEC", BN: "AZTECUSDT", BG: "AZTECUSDT", HL: "AZTEC", OK: "AZTEC-USDT-SWAP"},
|
||||
{Name: "BABY", BN: "BABYUSDT", BG: "BABYUSDT", HL: "BABY", OK: "BABY-USDT-SWAP"},
|
||||
{Name: "BANANA", BN: "BANANAUSDT", BG: "BANANAUSDT", HL: "BANANA", OK: "BANANA-USDT-SWAP"},
|
||||
{Name: "BCH", BN: "BCHUSDT", BG: "BCHUSDT", HL: "BCH", OK: "BCH-USDT-SWAP"},
|
||||
{Name: "BERA", BN: "BERAUSDT", BG: "BERAUSDT", HL: "BERA", OK: "BERA-USDT-SWAP"},
|
||||
{Name: "BIGTIME", BN: "BIGTIMEUSDT", BG: "BIGTIMEUSDT", HL: "BIGTIME", OK: "BIGTIME-USDT-SWAP"},
|
||||
{Name: "BIO", BN: "BIOUSDT", BG: "BIOUSDT", HL: "BIO", OK: "BIO-USDT-SWAP"},
|
||||
{Name: "BLUR", BN: "BLURUSDT", BG: "BLURUSDT", HL: "BLUR", OK: "BLUR-USDT-SWAP"},
|
||||
{Name: "BNB", BN: "BNBUSDT", BG: "BNBUSDT", HL: "BNB", OK: "BNB-USDT-SWAP"},
|
||||
{Name: "BNT", BN: "BNTUSDT", BG: "BNTUSDT", HL: "", OK: "BNT-USDT-SWAP"},
|
||||
{Name: "BOME", BN: "BOMEUSDT", BG: "BOMEUSDT", HL: "BOME", OK: "BOME-USDT-SWAP"},
|
||||
{Name: "BRETT", BN: "BRETTUSDT", BG: "BRETTUSDT", HL: "BRETT", OK: "BRETT-USDT-SWAP"},
|
||||
{Name: "BSV", BN: "BSVUSDT", BG: "BSVUSDT", HL: "BSV", OK: "BSV-USDT-SWAP"},
|
||||
{Name: "BTC", BN: "BTCUSDT", BG: "BTCUSDT", HL: "BTC", OK: "BTC-USDT-SWAP"},
|
||||
{Name: "CAKE", BN: "CAKEUSDT", BG: "CAKEUSDT", HL: "CAKE", OK: "CAKE-USDT-SWAP"},
|
||||
{Name: "CATI", BN: "CATIUSDT", BG: "CATIUSDT", HL: "", OK: "CATI-USDT-SWAP"},
|
||||
{Name: "CC", BN: "CCUSDT", BG: "CCUSDT", HL: "CC", OK: "CC-USDT-SWAP"},
|
||||
{Name: "CELO", BN: "CELOUSDT", BG: "CELOUSDT", HL: "CELO", OK: "CELO-USDT-SWAP"},
|
||||
{Name: "CFX", BN: "CFXUSDT", BG: "CFXUSDT", HL: "CFX", OK: "CFX-USDT-SWAP"},
|
||||
{Name: "CHILLGUY", BN: "CHILLGUYUSDT", BG: "CHILLGUYUSDT", HL: "CHILLGUY", OK: "CHILLGUY-USDT-SWAP"},
|
||||
{Name: "CHIP", BN: "CHIPUSDT", BG: "CHIPUSDT", HL: "CHIP", OK: "CHIP-USDT-SWAP"},
|
||||
{Name: "COMP", BN: "COMPUSDT", BG: "COMPUSDT", HL: "COMP", OK: "COMP-USDT-SWAP"},
|
||||
{Name: "CRV", BN: "CRVUSDT", BG: "CRVUSDT", HL: "CRV", OK: "CRV-USDT-SWAP"},
|
||||
{Name: "CYBER", BN: "CYBERUSDT", BG: "CYBERUSDT", HL: "", OK: "CYBER-USDT-SWAP"},
|
||||
{Name: "DASH", BN: "DASHUSDT", BG: "DASHUSDT", HL: "DASH", OK: "DASH-USDT-SWAP"},
|
||||
{Name: "DOOD", BN: "DOODUSDT", BG: "DOODUSDT", HL: "DOOD", OK: "DOOD-USDT-SWAP"},
|
||||
{Name: "DOT", BN: "DOTUSDT", BG: "DOTUSDT", HL: "DOT", OK: "DOT-USDT-SWAP"},
|
||||
{Name: "DYDX", BN: "DYDXUSDT", BG: "DYDXUSDT", HL: "DYDX", OK: "DYDX-USDT-SWAP"},
|
||||
{Name: "DYM", BN: "DYMUSDT", BG: "DYMUSDT", HL: "DYM", OK: "DYM-USDT-SWAP"},
|
||||
{Name: "EIGEN", BN: "EIGENUSDT", BG: "EIGENUSDT", HL: "EIGEN", OK: "EIGEN-USDT-SWAP"},
|
||||
{Name: "ENA", BN: "ENAUSDT", BG: "ENAUSDT", HL: "ENA", OK: "ENA-USDT-SWAP"},
|
||||
{Name: "ENS", BN: "ENSUSDT", BG: "ENSUSDT", HL: "ENS", OK: "ENS-USDT-SWAP"},
|
||||
{Name: "ETC", BN: "ETCUSDT", BG: "ETCUSDT", HL: "ETC", OK: "ETC-USDT-SWAP"},
|
||||
{Name: "ETH", BN: "ETHUSDT", BG: "ETHUSDT", HL: "ETH", OK: "ETH-USDT-SWAP"},
|
||||
{Name: "ETHFI", BN: "ETHFIUSDT", BG: "ETHFIUSDT", HL: "ETHFI", OK: "ETHFI-USDT-SWAP"},
|
||||
{Name: "FARTCOIN", BN: "FARTCOINUSDT", BG: "FARTCOINUSDT", HL: "FARTCOIN", OK: "FARTCOIN-USDT-SWAP"},
|
||||
{Name: "FET", BN: "FETUSDT", BG: "FETUSDT", HL: "FET", OK: "FET-USDT-SWAP"},
|
||||
{Name: "FIL", BN: "FILUSDT", BG: "FILUSDT", HL: "FIL", OK: "FIL-USDT-SWAP"},
|
||||
{Name: "FOGO", BN: "FOGOUSDT", BG: "FOGOUSDT", HL: "FOGO", OK: "FOGO-USDT-SWAP"},
|
||||
{Name: "GALA", BN: "GALAUSDT", BG: "GALAUSDT", HL: "GALA", OK: "GALA-USDT-SWAP"},
|
||||
{Name: "GAS", BN: "GASUSDT", BG: "GASUSDT", HL: "GAS", OK: "GAS-USDT-SWAP"},
|
||||
{Name: "GMT", BN: "GMTUSDT", BG: "GMTUSDT", HL: "GMT", OK: "GMT-USDT-SWAP"},
|
||||
{Name: "GMX", BN: "GMXUSDT", BG: "GMXUSDT", HL: "GMX", OK: "GMX-USDT-SWAP"},
|
||||
{Name: "GOAT", BN: "GOATUSDT", BG: "GOATUSDT", HL: "GOAT", OK: "GOAT-USDT-SWAP"},
|
||||
{Name: "GRASS", BN: "GRASSUSDT", BG: "GRASSUSDT", HL: "GRASS", OK: "GRASS-USDT-SWAP"},
|
||||
{Name: "GRIFFAIN", BN: "GRIFFAINUSDT", BG: "GRIFFAINUSDT", HL: "GRIFFAIN", OK: "GRIFFAIN-USDT-SWAP"},
|
||||
{Name: "HBAR", BN: "HBARUSDT", BG: "HBARUSDT", HL: "HBAR", OK: "HBAR-USDT-SWAP"},
|
||||
{Name: "HYPE", BN: "HYPEUSDT", BG: "HYPEUSDT", HL: "HYPE", OK: "HYPE-USDT-SWAP"},
|
||||
{Name: "HYPER", BN: "HYPERUSDT", BG: "HYPERUSDT", HL: "HYPER", OK: "HYPER-USDT-SWAP"},
|
||||
{Name: "ICP", BN: "ICPUSDT", BG: "ICPUSDT", HL: "ICP", OK: "ICP-USDT-SWAP"},
|
||||
{Name: "ILV", BN: "ILVUSDT", BG: "ILVUSDT", HL: "", OK: "ILV-USDT-SWAP"},
|
||||
{Name: "IMX", BN: "IMXUSDT", BG: "IMXUSDT", HL: "IMX", OK: "IMX-USDT-SWAP"},
|
||||
{Name: "INIT", BN: "INITUSDT", BG: "INITUSDT", HL: "INIT", OK: "INIT-USDT-SWAP"},
|
||||
{Name: "INJ", BN: "INJUSDT", BG: "INJUSDT", HL: "INJ", OK: "INJ-USDT-SWAP"},
|
||||
{Name: "IO", BN: "IOUSDT", BG: "IOUSDT", HL: "IO", OK: "IO-USDT-SWAP"},
|
||||
{Name: "IOTA", BN: "IOTAUSDT", BG: "IOTAUSDT", HL: "IOTA", OK: "IOTA-USDT-SWAP"},
|
||||
{Name: "IP", BN: "IPUSDT", BG: "IPUSDT", HL: "IP", OK: "IP-USDT-SWAP"},
|
||||
{Name: "JTO", BN: "JTOUSDT", BG: "JTOUSDT", HL: "JTO", OK: "JTO-USDT-SWAP"},
|
||||
{Name: "JUP", BN: "JUPUSDT", BG: "JUPUSDT", HL: "JUP", OK: "JUP-USDT-SWAP"},
|
||||
{Name: "KAITO", BN: "KAITOUSDT", BG: "KAITOUSDT", HL: "KAITO", OK: "KAITO-USDT-SWAP"},
|
||||
{Name: "KAS", BN: "KASUSDT", BG: "KASUSDT", HL: "KAS", OK: "KAS-USDT-SWAP"},
|
||||
{Name: "LAYER", BN: "LAYERUSDT", BG: "LAYERUSDT", HL: "LAYER", OK: "LAYER-USDT-SWAP"},
|
||||
{Name: "LDO", BN: "LDOUSDT", BG: "LDOUSDT", HL: "LDO", OK: "LDO-USDT-SWAP"},
|
||||
{Name: "LINEA", BN: "LINEAUSDT", BG: "LINEAUSDT", HL: "LINEA", OK: "LINEA-USDT-SWAP"},
|
||||
{Name: "LISTA", BN: "LISTAUSDT", BG: "LISTAUSDT", HL: "", OK: "LISTA-USDT-SWAP"},
|
||||
{Name: "LIT", BN: "LITUSDT", BG: "LITUSDT", HL: "LIT", OK: "LIT-USDT-SWAP"},
|
||||
{Name: "LTC", BN: "LTCUSDT", BG: "LTCUSDT", HL: "LTC", OK: "LTC-USDT-SWAP"},
|
||||
{Name: "MANTA", BN: "MANTAUSDT", BG: "MANTAUSDT", HL: "MANTA", OK: "MANTA-USDT-SWAP"},
|
||||
{Name: "MAV", BN: "MAVUSDT", BG: "MAVUSDT", HL: "MAV", OK: "MAV-USDT-SWAP"},
|
||||
{Name: "ME", BN: "MEUSDT", BG: "MEUSDT", HL: "ME", OK: "ME-USDT-SWAP"},
|
||||
{Name: "MEGA", BN: "MEGAUSDT", BG: "MEGAUSDT", HL: "MEGA", OK: "MEGA-USDT-SWAP"},
|
||||
{Name: "MELANIA", BN: "MELANIAUSDT", BG: "MELANIAUSDT", HL: "MELANIA", OK: "MELANIA-USDT-SWAP"},
|
||||
{Name: "MEME", BN: "MEMEUSDT", BG: "MEMEUSDT", HL: "MEME", OK: "MEME-USDT-SWAP"},
|
||||
{Name: "MERL", BN: "MERLUSDT", BG: "MERLUSDT", HL: "MERL", OK: "MERL-USDT-SWAP"},
|
||||
{Name: "MET", BN: "METUSDT", BG: "METUSDT", HL: "MET", OK: "MET-USDT-SWAP"},
|
||||
{Name: "MINA", BN: "MINAUSDT", BG: "MINAUSDT", HL: "MINA", OK: "MINA-USDT-SWAP"},
|
||||
{Name: "MON", BN: "MONUSDT", BG: "MONUSDT", HL: "MON", OK: "MON-USDT-SWAP"},
|
||||
{Name: "MOODENG", BN: "MOODENGUSDT", BG: "MOODENGUSDT", HL: "MOODENG", OK: "MOODENG-USDT-SWAP"},
|
||||
{Name: "MORPHO", BN: "MORPHOUSDT", BG: "MORPHOUSDT", HL: "MORPHO", OK: "MORPHO-USDT-SWAP"},
|
||||
{Name: "MOVE", BN: "MOVEUSDT", BG: "MOVEUSDT", HL: "MOVE", OK: "MOVE-USDT-SWAP"},
|
||||
{Name: "NEAR", BN: "NEARUSDT", BG: "NEARUSDT", HL: "NEAR", OK: "NEAR-USDT-SWAP"},
|
||||
{Name: "NEO", BN: "NEOUSDT", BG: "NEOUSDT", HL: "NEO", OK: "NEO-USDT-SWAP"},
|
||||
{Name: "NIL", BN: "NILUSDT", BG: "NILUSDT", HL: "NIL", OK: "NIL-USDT-SWAP"},
|
||||
{Name: "NOT", BN: "NOTUSDT", BG: "NOTUSDT", HL: "NOT", OK: "NOT-USDT-SWAP"},
|
||||
{Name: "NXPC", BN: "NXPCUSDT", BG: "NXPCUSDT", HL: "NXPC", OK: "NXPC-USDT-SWAP"},
|
||||
{Name: "OGN", BN: "OGNUSDT", BG: "OGNUSDT", HL: "", OK: "OGN-USDT-SWAP"},
|
||||
{Name: "ORDI", BN: "ORDIUSDT", BG: "ORDIUSDT", HL: "ORDI", OK: "ORDI-USDT-SWAP"},
|
||||
{Name: "PAXG", BN: "PAXGUSDT", BG: "PAXGUSDT", HL: "PAXG", OK: "PAXG-USDT-SWAP"},
|
||||
{Name: "PENDLE", BN: "PENDLEUSDT", BG: "PENDLEUSDT", HL: "PENDLE", OK: "PENDLE-USDT-SWAP"},
|
||||
{Name: "PENGU", BN: "PENGUUSDT", BG: "PENGUUSDT", HL: "PENGU", OK: "PENGU-USDT-SWAP"},
|
||||
{Name: "PEOPLE", BN: "PEOPLEUSDT", BG: "PEOPLEUSDT", HL: "PEOPLE", OK: "PEOPLE-USDT-SWAP"},
|
||||
{Name: "PIXEL", BN: "PIXELUSDT", BG: "PIXELUSDT", HL: "", OK: "PIXEL-USDT-SWAP"},
|
||||
{Name: "PNUT", BN: "PNUTUSDT", BG: "PNUTUSDT", HL: "PNUT", OK: "PNUT-USDT-SWAP"},
|
||||
{Name: "POL", BN: "POLUSDT", BG: "POLUSDT", HL: "POL", OK: "POL-USDT-SWAP"},
|
||||
{Name: "POLYX", BN: "POLYXUSDT", BG: "POLYXUSDT", HL: "POLYX", OK: "POLYX-USDT-SWAP"},
|
||||
{Name: "POPCAT", BN: "POPCATUSDT", BG: "POPCATUSDT", HL: "POPCAT", OK: "POPCAT-USDT-SWAP"},
|
||||
{Name: "PROVE", BN: "PROVEUSDT", BG: "PROVEUSDT", HL: "PROVE", OK: "PROVE-USDT-SWAP"},
|
||||
{Name: "PUMP", BN: "PUMPUSDT", BG: "PUMPUSDT", HL: "PUMP", OK: "PUMP-USDT-SWAP"},
|
||||
{Name: "PYTH", BN: "PYTHUSDT", BG: "PYTHUSDT", HL: "PYTH", OK: "PYTH-USDT-SWAP"},
|
||||
{Name: "RENDER", BN: "RENDERUSDT", BG: "RENDERUSDT", HL: "RENDER", OK: "RENDER-USDT-SWAP"},
|
||||
{Name: "RESOLV", BN: "RESOLVUSDT", BG: "RESOLVUSDT", HL: "RESOLV", OK: "RESOLV-USDT-SWAP"},
|
||||
{Name: "REZ", BN: "REZUSDT", BG: "REZUSDT", HL: "REZ", OK: "REZ-USDT-SWAP"},
|
||||
{Name: "RSR", BN: "RSRUSDT", BG: "RSRUSDT", HL: "RSR", OK: "RSR-USDT-SWAP"},
|
||||
{Name: "RUNE", BN: "RUNEUSDT", BG: "RUNEUSDT", HL: "RUNE", OK: "RUNE-USDT-SWAP"},
|
||||
{Name: "S", BN: "SUSDT", BG: "SUSDT", HL: "S", OK: "S-USDT-SWAP"},
|
||||
{Name: "SAGA", BN: "SAGAUSDT", BG: "SAGAUSDT", HL: "SAGA", OK: "SAGA-USDT-SWAP"},
|
||||
{Name: "SAND", BN: "SANDUSDT", BG: "SANDUSDT", HL: "SAND", OK: "SAND-USDT-SWAP"},
|
||||
{Name: "SEI", BN: "SEIUSDT", BG: "SEIUSDT", HL: "SEI", OK: "SEI-USDT-SWAP"},
|
||||
{Name: "SKR", BN: "SKRUSDT", BG: "SKRUSDT", HL: "SKR", OK: "SKR-USDT-SWAP"},
|
||||
{Name: "SKY", BN: "SKYUSDT", BG: "SKYUSDT", HL: "SKY", OK: "SKY-USDT-SWAP"},
|
||||
{Name: "SNX", BN: "SNXUSDT", BG: "SNXUSDT", HL: "SNX", OK: "SNX-USDT-SWAP"},
|
||||
{Name: "SOL", BN: "SOLUSDT", BG: "SOLUSDT", HL: "SOL", OK: "SOL-USDT-SWAP"},
|
||||
{Name: "SOPH", BN: "SOPHUSDT", BG: "SOPHUSDT", HL: "SOPH", OK: "SOPH-USDT-SWAP"},
|
||||
{Name: "SPX", BN: "SPXUSDT", BG: "SPXUSDT", HL: "SPX", OK: "SPX-USDT-SWAP"},
|
||||
{Name: "STABLE", BN: "STABLEUSDT", BG: "STABLEUSDT", HL: "STABLE", OK: "STABLE-USDT-SWAP"},
|
||||
{Name: "STG", BN: "STGUSDT", BG: "STGUSDT", HL: "", OK: "STG-USDT-SWAP"},
|
||||
{Name: "STRK", BN: "STRKUSDT", BG: "STRKUSDT", HL: "STRK", OK: "STRK-USDT-SWAP"},
|
||||
{Name: "STX", BN: "STXUSDT", BG: "STXUSDT", HL: "STX", OK: "STX-USDT-SWAP"},
|
||||
{Name: "SUI", BN: "SUIUSDT", BG: "SUIUSDT", HL: "SUI", OK: "SUI-USDT-SWAP"},
|
||||
{Name: "SUPER", BN: "SUPERUSDT", BG: "SUPERUSDT", HL: "SUPER", OK: "SUPER-USDT-SWAP"},
|
||||
{Name: "SUSHI", BN: "SUSHIUSDT", BG: "SUSHIUSDT", HL: "SUSHI", OK: "SUSHI-USDT-SWAP"},
|
||||
{Name: "SYRUP", BN: "SYRUPUSDT", BG: "SYRUPUSDT", HL: "SYRUP", OK: "SYRUP-USDT-SWAP"},
|
||||
{Name: "TAO", BN: "TAOUSDT", BG: "TAOUSDT", HL: "TAO", OK: "TAO-USDT-SWAP"},
|
||||
{Name: "TIA", BN: "TIAUSDT", BG: "TIAUSDT", HL: "TIA", OK: "TIA-USDT-SWAP"},
|
||||
{Name: "TNSR", BN: "TNSRUSDT", BG: "TNSRUSDT", HL: "TNSR", OK: "TNSR-USDT-SWAP"},
|
||||
{Name: "TON", BN: "TONUSDT", BG: "TONUSDT", HL: "TON", OK: "TON-USDT-SWAP"},
|
||||
{Name: "TRB", BN: "TRBUSDT", BG: "TRBUSDT", HL: "TRB", OK: "TRB-USDT-SWAP"},
|
||||
{Name: "TRUMP", BN: "TRUMPUSDT", BG: "TRUMPUSDT", HL: "TRUMP", OK: "TRUMP-USDT-SWAP"},
|
||||
{Name: "TRX", BN: "TRXUSDT", BG: "TRXUSDT", HL: "TRX", OK: "TRX-USDT-SWAP"},
|
||||
{Name: "TURBO", BN: "TURBOUSDT", BG: "TURBOUSDT", HL: "TURBO", OK: "TURBO-USDT-SWAP"},
|
||||
{Name: "UMA", BN: "UMAUSDT", BG: "UMAUSDT", HL: "UMA", OK: "UMA-USDT-SWAP"},
|
||||
{Name: "UNI", BN: "UNIUSDT", BG: "UNIUSDT", HL: "UNI", OK: "UNI-USDT-SWAP"},
|
||||
{Name: "USUAL", BN: "USUALUSDT", BG: "USUALUSDT", HL: "USUAL", OK: "USUAL-USDT-SWAP"},
|
||||
{Name: "VIRTUAL", BN: "VIRTUALUSDT", BG: "VIRTUALUSDT", HL: "VIRTUAL", OK: "VIRTUAL-USDT-SWAP"},
|
||||
{Name: "VVV", BN: "VVVUSDT", BG: "VVVUSDT", HL: "VVV", OK: "VVV-USDT-SWAP"},
|
||||
{Name: "W", BN: "WUSDT", BG: "WUSDT", HL: "W", OK: "W-USDT-SWAP"},
|
||||
{Name: "WCT", BN: "WCTUSDT", BG: "WCTUSDT", HL: "WCT", OK: "WCT-USDT-SWAP"},
|
||||
{Name: "WLD", BN: "WLDUSDT", BG: "WLDUSDT", HL: "WLD", OK: "WLD-USDT-SWAP"},
|
||||
{Name: "WLFI", BN: "WLFIUSDT", BG: "WLFIUSDT", HL: "WLFI", OK: "WLFI-USDT-SWAP"},
|
||||
{Name: "XAI", BN: "XAIUSDT", BG: "XAIUSDT", HL: "XAI", OK: "XAI-USDT-SWAP"},
|
||||
{Name: "XLM", BN: "XLMUSDT", BG: "XLMUSDT", HL: "XLM", OK: "XLM-USDT-SWAP"},
|
||||
{Name: "XMR", BN: "XMRUSDT", BG: "XMRUSDT", HL: "XMR", OK: "XMR-USDT-SWAP"},
|
||||
{Name: "XPL", BN: "XPLUSDT", BG: "XPLUSDT", HL: "XPL", OK: "XPL-USDT-SWAP"},
|
||||
{Name: "XRP", BN: "XRPUSDT", BG: "XRPUSDT", HL: "XRP", OK: "XRP-USDT-SWAP"},
|
||||
{Name: "YGG", BN: "YGGUSDT", BG: "YGGUSDT", HL: "YGG", OK: "YGG-USDT-SWAP"},
|
||||
{Name: "ZEC", BN: "ZECUSDT", BG: "ZECUSDT", HL: "ZEC", OK: "ZEC-USDT-SWAP"},
|
||||
{Name: "ZEN", BN: "ZENUSDT", BG: "ZENUSDT", HL: "ZEN", OK: "ZEN-USDT-SWAP"},
|
||||
{Name: "ZETA", BN: "ZETAUSDT", BG: "ZETAUSDT", HL: "ZETA", OK: "ZETA-USDT-SWAP"},
|
||||
{Name: "ZK", BN: "ZKUSDT", BG: "ZKUSDT", HL: "ZK", OK: "ZK-USDT-SWAP"},
|
||||
{Name: "ZORA", BN: "ZORAUSDT", BG: "ZORAUSDT", HL: "ZORA", OK: "ZORA-USDT-SWAP"},
|
||||
{Name: "ZRO", BN: "ZROUSDT", BG: "ZROUSDT", HL: "ZRO", OK: "ZRO-USDT-SWAP"},
|
||||
{Name: "DOGE", BN: "DOGEUSDT", BG: "DOGEUSDT", OK: "DOGE-USDT-SWAP"},
|
||||
{Name: "LINK", BN: "LINKUSDT", BG: "LINKUSDT", OK: "LINK-USDT-SWAP"},
|
||||
{Name: "ONDO", BN: "ONDOUSDT", BG: "ONDOUSDT", OK: "ONDO-USDT-SWAP"},
|
||||
{Name: "OP", BN: "OPUSDT", BG: "OPUSDT", OK: "OP-USDT-SWAP"},
|
||||
{Name: "WIF", BN: "WIFUSDT", BG: "WIFUSDT", OK: "WIF-USDT-SWAP"},
|
||||
{Name: "ARB", BN: "ARBUSDT", BG: "ARBUSDT", OK: "ARB-USDT-SWAP"},
|
||||
{Name: "0G", BN: "0GUSDT", BG: "0GUSDT", OK: "0G-USDT-SWAP"},
|
||||
{Name: "2Z", BN: "2ZUSDT", BG: "2ZUSDT", OK: "2Z-USDT-SWAP"},
|
||||
{Name: "AAVE", BN: "AAVEUSDT", BG: "AAVEUSDT", OK: "AAVE-USDT-SWAP"},
|
||||
{Name: "ACE", BN: "ACEUSDT", BG: "ACEUSDT", OK: "ACE-USDT-SWAP"},
|
||||
{Name: "ADA", BN: "ADAUSDT", BG: "ADAUSDT", OK: "ADA-USDT-SWAP"},
|
||||
{Name: "AIXBT", BN: "AIXBTUSDT", BG: "AIXBTUSDT", OK: "AIXBT-USDT-SWAP"},
|
||||
{Name: "ALGO", BN: "ALGOUSDT", BG: "ALGOUSDT", OK: "ALGO-USDT-SWAP"},
|
||||
{Name: "ALT", BN: "ALTUSDT", BG: "ALTUSDT", OK: "ALT-USDT-SWAP"},
|
||||
{Name: "ANIME", BN: "ANIMEUSDT", BG: "ANIMEUSDT", OK: "ANIME-USDT-SWAP"},
|
||||
{Name: "APE", BN: "APEUSDT", BG: "APEUSDT", OK: "APE-USDT-SWAP"},
|
||||
{Name: "APT", BN: "APTUSDT", BG: "APTUSDT", OK: "APT-USDT-SWAP"},
|
||||
{Name: "AR", BN: "ARUSDT", BG: "ARUSDT", OK: "AR-USDT-SWAP"},
|
||||
{Name: "ARK", BN: "ARKUSDT", BG: "ARKUSDT", OK: "ARK-USDT-SWAP"},
|
||||
{Name: "ASTER", BN: "ASTERUSDT", BG: "ASTERUSDT", OK: "ASTER-USDT-SWAP"},
|
||||
{Name: "ATOM", BN: "ATOMUSDT", BG: "ATOMUSDT", OK: "ATOM-USDT-SWAP"},
|
||||
{Name: "AVAX", BN: "AVAXUSDT", BG: "AVAXUSDT", OK: "AVAX-USDT-SWAP"},
|
||||
{Name: "AVNT", BN: "AVNTUSDT", BG: "AVNTUSDT", OK: "AVNT-USDT-SWAP"},
|
||||
{Name: "AXS", BN: "AXSUSDT", BG: "AXSUSDT", OK: "AXS-USDT-SWAP"},
|
||||
{Name: "AZTEC", BN: "AZTECUSDT", BG: "AZTECUSDT", OK: "AZTEC-USDT-SWAP"},
|
||||
{Name: "BABY", BN: "BABYUSDT", BG: "BABYUSDT", OK: "BABY-USDT-SWAP"},
|
||||
{Name: "BANANA", BN: "BANANAUSDT", BG: "BANANAUSDT", OK: "BANANA-USDT-SWAP"},
|
||||
{Name: "BCH", BN: "BCHUSDT", BG: "BCHUSDT", OK: "BCH-USDT-SWAP"},
|
||||
{Name: "BERA", BN: "BERAUSDT", BG: "BERAUSDT", OK: "BERA-USDT-SWAP"},
|
||||
{Name: "BIGTIME", BN: "BIGTIMEUSDT", BG: "BIGTIMEUSDT", OK: "BIGTIME-USDT-SWAP"},
|
||||
{Name: "BIO", BN: "BIOUSDT", BG: "BIOUSDT", OK: "BIO-USDT-SWAP"},
|
||||
{Name: "BLUR", BN: "BLURUSDT", BG: "BLURUSDT", OK: "BLUR-USDT-SWAP"},
|
||||
{Name: "BNB", BN: "BNBUSDT", BG: "BNBUSDT", OK: "BNB-USDT-SWAP"},
|
||||
{Name: "BNT", BN: "BNTUSDT", BG: "BNTUSDT", OK: "BNT-USDT-SWAP"},
|
||||
{Name: "BOME", BN: "BOMEUSDT", BG: "BOMEUSDT", OK: "BOME-USDT-SWAP"},
|
||||
{Name: "BRETT", BN: "BRETTUSDT", BG: "BRETTUSDT", OK: "BRETT-USDT-SWAP"},
|
||||
{Name: "BSV", BN: "BSVUSDT", BG: "BSVUSDT", OK: "BSV-USDT-SWAP"},
|
||||
{Name: "BTC", BN: "BTCUSDT", BG: "BTCUSDT", OK: "BTC-USDT-SWAP"},
|
||||
{Name: "CAKE", BN: "CAKEUSDT", BG: "CAKEUSDT", OK: "CAKE-USDT-SWAP"},
|
||||
{Name: "CATI", BN: "CATIUSDT", BG: "CATIUSDT", OK: "CATI-USDT-SWAP"},
|
||||
{Name: "CC", BN: "CCUSDT", BG: "CCUSDT", OK: "CC-USDT-SWAP"},
|
||||
{Name: "CELO", BN: "CELOUSDT", BG: "CELOUSDT", OK: "CELO-USDT-SWAP"},
|
||||
{Name: "CFX", BN: "CFXUSDT", BG: "CFXUSDT", OK: "CFX-USDT-SWAP"},
|
||||
{Name: "CHILLGUY", BN: "CHILLGUYUSDT", BG: "CHILLGUYUSDT", OK: "CHILLGUY-USDT-SWAP"},
|
||||
{Name: "CHIP", BN: "CHIPUSDT", BG: "CHIPUSDT", OK: "CHIP-USDT-SWAP"},
|
||||
{Name: "COMP", BN: "COMPUSDT", BG: "COMPUSDT", OK: "COMP-USDT-SWAP"},
|
||||
{Name: "CRV", BN: "CRVUSDT", BG: "CRVUSDT", OK: "CRV-USDT-SWAP"},
|
||||
{Name: "CYBER", BN: "CYBERUSDT", BG: "CYBERUSDT", OK: "CYBER-USDT-SWAP"},
|
||||
{Name: "DASH", BN: "DASHUSDT", BG: "DASHUSDT", OK: "DASH-USDT-SWAP"},
|
||||
{Name: "DOOD", BN: "DOODUSDT", BG: "DOODUSDT", OK: "DOOD-USDT-SWAP"},
|
||||
{Name: "DOT", BN: "DOTUSDT", BG: "DOTUSDT", OK: "DOT-USDT-SWAP"},
|
||||
{Name: "DYDX", BN: "DYDXUSDT", BG: "DYDXUSDT", OK: "DYDX-USDT-SWAP"},
|
||||
{Name: "DYM", BN: "DYMUSDT", BG: "DYMUSDT", OK: "DYM-USDT-SWAP"},
|
||||
{Name: "EIGEN", BN: "EIGENUSDT", BG: "EIGENUSDT", OK: "EIGEN-USDT-SWAP"},
|
||||
{Name: "ENA", BN: "ENAUSDT", BG: "ENAUSDT", OK: "ENA-USDT-SWAP"},
|
||||
{Name: "ENS", BN: "ENSUSDT", BG: "ENSUSDT", OK: "ENS-USDT-SWAP"},
|
||||
{Name: "ETC", BN: "ETCUSDT", BG: "ETCUSDT", OK: "ETC-USDT-SWAP"},
|
||||
{Name: "ETH", BN: "ETHUSDT", BG: "ETHUSDT", OK: "ETH-USDT-SWAP"},
|
||||
{Name: "ETHFI", BN: "ETHFIUSDT", BG: "ETHFIUSDT", OK: "ETHFI-USDT-SWAP"},
|
||||
{Name: "FARTCOIN", BN: "FARTCOINUSDT", BG: "FARTCOINUSDT", OK: "FARTCOIN-USDT-SWAP"},
|
||||
{Name: "FET", BN: "FETUSDT", BG: "FETUSDT", OK: "FET-USDT-SWAP"},
|
||||
{Name: "FIL", BN: "FILUSDT", BG: "FILUSDT", OK: "FIL-USDT-SWAP"},
|
||||
{Name: "FOGO", BN: "FOGOUSDT", BG: "FOGOUSDT", OK: "FOGO-USDT-SWAP"},
|
||||
{Name: "GALA", BN: "GALAUSDT", BG: "GALAUSDT", OK: "GALA-USDT-SWAP"},
|
||||
{Name: "GAS", BN: "GASUSDT", BG: "GASUSDT", OK: "GAS-USDT-SWAP"},
|
||||
{Name: "GMT", BN: "GMTUSDT", BG: "GMTUSDT", OK: "GMT-USDT-SWAP"},
|
||||
{Name: "GMX", BN: "GMXUSDT", BG: "GMXUSDT", OK: "GMX-USDT-SWAP"},
|
||||
{Name: "GOAT", BN: "GOATUSDT", BG: "GOATUSDT", OK: "GOAT-USDT-SWAP"},
|
||||
{Name: "GRASS", BN: "GRASSUSDT", BG: "GRASSUSDT", OK: "GRASS-USDT-SWAP"},
|
||||
{Name: "GRIFFAIN", BN: "GRIFFAINUSDT", BG: "GRIFFAINUSDT", OK: "GRIFFAIN-USDT-SWAP"},
|
||||
{Name: "HBAR", BN: "HBARUSDT", BG: "HBARUSDT", OK: "HBAR-USDT-SWAP"},
|
||||
{Name: "HYPE", BN: "HYPEUSDT", BG: "HYPEUSDT", OK: "HYPE-USDT-SWAP"},
|
||||
{Name: "HYPER", BN: "HYPERUSDT", BG: "HYPERUSDT", OK: "HYPER-USDT-SWAP"},
|
||||
{Name: "ICP", BN: "ICPUSDT", BG: "ICPUSDT", OK: "ICP-USDT-SWAP"},
|
||||
{Name: "ILV", BN: "ILVUSDT", BG: "ILVUSDT", OK: "ILV-USDT-SWAP"},
|
||||
{Name: "IMX", BN: "IMXUSDT", BG: "IMXUSDT", OK: "IMX-USDT-SWAP"},
|
||||
{Name: "INIT", BN: "INITUSDT", BG: "INITUSDT", OK: "INIT-USDT-SWAP"},
|
||||
{Name: "INJ", BN: "INJUSDT", BG: "INJUSDT", OK: "INJ-USDT-SWAP"},
|
||||
{Name: "IO", BN: "IOUSDT", BG: "IOUSDT", OK: "IO-USDT-SWAP"},
|
||||
{Name: "IOTA", BN: "IOTAUSDT", BG: "IOTAUSDT", OK: "IOTA-USDT-SWAP"},
|
||||
{Name: "IP", BN: "IPUSDT", BG: "IPUSDT", OK: "IP-USDT-SWAP"},
|
||||
{Name: "JTO", BN: "JTOUSDT", BG: "JTOUSDT", OK: "JTO-USDT-SWAP"},
|
||||
{Name: "JUP", BN: "JUPUSDT", BG: "JUPUSDT", OK: "JUP-USDT-SWAP"},
|
||||
{Name: "KAITO", BN: "KAITOUSDT", BG: "KAITOUSDT", OK: "KAITO-USDT-SWAP"},
|
||||
{Name: "KAS", BN: "KASUSDT", BG: "KASUSDT", OK: "KAS-USDT-SWAP"},
|
||||
{Name: "LAYER", BN: "LAYERUSDT", BG: "LAYERUSDT", OK: "LAYER-USDT-SWAP"},
|
||||
{Name: "LDO", BN: "LDOUSDT", BG: "LDOUSDT", OK: "LDO-USDT-SWAP"},
|
||||
{Name: "LINEA", BN: "LINEAUSDT", BG: "LINEAUSDT", OK: "LINEA-USDT-SWAP"},
|
||||
{Name: "LISTA", BN: "LISTAUSDT", BG: "LISTAUSDT", OK: "LISTA-USDT-SWAP"},
|
||||
{Name: "LIT", BN: "LITUSDT", BG: "LITUSDT", OK: "LIT-USDT-SWAP"},
|
||||
{Name: "LTC", BN: "LTCUSDT", BG: "LTCUSDT", OK: "LTC-USDT-SWAP"},
|
||||
{Name: "MANTA", BN: "MANTAUSDT", BG: "MANTAUSDT", OK: "MANTA-USDT-SWAP"},
|
||||
{Name: "MAV", BN: "MAVUSDT", BG: "MAVUSDT", OK: "MAV-USDT-SWAP"},
|
||||
{Name: "ME", BN: "MEUSDT", BG: "MEUSDT", OK: "ME-USDT-SWAP"},
|
||||
{Name: "MEGA", BN: "MEGAUSDT", BG: "MEGAUSDT", OK: "MEGA-USDT-SWAP"},
|
||||
{Name: "MELANIA", BN: "MELANIAUSDT", BG: "MELANIAUSDT", OK: "MELANIA-USDT-SWAP"},
|
||||
{Name: "MEME", BN: "MEMEUSDT", BG: "MEMEUSDT", OK: "MEME-USDT-SWAP"},
|
||||
{Name: "MERL", BN: "MERLUSDT", BG: "MERLUSDT", OK: "MERL-USDT-SWAP"},
|
||||
{Name: "MET", BN: "METUSDT", BG: "METUSDT", OK: "MET-USDT-SWAP"},
|
||||
{Name: "MINA", BN: "MINAUSDT", BG: "MINAUSDT", OK: "MINA-USDT-SWAP"},
|
||||
{Name: "MON", BN: "MONUSDT", BG: "MONUSDT", OK: "MON-USDT-SWAP"},
|
||||
{Name: "MOODENG", BN: "MOODENGUSDT", BG: "MOODENGUSDT", OK: "MOODENG-USDT-SWAP"},
|
||||
{Name: "MORPHO", BN: "MORPHOUSDT", BG: "MORPHOUSDT", OK: "MORPHO-USDT-SWAP"},
|
||||
{Name: "MOVE", BN: "MOVEUSDT", BG: "MOVEUSDT", OK: "MOVE-USDT-SWAP"},
|
||||
{Name: "NEAR", BN: "NEARUSDT", BG: "NEARUSDT", OK: "NEAR-USDT-SWAP"},
|
||||
{Name: "NEO", BN: "NEOUSDT", BG: "NEOUSDT", OK: "NEO-USDT-SWAP"},
|
||||
{Name: "NIL", BN: "NILUSDT", BG: "NILUSDT", OK: "NIL-USDT-SWAP"},
|
||||
{Name: "NOT", BN: "NOTUSDT", BG: "NOTUSDT", OK: "NOT-USDT-SWAP"},
|
||||
{Name: "NXPC", BN: "NXPCUSDT", BG: "NXPCUSDT", OK: "NXPC-USDT-SWAP"},
|
||||
{Name: "OGN", BN: "OGNUSDT", BG: "OGNUSDT", OK: "OGN-USDT-SWAP"},
|
||||
{Name: "ORDI", BN: "ORDIUSDT", BG: "ORDIUSDT", OK: "ORDI-USDT-SWAP"},
|
||||
{Name: "PAXG", BN: "PAXGUSDT", BG: "PAXGUSDT", OK: "PAXG-USDT-SWAP"},
|
||||
{Name: "PENDLE", BN: "PENDLEUSDT", BG: "PENDLEUSDT", OK: "PENDLE-USDT-SWAP"},
|
||||
{Name: "PENGU", BN: "PENGUUSDT", BG: "PENGUUSDT", OK: "PENGU-USDT-SWAP"},
|
||||
{Name: "PEOPLE", BN: "PEOPLEUSDT", BG: "PEOPLEUSDT", OK: "PEOPLE-USDT-SWAP"},
|
||||
{Name: "PIXEL", BN: "PIXELUSDT", BG: "PIXELUSDT", OK: "PIXEL-USDT-SWAP"},
|
||||
{Name: "PNUT", BN: "PNUTUSDT", BG: "PNUTUSDT", OK: "PNUT-USDT-SWAP"},
|
||||
{Name: "POL", BN: "POLUSDT", BG: "POLUSDT", OK: "POL-USDT-SWAP"},
|
||||
{Name: "POLYX", BN: "POLYXUSDT", BG: "POLYXUSDT", OK: "POLYX-USDT-SWAP"},
|
||||
{Name: "POPCAT", BN: "POPCATUSDT", BG: "POPCATUSDT", OK: "POPCAT-USDT-SWAP"},
|
||||
{Name: "PROVE", BN: "PROVEUSDT", BG: "PROVEUSDT", OK: "PROVE-USDT-SWAP"},
|
||||
{Name: "PUMP", BN: "PUMPUSDT", BG: "PUMPUSDT", OK: "PUMP-USDT-SWAP"},
|
||||
{Name: "PYTH", BN: "PYTHUSDT", BG: "PYTHUSDT", OK: "PYTH-USDT-SWAP"},
|
||||
{Name: "RENDER", BN: "RENDERUSDT", BG: "RENDERUSDT", OK: "RENDER-USDT-SWAP"},
|
||||
{Name: "RESOLV", BN: "RESOLVUSDT", BG: "RESOLVUSDT", OK: "RESOLV-USDT-SWAP"},
|
||||
{Name: "REZ", BN: "REZUSDT", BG: "REZUSDT", OK: "REZ-USDT-SWAP"},
|
||||
{Name: "RSR", BN: "RSRUSDT", BG: "RSRUSDT", OK: "RSR-USDT-SWAP"},
|
||||
{Name: "RUNE", BN: "RUNEUSDT", BG: "RUNEUSDT", OK: "RUNE-USDT-SWAP"},
|
||||
{Name: "S", BN: "SUSDT", BG: "SUSDT", OK: "S-USDT-SWAP"},
|
||||
{Name: "SAGA", BN: "SAGAUSDT", BG: "SAGAUSDT", OK: "SAGA-USDT-SWAP"},
|
||||
{Name: "SAND", BN: "SANDUSDT", BG: "SANDUSDT", OK: "SAND-USDT-SWAP"},
|
||||
{Name: "SEI", BN: "SEIUSDT", BG: "SEIUSDT", OK: "SEI-USDT-SWAP"},
|
||||
{Name: "SKR", BN: "SKRUSDT", BG: "SKRUSDT", OK: "SKR-USDT-SWAP"},
|
||||
{Name: "SKY", BN: "SKYUSDT", BG: "SKYUSDT", OK: "SKY-USDT-SWAP"},
|
||||
{Name: "SNX", BN: "SNXUSDT", BG: "SNXUSDT", OK: "SNX-USDT-SWAP"},
|
||||
{Name: "SOL", BN: "SOLUSDT", BG: "SOLUSDT", OK: "SOL-USDT-SWAP"},
|
||||
{Name: "SOPH", BN: "SOPHUSDT", BG: "SOPHUSDT", OK: "SOPH-USDT-SWAP"},
|
||||
{Name: "SPX", BN: "SPXUSDT", BG: "SPXUSDT", OK: "SPX-USDT-SWAP"},
|
||||
{Name: "STABLE", BN: "STABLEUSDT", BG: "STABLEUSDT", OK: "STABLE-USDT-SWAP"},
|
||||
{Name: "STG", BN: "STGUSDT", BG: "STGUSDT", OK: "STG-USDT-SWAP"},
|
||||
{Name: "STRK", BN: "STRKUSDT", BG: "STRKUSDT", OK: "STRK-USDT-SWAP"},
|
||||
{Name: "STX", BN: "STXUSDT", BG: "STXUSDT", OK: "STX-USDT-SWAP"},
|
||||
{Name: "SUI", BN: "SUIUSDT", BG: "SUIUSDT", OK: "SUI-USDT-SWAP"},
|
||||
{Name: "SUPER", BN: "SUPERUSDT", BG: "SUPERUSDT", OK: "SUPER-USDT-SWAP"},
|
||||
{Name: "SUSHI", BN: "SUSHIUSDT", BG: "SUSHIUSDT", OK: "SUSHI-USDT-SWAP"},
|
||||
{Name: "SYRUP", BN: "SYRUPUSDT", BG: "SYRUPUSDT", OK: "SYRUP-USDT-SWAP"},
|
||||
{Name: "TAO", BN: "TAOUSDT", BG: "TAOUSDT", OK: "TAO-USDT-SWAP"},
|
||||
{Name: "TIA", BN: "TIAUSDT", BG: "TIAUSDT", OK: "TIA-USDT-SWAP"},
|
||||
{Name: "TNSR", BN: "TNSRUSDT", BG: "TNSRUSDT", OK: "TNSR-USDT-SWAP"},
|
||||
{Name: "TON", BN: "TONUSDT", BG: "TONUSDT", OK: "TON-USDT-SWAP"},
|
||||
{Name: "TRB", BN: "TRBUSDT", BG: "TRBUSDT", OK: "TRB-USDT-SWAP"},
|
||||
{Name: "TRUMP", BN: "TRUMPUSDT", BG: "TRUMPUSDT", OK: "TRUMP-USDT-SWAP"},
|
||||
{Name: "TRX", BN: "TRXUSDT", BG: "TRXUSDT", OK: "TRX-USDT-SWAP"},
|
||||
{Name: "TURBO", BN: "TURBOUSDT", BG: "TURBOUSDT", OK: "TURBO-USDT-SWAP"},
|
||||
{Name: "UMA", BN: "UMAUSDT", BG: "UMAUSDT", OK: "UMA-USDT-SWAP"},
|
||||
{Name: "UNI", BN: "UNIUSDT", BG: "UNIUSDT", OK: "UNI-USDT-SWAP"},
|
||||
{Name: "USUAL", BN: "USUALUSDT", BG: "USUALUSDT", OK: "USUAL-USDT-SWAP"},
|
||||
{Name: "VIRTUAL", BN: "VIRTUALUSDT", BG: "VIRTUALUSDT", OK: "VIRTUAL-USDT-SWAP"},
|
||||
{Name: "VVV", BN: "VVVUSDT", BG: "VVVUSDT", OK: "VVV-USDT-SWAP"},
|
||||
{Name: "W", BN: "WUSDT", BG: "WUSDT", OK: "W-USDT-SWAP"},
|
||||
{Name: "WCT", BN: "WCTUSDT", BG: "WCTUSDT", OK: "WCT-USDT-SWAP"},
|
||||
{Name: "WLD", BN: "WLDUSDT", BG: "WLDUSDT", OK: "WLD-USDT-SWAP"},
|
||||
{Name: "WLFI", BN: "WLFIUSDT", BG: "WLFIUSDT", OK: "WLFI-USDT-SWAP"},
|
||||
{Name: "XAI", BN: "XAIUSDT", BG: "XAIUSDT", OK: "XAI-USDT-SWAP"},
|
||||
{Name: "XLM", BN: "XLMUSDT", BG: "XLMUSDT", OK: "XLM-USDT-SWAP"},
|
||||
{Name: "XMR", BN: "XMRUSDT", BG: "XMRUSDT", OK: "XMR-USDT-SWAP"},
|
||||
{Name: "XPL", BN: "XPLUSDT", BG: "XPLUSDT", OK: "XPL-USDT-SWAP"},
|
||||
{Name: "XRP", BN: "XRPUSDT", BG: "XRPUSDT", OK: "XRP-USDT-SWAP"},
|
||||
{Name: "YGG", BN: "YGGUSDT", BG: "YGGUSDT", OK: "YGG-USDT-SWAP"},
|
||||
{Name: "ZEC", BN: "ZECUSDT", BG: "ZECUSDT", OK: "ZEC-USDT-SWAP"},
|
||||
{Name: "ZEN", BN: "ZENUSDT", BG: "ZENUSDT", OK: "ZEN-USDT-SWAP"},
|
||||
{Name: "ZETA", BN: "ZETAUSDT", BG: "ZETAUSDT", OK: "ZETA-USDT-SWAP"},
|
||||
{Name: "ZK", BN: "ZKUSDT", BG: "ZKUSDT", OK: "ZK-USDT-SWAP"},
|
||||
{Name: "ZORA", BN: "ZORAUSDT", BG: "ZORAUSDT", OK: "ZORA-USDT-SWAP"},
|
||||
{Name: "ZRO", BN: "ZROUSDT", BG: "ZROUSDT", OK: "ZRO-USDT-SWAP"},
|
||||
}
|
||||
|
||||
// netProfit calculates net profit % after fees for a complete round trip (entry + exit).
|
||||
// NOTE: Does NOT swap prices — callers (ScanArbWithFees) pass prices in explicit buy/sell order
|
||||
// and try both directions via addPair. Using exchange.CalcNetProfit would double-swap (B#6).
|
||||
func netProfit(buyPrice, sellPrice, buyFee, sellFee float64) float64 {
|
||||
if buyPrice <= 0 || sellPrice <= 0 {
|
||||
return 0
|
||||
}
|
||||
// Entry: buy at buyPrice (pay buyFee), sell short at sellPrice (pay sellFee)
|
||||
cost := buyPrice * (1 + buyFee/100)
|
||||
revenue := sellPrice * (1 - sellFee/100)
|
||||
// Exit: sell long (pay sellFee), buy back short (pay buyFee)
|
||||
// Total fees = 2 * (buyFee + sellFee), first round already in formula above
|
||||
return (revenue/cost - 1)*100 - 2*(buyFee + sellFee)
|
||||
// ThreeExSpread holds the 3-exchange max spread for a single coin.
|
||||
type ThreeExSpread struct {
|
||||
Coin string
|
||||
BnPrice float64
|
||||
OkxPrice float64
|
||||
BgPrice float64
|
||||
SpreadPct float64 // (max-min)/min*100 across 3 exchanges
|
||||
MaxEx string // exchange with highest price
|
||||
MinEx string // exchange with lowest price
|
||||
}
|
||||
|
||||
// ScanBGHL scans coins for arbitrage ONLY between Bitget and HyperLiquid (P3-1).
|
||||
// Returns both directions (BG->HL and HL->BG) sorted by net profit descending.
|
||||
func ScanBGHL(snap map[string]map[string]float64) []*ArbOpportunity {
|
||||
var results []*ArbOpportunity
|
||||
// Scan3Ex computes 3-exchange max spread for all tracked coins.
|
||||
// Requires at least 2 of 3 exchanges to have a price.
|
||||
func Scan3Ex(snap map[string]map[string]float64) []ThreeExSpread {
|
||||
var results []ThreeExSpread
|
||||
|
||||
for _, coin := range TrackedCoins {
|
||||
if coin.BG == "" || coin.HL == "" {
|
||||
continue // skip coins not available on both exchanges
|
||||
}
|
||||
exMap := snap[coin.Name]
|
||||
if exMap == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
bnP := exMap[ExBinance]
|
||||
okxP := exMap[ExOKX]
|
||||
bgP := exMap[ExBitget]
|
||||
hlP := exMap[ExHyperLiquid]
|
||||
if bgP <= 0 || hlP <= 0 {
|
||||
|
||||
// Need at least 2 exchanges
|
||||
count := 0
|
||||
if bnP > 0 { count++ }
|
||||
if okxP > 0 { count++ }
|
||||
if bgP > 0 { count++ }
|
||||
if count < 2 {
|
||||
continue
|
||||
}
|
||||
|
||||
// BG->HL: buy cheap at Bitget, sell expensive at HyperLiquid
|
||||
profitBG := netProfit(bgP, hlP, takerFees[ExBitget], takerFees[ExHyperLiquid])
|
||||
// HL->BG: buy cheap at HyperLiquid, sell expensive at Bitget
|
||||
profitHL := netProfit(hlP, bgP, takerFees[ExHyperLiquid], takerFees[ExBitget])
|
||||
// Find min/max among available prices
|
||||
prices := []struct {
|
||||
ex string
|
||||
p float64
|
||||
}{}
|
||||
if bnP > 0 { prices = append(prices, struct{ ex string; p float64 }{ExBinance, bnP}) }
|
||||
if okxP > 0 { prices = append(prices, struct{ ex string; p float64 }{ExOKX, okxP}) }
|
||||
if bgP > 0 { prices = append(prices, struct{ ex string; p float64 }{ExBitget, bgP}) }
|
||||
|
||||
grossBG := (hlP - bgP) / bgP * 100
|
||||
grossHL := (bgP - hlP) / hlP * 100
|
||||
minP, maxP := prices[0], prices[0]
|
||||
for _, pp := range prices[1:] {
|
||||
if pp.p < minP.p { minP = pp }
|
||||
if pp.p > maxP.p { maxP = pp }
|
||||
}
|
||||
|
||||
results = append(results, &ArbOpportunity{
|
||||
spread := (maxP.p - minP.p) / minP.p * 100
|
||||
|
||||
results = append(results, ThreeExSpread{
|
||||
Coin: coin.Name,
|
||||
Direction: "BG->HL",
|
||||
BuyEx: ExBitget,
|
||||
SellEx: ExHyperLiquid,
|
||||
BuyPrice: bgP,
|
||||
SellPrice: hlP,
|
||||
NetProfit: profitBG,
|
||||
GrossBasis: grossBG,
|
||||
}, &ArbOpportunity{
|
||||
Coin: coin.Name,
|
||||
Direction: "HL->BG",
|
||||
BuyEx: ExHyperLiquid,
|
||||
SellEx: ExBitget,
|
||||
BuyPrice: hlP,
|
||||
SellPrice: bgP,
|
||||
NetProfit: profitHL,
|
||||
GrossBasis: grossHL,
|
||||
BnPrice: bnP,
|
||||
OkxPrice: okxP,
|
||||
BgPrice: bgP,
|
||||
SpreadPct: spread,
|
||||
MaxEx: maxP.ex,
|
||||
MinEx: minP.ex,
|
||||
})
|
||||
}
|
||||
|
||||
// Sort by spread descending
|
||||
sort.Slice(results, func(i, j int) bool {
|
||||
return results[i].NetProfit > results[j].NetProfit
|
||||
return results[i].SpreadPct > results[j].SpreadPct
|
||||
})
|
||||
|
||||
return results
|
||||
|
||||
@@ -50,10 +50,9 @@ if [ "$CLEAN_DB" = true ]; then
|
||||
fi
|
||||
|
||||
# 设置代理(Clash Verge 本地代理,用于 Binance/OKX/Bitget WS 连接)
|
||||
# HyperLiquid 直连,无需代理
|
||||
export HTTPS_PROXY=http://127.0.0.1:7897
|
||||
export HTTP_PROXY=http://127.0.0.1:7897
|
||||
export NO_PROXY="api.hyperliquid.xyz,hyperliquid.xyz,localhost,127.0.0.1"
|
||||
export NO_PROXY="localhost,127.0.0.1"
|
||||
|
||||
# 编译
|
||||
NEED_BUILD=false
|
||||
@@ -63,7 +62,7 @@ elif [ "$FORCE_REBUILD" = true ]; then
|
||||
NEED_BUILD=true
|
||||
elif [ -n "$(find . -name '*.go' -newer "$BIN" 2>/dev/null | head -1)" ]; then
|
||||
NEED_BUILD=true
|
||||
elif [ -n "$(find web/static -newer "$BIN" 2>/dev/null | head -1)" ]; then
|
||||
elif [ -n "$(find frontend/dist -newer "$BIN" 2>/dev/null | head -1)" ]; then
|
||||
NEED_BUILD=true
|
||||
fi
|
||||
|
||||
|
||||
@@ -0,0 +1,336 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"math"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// SurgeDetector detects anomalous 3-exchange max spreads using per-coin adaptive baselines.
|
||||
// Theory: when a coin starts moving sharply, different exchanges update at different speeds,
|
||||
// creating a temporary spike in inter-exchange spread. This detector captures that moment.
|
||||
type SurgeDetector struct {
|
||||
mu sync.Mutex
|
||||
coins map[string]*coinSurgeState
|
||||
|
||||
// Config
|
||||
enabled bool
|
||||
windowSize int // rolling window samples (default: 600 = ~30s at 50ms tick)
|
||||
baselineMul float64 // baseline * N = threshold (default: 3.0)
|
||||
minAbsSpreadPct float64 // minimum absolute spread % to trigger (default: 0.05)
|
||||
cooldownSec int // seconds between alerts for same coin (default: 60)
|
||||
|
||||
// Recent events (ring buffer)
|
||||
events []SurgeEvent
|
||||
eventIdx int
|
||||
maxEvents int
|
||||
|
||||
// DB persistence callback
|
||||
onEvent func(SurgeEvent)
|
||||
}
|
||||
|
||||
// coinSurgeState holds per-coin adaptive baseline data.
|
||||
type coinSurgeState struct {
|
||||
spreads []float64 // rolling window of recent spread values
|
||||
lastAlertAt time.Time
|
||||
}
|
||||
|
||||
// SurgeEvent represents a detected surge anomaly.
|
||||
type SurgeEvent struct {
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
Coin string `json:"coin"`
|
||||
BnPrice float64 `json:"bn_price"`
|
||||
OkxPrice float64 `json:"okx_price"`
|
||||
BgPrice float64 `json:"bg_price"`
|
||||
SpreadPct float64 `json:"spread_pct"` // current 3-exchange max spread
|
||||
BaselinePct float64 `json:"baseline_pct"` // adaptive baseline at time of event
|
||||
ThresholdPct float64 `json:"threshold_pct"` // trigger threshold
|
||||
Ratio float64 `json:"ratio"` // spread / threshold
|
||||
Direction string `json:"direction"` // "up" or "down"
|
||||
LeadingExchange string `json:"leading_exchange"` // which exchange moved first/furthest
|
||||
MidPrice float64 `json:"mid_price"` // median of 3 prices
|
||||
}
|
||||
|
||||
// SurgeSnapshot holds current spread/baseline state for a coin (SSE push).
|
||||
type SurgeSnapshot struct {
|
||||
Coin string `json:"coin"`
|
||||
SpreadPct float64 `json:"spread_pct"`
|
||||
BaselinePct float64 `json:"baseline_pct"`
|
||||
ThresholdPct float64 `json:"threshold_pct"`
|
||||
Direction string `json:"direction,omitempty"` // "up"/"down" if currently surging
|
||||
WindowSize int `json:"window_size"` // current number of samples in window
|
||||
}
|
||||
|
||||
func NewSurgeDetector() *SurgeDetector {
|
||||
return &SurgeDetector{
|
||||
coins: make(map[string]*coinSurgeState),
|
||||
events: make([]SurgeEvent, 200),
|
||||
maxEvents: 200,
|
||||
}
|
||||
}
|
||||
|
||||
// Configure sets detection parameters.
|
||||
func (sd *SurgeDetector) Configure(windowSize int, baselineMul, minAbsSpreadPct float64, cooldownSec int) {
|
||||
sd.enabled = true
|
||||
sd.windowSize = windowSize
|
||||
sd.baselineMul = baselineMul
|
||||
sd.minAbsSpreadPct = minAbsSpreadPct
|
||||
sd.cooldownSec = cooldownSec
|
||||
}
|
||||
|
||||
// SetOnEvent sets the DB persistence callback.
|
||||
func (sd *SurgeDetector) SetOnEvent(fn func(SurgeEvent)) {
|
||||
sd.onEvent = fn
|
||||
}
|
||||
|
||||
// Tick processes one snapshot tick, detecting surges for all coins.
|
||||
// Returns newly detected events for immediate SSE broadcast.
|
||||
func (sd *SurgeDetector) Tick(snap map[string]map[string]float64) []SurgeEvent {
|
||||
if !sd.enabled {
|
||||
return nil
|
||||
}
|
||||
|
||||
sd.mu.Lock()
|
||||
defer sd.mu.Unlock()
|
||||
|
||||
var newEvents []SurgeEvent
|
||||
now := time.Now()
|
||||
|
||||
for _, coin := range TrackedCoins {
|
||||
exMap := snap[coin.Name]
|
||||
if exMap == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
bnP := exMap[ExBinance]
|
||||
okxP := exMap[ExOKX]
|
||||
bgP := exMap[ExBitget]
|
||||
|
||||
// Need at least 2 exchanges
|
||||
prices := []float64{}
|
||||
if bnP > 0 { prices = append(prices, bnP) }
|
||||
if okxP > 0 { prices = append(prices, okxP) }
|
||||
if bgP > 0 { prices = append(prices, bgP) }
|
||||
if len(prices) < 2 {
|
||||
continue
|
||||
}
|
||||
|
||||
// Compute 3-exchange max spread
|
||||
minP, maxP := prices[0], prices[0]
|
||||
for _, p := range prices[1:] {
|
||||
if p < minP { minP = p }
|
||||
if p > maxP { maxP = p }
|
||||
}
|
||||
spread := (maxP - minP) / minP * 100
|
||||
|
||||
// Get or create coin state
|
||||
state, exists := sd.coins[coin.Name]
|
||||
if !exists {
|
||||
state = &coinSurgeState{
|
||||
spreads: make([]float64, 0, sd.windowSize),
|
||||
}
|
||||
sd.coins[coin.Name] = state
|
||||
}
|
||||
|
||||
// Add spread to rolling window
|
||||
state.spreads = append(state.spreads, spread)
|
||||
if len(state.spreads) > sd.windowSize {
|
||||
state.spreads = state.spreads[len(state.spreads)-sd.windowSize:]
|
||||
}
|
||||
|
||||
// Need minimum samples for baseline (at least 10)
|
||||
if len(state.spreads) < 10 {
|
||||
continue
|
||||
}
|
||||
|
||||
// Compute baseline = median of recent spreads
|
||||
baseline := median(state.spreads)
|
||||
|
||||
// Threshold = baseline * multiplier, but at least minAbsSpreadPct
|
||||
threshold := baseline * sd.baselineMul
|
||||
if threshold < sd.minAbsSpreadPct {
|
||||
threshold = sd.minAbsSpreadPct
|
||||
}
|
||||
|
||||
// Check if spread exceeds threshold AND cooldown has passed
|
||||
if spread < threshold {
|
||||
continue
|
||||
}
|
||||
if !state.lastAlertAt.IsZero() && now.Sub(state.lastAlertAt).Seconds() < float64(sd.cooldownSec) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Surge detected — determine direction
|
||||
midPrice := median(prices)
|
||||
var direction, leadingEx string
|
||||
|
||||
// Find max and min exchanges for reporting
|
||||
exPrices := map[string]float64{}
|
||||
if bnP > 0 { exPrices[ExBinance] = bnP }
|
||||
if okxP > 0 { exPrices[ExOKX] = okxP }
|
||||
if bgP > 0 { exPrices[ExBitget] = bgP }
|
||||
|
||||
var maxEx, minEx string
|
||||
var maxVal, minVal float64 = -1, math.MaxFloat64
|
||||
for ex, p := range exPrices {
|
||||
if p > maxVal { maxVal = p; maxEx = ex }
|
||||
if p < minVal { minVal = p; minEx = ex }
|
||||
}
|
||||
|
||||
// Direction: if highest is further from median than lowest → up, else down
|
||||
if (maxVal - midPrice) > (midPrice - minVal) {
|
||||
direction = "up"
|
||||
leadingEx = maxEx
|
||||
} else {
|
||||
direction = "down"
|
||||
leadingEx = minEx
|
||||
}
|
||||
|
||||
ratio := spread / threshold
|
||||
|
||||
event := SurgeEvent{
|
||||
Timestamp: now,
|
||||
Coin: coin.Name,
|
||||
BnPrice: bnP,
|
||||
OkxPrice: okxP,
|
||||
BgPrice: bgP,
|
||||
SpreadPct: math.Round(spread*10000) / 10000,
|
||||
BaselinePct: math.Round(baseline*10000) / 10000,
|
||||
ThresholdPct: math.Round(threshold*10000) / 10000,
|
||||
Ratio: math.Round(ratio*100) / 100,
|
||||
Direction: direction,
|
||||
LeadingExchange: leadingEx,
|
||||
MidPrice: math.Round(midPrice*10000) / 10000,
|
||||
}
|
||||
|
||||
state.lastAlertAt = now
|
||||
newEvents = append(newEvents, event)
|
||||
|
||||
// Store in ring buffer
|
||||
sd.events[sd.eventIdx%sd.maxEvents] = event
|
||||
sd.eventIdx++
|
||||
|
||||
log.Printf("[Surge] %s %s surge detected: spread=%.4f%% baseline=%.4f%% threshold=%.4f%% ratio=%.1fx leading=%s",
|
||||
coin.Name, direction, event.SpreadPct, event.BaselinePct, event.ThresholdPct, event.Ratio, leadingEx)
|
||||
|
||||
// Persist to DB if callback set
|
||||
if sd.onEvent != nil {
|
||||
sd.onEvent(event)
|
||||
}
|
||||
}
|
||||
|
||||
return newEvents
|
||||
}
|
||||
|
||||
// GetRecentEvents returns the most recent N surge events.
|
||||
func (sd *SurgeDetector) GetRecentEvents(n int) []SurgeEvent {
|
||||
sd.mu.Lock()
|
||||
defer sd.mu.Unlock()
|
||||
|
||||
if n <= 0 || n > sd.maxEvents {
|
||||
n = sd.maxEvents
|
||||
}
|
||||
|
||||
total := sd.eventIdx
|
||||
if total > sd.maxEvents {
|
||||
total = sd.maxEvents
|
||||
}
|
||||
|
||||
result := make([]SurgeEvent, 0, total)
|
||||
for i := 0; i < total; i++ {
|
||||
idx := (sd.eventIdx - total + i) % sd.maxEvents
|
||||
if idx < 0 {
|
||||
idx += sd.maxEvents
|
||||
}
|
||||
ev := sd.events[idx]
|
||||
if ev.Coin != "" {
|
||||
result = append(result, ev)
|
||||
}
|
||||
}
|
||||
|
||||
// Return at most n, most recent first
|
||||
if len(result) <= n {
|
||||
// Reverse to get newest first
|
||||
for i, j := 0, len(result)-1; i < j; i, j = i+1, j-1 {
|
||||
result[i], result[j] = result[j], result[i]
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// Take last n and reverse
|
||||
out := make([]SurgeEvent, n)
|
||||
for i := 0; i < n; i++ {
|
||||
out[i] = result[len(result)-1-i]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Snapshot returns current spread/baseline state for all coins (SSE push).
|
||||
func (sd *SurgeDetector) Snapshot() []SurgeSnapshot {
|
||||
sd.mu.Lock()
|
||||
defer sd.mu.Unlock()
|
||||
|
||||
var result []SurgeSnapshot
|
||||
now := time.Now()
|
||||
|
||||
for _, coin := range TrackedCoins {
|
||||
state, exists := sd.coins[coin.Name]
|
||||
if !exists || len(state.spreads) < 10 {
|
||||
continue
|
||||
}
|
||||
|
||||
currentSpread := state.spreads[len(state.spreads)-1]
|
||||
baseline := median(state.spreads)
|
||||
threshold := baseline * sd.baselineMul
|
||||
if threshold < sd.minAbsSpreadPct {
|
||||
threshold = sd.minAbsSpreadPct
|
||||
}
|
||||
|
||||
snap := SurgeSnapshot{
|
||||
Coin: coin.Name,
|
||||
SpreadPct: math.Round(currentSpread*10000) / 10000,
|
||||
BaselinePct: math.Round(baseline*10000) / 10000,
|
||||
ThresholdPct: math.Round(threshold*10000) / 10000,
|
||||
WindowSize: len(state.spreads),
|
||||
}
|
||||
|
||||
// Check if currently surging (within cooldown)
|
||||
if currentSpread >= threshold && !state.lastAlertAt.IsZero() && now.Sub(state.lastAlertAt).Seconds() < float64(sd.cooldownSec) {
|
||||
if state.spreads[len(state.spreads)-1] >= threshold {
|
||||
snap.Direction = "up" // placeholder, real direction calculated in Tick
|
||||
}
|
||||
}
|
||||
|
||||
result = append(result, snap)
|
||||
}
|
||||
|
||||
// Sort by spread descending
|
||||
sort.Slice(result, func(i, j int) bool {
|
||||
return result[i].SpreadPct > result[j].SpreadPct
|
||||
})
|
||||
|
||||
// Limit to top 50
|
||||
if len(result) > 50 {
|
||||
result = result[:50]
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// median computes the median of a slice of float64s.
|
||||
// The input slice is NOT modified.
|
||||
func median(vals []float64) float64 {
|
||||
if len(vals) == 0 {
|
||||
return 0
|
||||
}
|
||||
sorted := make([]float64, len(vals))
|
||||
copy(sorted, vals)
|
||||
sort.Float64s(sorted)
|
||||
n := len(sorted)
|
||||
if n%2 == 1 {
|
||||
return sorted[n/2]
|
||||
}
|
||||
return (sorted[n/2-1] + sorted[n/2]) / 2
|
||||
}
|
||||
@@ -199,14 +199,11 @@ func (td *TrendDetector) Tick() {
|
||||
defer td.mu.Unlock()
|
||||
|
||||
for _, entry := range entries {
|
||||
// Collect 60s changes from all 4 exchanges
|
||||
// Collect 60s changes from all 3 exchanges
|
||||
var changes []exchangeChange
|
||||
if entry.BG60s != 0 {
|
||||
changes = append(changes, exchangeChange{name: ExBitget, change: entry.BG60s})
|
||||
}
|
||||
if entry.HL60s != 0 {
|
||||
changes = append(changes, exchangeChange{name: ExHyperLiquid, change: entry.HL60s})
|
||||
}
|
||||
if entry.BN60s != 0 {
|
||||
changes = append(changes, exchangeChange{name: ExBinance, change: entry.BN60s})
|
||||
}
|
||||
@@ -214,8 +211,8 @@ func (td *TrendDetector) Tick() {
|
||||
changes = append(changes, exchangeChange{name: ExOKX, change: entry.OKX60s})
|
||||
}
|
||||
|
||||
if len(changes) < 3 {
|
||||
continue // need at least 3 exchanges for reliable detection
|
||||
if len(changes) < 2 {
|
||||
continue // need at least 2 exchanges for reliable detection
|
||||
}
|
||||
|
||||
// Compute aggregate stats
|
||||
@@ -287,7 +284,7 @@ func (td *TrendDetector) Tick() {
|
||||
cs.confirmCount = 1
|
||||
cs.misalignCount = 0
|
||||
td.recordEvent(entry.Coin, "idle", "alert", string(majorityDir),
|
||||
zScore, cs.volatility, entry.BG60s, entry.HL60s, entry.BN60s, entry.OKX60s,
|
||||
zScore, cs.volatility, entry.BG60s, 0, entry.BN60s, entry.OKX60s,
|
||||
majorityCount, len(changes))
|
||||
}
|
||||
|
||||
@@ -301,7 +298,7 @@ func (td *TrendDetector) Tick() {
|
||||
cs.confirmedAt = now
|
||||
cs.stateSince = now
|
||||
td.recordEvent(entry.Coin, "alert", "confirmed", string(cs.direction),
|
||||
zScore, cs.volatility, entry.BG60s, entry.HL60s, entry.BN60s, entry.OKX60s,
|
||||
zScore, cs.volatility, entry.BG60s, 0, entry.BN60s, entry.OKX60s,
|
||||
majorityCount, len(changes))
|
||||
}
|
||||
} else {
|
||||
@@ -313,7 +310,7 @@ func (td *TrendDetector) Tick() {
|
||||
cs.confirmCount = 0
|
||||
cs.misalignCount = 0
|
||||
td.recordEvent(entry.Coin, "alert", "idle", string(cs.direction),
|
||||
zScore, cs.volatility, entry.BG60s, entry.HL60s, entry.BN60s, entry.OKX60s,
|
||||
zScore, cs.volatility, entry.BG60s, 0, entry.BN60s, entry.OKX60s,
|
||||
majorityCount, len(changes))
|
||||
}
|
||||
}
|
||||
@@ -325,7 +322,7 @@ func (td *TrendDetector) Tick() {
|
||||
cs.state = TrendExhausting
|
||||
cs.stateSince = now
|
||||
td.recordEvent(entry.Coin, "confirmed", "exhausting", string(cs.direction),
|
||||
zScore, cs.volatility, entry.BG60s, entry.HL60s, entry.BN60s, entry.OKX60s,
|
||||
zScore, cs.volatility, entry.BG60s, 0, entry.BN60s, entry.OKX60s,
|
||||
majorityCount, len(changes))
|
||||
}
|
||||
|
||||
@@ -337,7 +334,7 @@ func (td *TrendDetector) Tick() {
|
||||
cs.confirmCount = 0
|
||||
cs.misalignCount = 0
|
||||
td.recordEvent(entry.Coin, "exhausting", "idle", string(cs.direction),
|
||||
zScore, cs.volatility, entry.BG60s, entry.HL60s, entry.BN60s, entry.OKX60s,
|
||||
zScore, cs.volatility, entry.BG60s, 0, entry.BN60s, entry.OKX60s,
|
||||
majorityCount, len(changes))
|
||||
}
|
||||
// Also immediately go to idle if below threshold
|
||||
@@ -347,7 +344,7 @@ func (td *TrendDetector) Tick() {
|
||||
cs.confirmCount = 0
|
||||
cs.misalignCount = 0
|
||||
td.recordEvent(entry.Coin, "exhausting", "idle", string(cs.direction),
|
||||
zScore, cs.volatility, entry.BG60s, entry.HL60s, entry.BN60s, entry.OKX60s,
|
||||
zScore, cs.volatility, entry.BG60s, 0, entry.BN60s, entry.OKX60s,
|
||||
majorityCount, len(changes))
|
||||
}
|
||||
}
|
||||
@@ -391,13 +388,13 @@ func (td *TrendDetector) Snapshot() []TrendEntry {
|
||||
|
||||
if me, ok := entryMap[coin]; ok {
|
||||
entry.BGChange = me.BG15s
|
||||
entry.HLChange = me.HL15s
|
||||
entry.HLChange = 0
|
||||
entry.BNChange = me.BN15s
|
||||
entry.OKXChange = me.OKX15s
|
||||
|
||||
// Count how many exchanges agree with the trend direction
|
||||
agree := 0
|
||||
changes := []float64{entry.BGChange, entry.HLChange, entry.BNChange, entry.OKXChange}
|
||||
changes := []float64{entry.BGChange, entry.BNChange, entry.OKXChange}
|
||||
for _, c := range changes {
|
||||
if cs.direction == TrendUp && c > 0.001 {
|
||||
agree++
|
||||
|
||||
@@ -484,21 +484,6 @@ func (tf *TrendFilter) computeFilterState(coin string, k1h, k5m []klineData, now
|
||||
return fs
|
||||
}
|
||||
|
||||
// median returns the median value of a sorted copy of the slice.
|
||||
func median(values []float64) float64 {
|
||||
if len(values) == 0 {
|
||||
return 0
|
||||
}
|
||||
sorted := make([]float64, len(values))
|
||||
copy(sorted, values)
|
||||
sort.Float64s(sorted)
|
||||
mid := len(sorted) / 2
|
||||
if len(sorted)%2 == 0 {
|
||||
return (sorted[mid-1] + sorted[mid]) / 2
|
||||
}
|
||||
return sorted[mid]
|
||||
}
|
||||
|
||||
// computeSignalScore calculates the composite signal score (0-100) from a FilterState.
|
||||
// Must be called after FreshAnomaly, PriceAboveEMA, and volume fields are set.
|
||||
func computeSignalScore(fs *FilterState) float64 {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
@@ -11,7 +10,6 @@ type TrackedCoin struct {
|
||||
Name string // Display name (BTC, ETH, etc.)
|
||||
BN string // Binance symbol (BTCUSDT)
|
||||
BG string // Bitget symbol (BTCUSDT)
|
||||
HL string // HyperLiquid symbol (BTC)
|
||||
OK string // OKX symbol (BTC-USDT-SWAP)
|
||||
}
|
||||
|
||||
@@ -109,92 +107,3 @@ func (s *PriceStore) GetAll() map[string]map[string]float64 {
|
||||
}
|
||||
return snap
|
||||
}
|
||||
|
||||
// ArbOpportunity represents a profitable arbitrage route.
|
||||
type ArbOpportunity struct {
|
||||
Coin string
|
||||
Direction string // e.g. "BN->HL"
|
||||
BuyEx string
|
||||
SellEx string
|
||||
BuyPrice float64
|
||||
SellPrice float64
|
||||
NetProfit float64 // percentage after fees
|
||||
GrossBasis float64 // raw price difference %
|
||||
}
|
||||
|
||||
// SpreadWindow tracks how long each coin's spread stays above threshold.
|
||||
// Used to measure the window of opportunity between threshold-crossing and
|
||||
// convergence — helps diagnose whether entry latency is a problem.
|
||||
type SpreadWindow struct {
|
||||
Coin string
|
||||
Direction string // "BG->HL" or "HL->BG"
|
||||
Since time.Time
|
||||
PeakNet float64 // highest netProfit % observed during this window
|
||||
}
|
||||
|
||||
type SpreadWindowTracker struct {
|
||||
windows map[string]*SpreadWindow // key: "COIN:DIRECTION"
|
||||
}
|
||||
|
||||
func NewSpreadWindowTracker() *SpreadWindowTracker {
|
||||
return &SpreadWindowTracker{windows: make(map[string]*SpreadWindow)}
|
||||
}
|
||||
|
||||
func (swt *SpreadWindowTracker) Tick(snap map[string]map[string]float64, threshold float64) {
|
||||
now := time.Now()
|
||||
for _, coin := range TrackedCoins {
|
||||
if coin.BG == "" || coin.HL == "" {
|
||||
continue
|
||||
}
|
||||
exMap := snap[coin.Name]
|
||||
if exMap == nil {
|
||||
continue
|
||||
}
|
||||
bgP := exMap[ExBitget]
|
||||
hlP := exMap[ExHyperLiquid]
|
||||
if bgP <= 0 || hlP <= 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check both directions — use netProfit() for exact fee model match
|
||||
// BG→HL: buy BG (Bitget 0.020%), sell HL (HL 0.015%)
|
||||
// HL→BG: buy HL (HL 0.015%), sell BG (Bitget 0.020%)
|
||||
type dirCheck struct {
|
||||
name string
|
||||
buyPrice float64
|
||||
sellPrice float64
|
||||
buyFee float64
|
||||
sellFee float64
|
||||
}
|
||||
for _, dir := range []dirCheck{
|
||||
{"BG->HL", bgP, hlP, takerFees[ExBitget], takerFees[ExHyperLiquid]},
|
||||
{"HL->BG", hlP, bgP, takerFees[ExHyperLiquid], takerFees[ExBitget]},
|
||||
} {
|
||||
key := coin.Name + ":" + dir.name
|
||||
netSpr := netProfit(dir.buyPrice, dir.sellPrice, dir.buyFee, dir.sellFee)
|
||||
|
||||
w, exists := swt.windows[key]
|
||||
if netSpr >= threshold {
|
||||
if !exists {
|
||||
swt.windows[key] = &SpreadWindow{
|
||||
Coin: coin.Name,
|
||||
Direction: dir.name,
|
||||
Since: now,
|
||||
PeakNet: netSpr,
|
||||
}
|
||||
} else if netSpr > w.PeakNet {
|
||||
w.PeakNet = netSpr
|
||||
}
|
||||
} else {
|
||||
if exists {
|
||||
dur := now.Sub(w.Since)
|
||||
if dur > 100*time.Millisecond {
|
||||
log.Printf("[SpreadWindow] %s %s exceeded threshold for %v (peak net=%+.4f%%)",
|
||||
w.Coin, w.Direction, dur.Round(time.Millisecond), w.PeakNet)
|
||||
}
|
||||
delete(swt.windows, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,420 +0,0 @@
|
||||
/* ============================================================
|
||||
Exchange Monitor Dashboard — Frontend Logic v3 (P3)
|
||||
============================================================ */
|
||||
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
// ---- DOM refs ----
|
||||
const $ = id => document.getElementById(id);
|
||||
|
||||
const els = {
|
||||
clock: $('clock'),
|
||||
connStatus: $('conn-status'),
|
||||
connDetail: $('conn-detail'),
|
||||
pricesAge: $('prices-age'),
|
||||
priceBody: $('price-body'),
|
||||
arbBody: $('arb-body'),
|
||||
posBody: $('positions-body'),
|
||||
tradesBody: $('trades-body'),
|
||||
statTotal: $('stat-total'),
|
||||
statConv: $('stat-converged'),
|
||||
statDiv: $('stat-diverged'),
|
||||
statFlat: $('stat-flat'),
|
||||
statPos: $('stat-positions'),
|
||||
statCoins: $('stat-coins'),
|
||||
};
|
||||
|
||||
// ---- Clock ----
|
||||
function updateClock() {
|
||||
const now = new Date();
|
||||
els.clock.textContent = now.toLocaleTimeString('zh-CN', { hour12: false });
|
||||
}
|
||||
setInterval(updateClock, 1000);
|
||||
updateClock();
|
||||
|
||||
const EXCHANGES = ['HyperLiquid', 'Bitget'];
|
||||
const COINS = []; // populated dynamically from SSE data
|
||||
|
||||
function formatPrice(p) {
|
||||
if (p == null || p <= 0) return '-';
|
||||
if (p >= 100) return p.toFixed(2);
|
||||
if (p >= 1) return p.toFixed(4);
|
||||
return p.toFixed(6);
|
||||
}
|
||||
|
||||
function priceClass(last, cur) {
|
||||
if (last == null || cur == null) return '';
|
||||
return cur > last ? 'text-green' : cur < last ? 'text-red' : '';
|
||||
}
|
||||
|
||||
function pnlClass(val) {
|
||||
if (val == null) return '';
|
||||
return val > 0 ? 'text-green' : val < 0 ? 'text-red' : '';
|
||||
}
|
||||
|
||||
const priceCache = {};
|
||||
|
||||
// ---- SSE Connection ----
|
||||
let eventSource = null;
|
||||
|
||||
function connectSSE() {
|
||||
if (eventSource) eventSource.close();
|
||||
|
||||
eventSource = new EventSource('/events');
|
||||
|
||||
eventSource.addEventListener('connected', () => {
|
||||
els.connStatus.textContent = '● 已连接';
|
||||
els.connStatus.className = 'status-online';
|
||||
});
|
||||
|
||||
eventSource.onerror = () => {
|
||||
els.connStatus.textContent = '● 已断开 (重连中...)';
|
||||
els.connStatus.className = 'status-offline';
|
||||
setTimeout(connectSSE, 3000);
|
||||
};
|
||||
|
||||
eventSource.onmessage = (e) => {
|
||||
try {
|
||||
const msg = JSON.parse(e.data);
|
||||
const handler = eventHandlers[msg.event];
|
||||
if (handler) handler(msg.data);
|
||||
} catch(err) {
|
||||
// ignore parse errors
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// ---- Event Handlers ----
|
||||
const eventHandlers = {};
|
||||
|
||||
eventHandlers.prices = (prices) => {
|
||||
if (!prices || prices.length === 0) return;
|
||||
|
||||
// Dynamically populate COINS list on first data
|
||||
if (COINS.length === 0) {
|
||||
for (const row of prices) {
|
||||
COINS.push(row.coin);
|
||||
}
|
||||
}
|
||||
|
||||
let html = '';
|
||||
let coinsOnline = 0;
|
||||
|
||||
for (const coin of COINS) {
|
||||
const row = prices.find(p => p.coin === coin);
|
||||
if (!row) {
|
||||
html += `<tr><td>${coin}</td>${EXCHANGES.map(() => '<td class="text-dim">-</td>').join('')}<td class="text-dim">-</td></tr>`;
|
||||
continue;
|
||||
}
|
||||
coinsOnline++;
|
||||
|
||||
const cells = EXCHANGES.map(ex => {
|
||||
const p = row[ex];
|
||||
const key = coin + '.' + ex;
|
||||
const prev = priceCache[key];
|
||||
const curP = p || 0;
|
||||
const cls = prev ? priceClass(prev.last, curP) : '';
|
||||
|
||||
if (prev) {
|
||||
prev.last = curP;
|
||||
} else {
|
||||
priceCache[key] = { last: curP };
|
||||
}
|
||||
|
||||
let display = formatPrice(p);
|
||||
return `<td class="${cls}">${display}</td>`;
|
||||
});
|
||||
|
||||
// P3-2: Add spread column
|
||||
const spread = row['bg_hl_spread'];
|
||||
const spreadCls = spread > 0.2 ? 'text-green' : spread < -0.2 ? 'text-red' : '';
|
||||
const spreadStr = spread != null ? spread.toFixed(4) + '%' : '-';
|
||||
|
||||
html += `<tr><td><strong>${coin}</strong></td>${cells.join('')}<td class="${spreadCls}">${spreadStr}</td></tr>`;
|
||||
}
|
||||
|
||||
els.priceBody.innerHTML = html;
|
||||
els.pricesAge.textContent = new Date().toLocaleTimeString('zh-CN', { hour12: false });
|
||||
};
|
||||
|
||||
eventHandlers.arb = (opps) => {
|
||||
if (!opps || opps.length === 0) {
|
||||
els.arbBody.innerHTML = '<tr><td colspan="5" class="text-dim">暂无套利机会</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
const html = opps.slice(0, 10).map(opp => {
|
||||
const cls = opp.net_profit > 0.10 ? 'text-green' : opp.net_profit > 0.05 ? 'text-yellow' : '';
|
||||
return `<tr>
|
||||
<td>${opp.coin}</td>
|
||||
<td>${opp.direction}</td>
|
||||
<td class="text-right">${formatPrice(opp.buy_price)}</td>
|
||||
<td class="text-right">${formatPrice(opp.sell_price)}</td>
|
||||
<td class="text-right ${cls}"><strong>${opp.net_profit.toFixed(4)}</strong></td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
|
||||
els.arbBody.innerHTML = html;
|
||||
};
|
||||
|
||||
// P3-3: Positions with live PnL — sorted by time (oldest first)
|
||||
eventHandlers.positions = (positions) => {
|
||||
if (!positions || positions.length === 0) {
|
||||
els.posBody.innerHTML = '<tr><td colspan="8" class="text-dim">无持仓</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Sort by coin name (stable, deterministic)
|
||||
const sorted = [...positions].sort((a, b) => a.coin.localeCompare(b.coin));
|
||||
|
||||
const html = sorted.map(p => {
|
||||
const pnl = p.pnl_est;
|
||||
const pnlStr = pnl != null ? pnl.toFixed(4) + '%' : '-';
|
||||
const curSpread = p.current_spread != null ? p.current_spread.toFixed(4) + '%' : '-';
|
||||
return `<tr>
|
||||
<td><strong>${p.coin}</strong></td>
|
||||
<td>${p.direction}</td>
|
||||
<td class="text-right">$${p.amount_usd.toFixed(0)}</td>
|
||||
<td class="text-right">${p.entry_spread.toFixed(4)}%</td>
|
||||
<td class="text-right">${curSpread}</td>
|
||||
<td class="text-right ${pnlClass(pnl)}"><strong>${pnlStr}</strong></td>
|
||||
<td class="text-right">${p.scales}</td>
|
||||
<td>${p.duration}</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
|
||||
els.posBody.innerHTML = html;
|
||||
};
|
||||
|
||||
// P3-5: Connection status in stats
|
||||
eventHandlers.stats = (stats) => {
|
||||
els.statTotal.textContent = stats.total_trades || 0;
|
||||
els.statConv.textContent = stats.converged || 0;
|
||||
els.statDiv.textContent = stats.diverged || 0;
|
||||
els.statFlat.textContent = stats.flat || 0;
|
||||
els.statPos.textContent = stats.open_positions || 0;
|
||||
els.statCoins.textContent = stats.coins || 0;
|
||||
|
||||
// Detailed PnL stats
|
||||
if (stats.detail) {
|
||||
const d = stats.detail;
|
||||
// Total PnL: show both USD and percentage of capital
|
||||
const usdStr = (d.total_pnl_usd != null) ? '$' + d.total_pnl_usd.toFixed(2) : '—';
|
||||
const pctStr = (d.capital_pnl != null) ? d.capital_pnl.toFixed(4) + '%' : '—';
|
||||
$('stat-total-pnl').textContent = usdStr + ' (' + pctStr + ')';
|
||||
$('stat-total-pnl').className = pnlClass(d.capital_pnl);
|
||||
$('stat-capital').textContent = (stats.capital != null) ? '$' + stats.capital.toFixed(0) : '—';
|
||||
$('stat-win-rate').textContent = (d.win_rate != null) ? d.win_rate.toFixed(1) + '%' : '—';
|
||||
$('stat-max-profit').textContent = (d.max_profit != null) ? '+' + d.max_profit.toFixed(2) + '%' : '—';
|
||||
$('stat-max-loss').textContent = (d.max_loss != null) ? d.max_loss.toFixed(2) + '%' : '—';
|
||||
$('stat-avg-dur').textContent = d.avg_dur || '—';
|
||||
}
|
||||
|
||||
// Connection status dots
|
||||
if (stats.connections) {
|
||||
const dots = Object.entries(stats.connections).map(([ex, status]) => {
|
||||
const color = status === 'online' ? '#3fb950' : status === 'stale' ? '#d29922' : '#f85149';
|
||||
return `<span style="color:${color}">●</span> ${ex}`;
|
||||
}).join(' ');
|
||||
els.connDetail.innerHTML = dots;
|
||||
}
|
||||
|
||||
// Blacklist — stale spread coins
|
||||
if (stats.blacklist && stats.blacklist.length > 0) {
|
||||
const html = stats.blacklist.map(b => {
|
||||
const minLeft = Math.floor(b.remaining_sec / 60);
|
||||
const secLeft = b.remaining_sec % 60;
|
||||
return `<span style="color:#f85149;margin-right:12px;font-size:13px">⛔ ${b.coin} (${b.since} 剩余 ${minLeft}:${secLeft.toString().padStart(2,'0')})</span>`;
|
||||
}).join('');
|
||||
$('bl-body').innerHTML = html;
|
||||
} else {
|
||||
$('bl-body').innerHTML = '<span class="text-dim">暂无</span>';
|
||||
}
|
||||
};
|
||||
|
||||
// P3-4: Real-time trade events
|
||||
eventHandlers.trade_open = (trade) => {
|
||||
// Flash the positions card to draw attention
|
||||
const card = $('positions-card');
|
||||
card.style.transition = 'border-color 0.3s';
|
||||
card.style.borderColor = '#3fb950';
|
||||
setTimeout(() => { card.style.borderColor = ''; }, 2000);
|
||||
// Refresh trades table
|
||||
setTimeout(loadTrades, 500);
|
||||
};
|
||||
|
||||
eventHandlers.trade_close = (trade) => {
|
||||
const card = $('trades-card');
|
||||
card.style.transition = 'border-color 0.3s';
|
||||
card.style.borderColor = trade.pnl_pct > 0 ? '#3fb950' : '#f85149';
|
||||
setTimeout(() => { card.style.borderColor = ''; }, 2000);
|
||||
setTimeout(loadTrades, 500);
|
||||
};
|
||||
|
||||
// ---- Trades from API ----
|
||||
async function loadTrades() {
|
||||
try {
|
||||
const resp = await fetch('/api/trades');
|
||||
const data = await resp.json();
|
||||
const trades = data.trades || [];
|
||||
|
||||
if (trades.length === 0) {
|
||||
els.tradesBody.innerHTML = '<tr><td colspan="8" class="text-dim">暂无交易记录</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
const html = trades.slice(0, 20).map(t => {
|
||||
const pnlCls = t.NetPnl > 0 ? 'text-green' : t.NetPnl < 0 ? 'text-red' : '';
|
||||
const convCls = t.Convergence === '价差收敛' ? 'text-green' :
|
||||
t.Convergence === '价差发散' ? 'text-red' : 'text-yellow';
|
||||
return `<tr class="trade-row" data-id="${t.ID}" onclick="openTradeDetail(${t.ID})">
|
||||
<td class="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 class="text-right">${t.EntrySpread != null ? t.EntrySpread.toFixed(4) : '-'}</td>
|
||||
<td class="text-right">${t.ExitSpread != null ? t.ExitSpread.toFixed(4) : '-'}</td>
|
||||
<td class="text-right ${pnlCls}"><strong>${t.NetPnl != null ? t.NetPnl.toFixed(4) : '-'}</strong></td>
|
||||
<td class="${convCls}">${t.Convergence || '-'}</td>
|
||||
<td>${t.ExitReason || '-'}</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
|
||||
els.tradesBody.innerHTML = html;
|
||||
} catch (err) {
|
||||
els.tradesBody.innerHTML = '<tr><td colspan="8" class="text-red">加载失败</td></tr>';
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Trade Detail Modal ----
|
||||
function openTradeDetail(id) {
|
||||
const modal = document.getElementById('trade-modal');
|
||||
const body = document.getElementById('trade-detail-body');
|
||||
modal.style.display = 'flex';
|
||||
body.innerHTML = '<div class="loading">加载中...</div>';
|
||||
|
||||
fetch('/api/trade/' + id)
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
const t = data.trade;
|
||||
if (!t || !t.ID) {
|
||||
body.innerHTML = '<div class="loading text-red">交易数据加载失败</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const opened = new Date(t.OpenedAt);
|
||||
const closed = t.ClosedAt ? new Date(t.ClosedAt) : null;
|
||||
const dur = closed ? Math.round((closed - opened) / 1000) + 's' : '-';
|
||||
const pnlCls = t.NetPnl > 0 ? 'text-green' : t.NetPnl < 0 ? 'text-red' : '';
|
||||
|
||||
const feeEntry = t.FeeEntry != null ? t.FeeEntry.toFixed(3) + '%' : '-';
|
||||
const feeExit = t.FeeExit != null ? t.FeeExit.toFixed(3) + '%' : '-';
|
||||
const totalFee = t.FeeEntry != null && t.FeeExit != null
|
||||
? (t.FeeEntry + t.FeeExit).toFixed(3) + '%' : '-';
|
||||
|
||||
const le = t.LongEntry != null ? t.LongEntry.toFixed(6) : '-';
|
||||
const lx = t.LongExit != null ? t.LongExit.toFixed(6) : '-';
|
||||
const se = t.ShortEntry != null ? t.ShortEntry.toFixed(6) : '-';
|
||||
const sx = t.ShortExit != null ? t.ShortExit.toFixed(6) : '-';
|
||||
const lpnl = t.LongPnl != null ? t.LongPnl.toFixed(4) + '%' : '-';
|
||||
const spnl = t.ShortPnl != null ? t.ShortPnl.toFixed(4) + '%' : '-';
|
||||
|
||||
body.innerHTML = `<div class="detail-grid">
|
||||
<div class="detail-section">
|
||||
<h3>概览</h3>
|
||||
<div class="detail-row"><span class="label">币种</span><span class="value"><strong>${t.Coin}</strong>/USDT</span></div>
|
||||
<div class="detail-row"><span class="label">方向</span><span class="value">${t.Direction || '-'}</span></div>
|
||||
<div class="detail-row"><span class="label">状态</span><span class="value">${t.Status === 'closed' ? '已平仓' : t.Status}</span></div>
|
||||
<div class="detail-row"><span class="label">加仓次数</span><span class="value">${t.ScaleCount || 0} 次</span></div>
|
||||
<div class="detail-row"><span class="label">总规模</span><span class="value">$${(t.AmountUSD || 0).toFixed(0)}</span></div>
|
||||
</div>
|
||||
<div class="detail-section">
|
||||
<h3>时间</h3>
|
||||
<div class="detail-row"><span class="label">开仓</span><span class="value">${opened.toLocaleString('zh-CN', { hour12: false })}</span></div>
|
||||
<div class="detail-row"><span class="label">平仓</span><span class="value">${closed ? closed.toLocaleString('zh-CN', { hour12: false }) : '-'}</span></div>
|
||||
<div class="detail-row"><span class="label">持仓时长</span><span class="value">${dur}</span></div>
|
||||
</div>
|
||||
<div class="detail-section">
|
||||
<h3>价差</h3>
|
||||
<div class="detail-row"><span class="label">入场价差</span><span class="value">${t.EntrySpread != null ? t.EntrySpread.toFixed(4) + '%' : '-'}</span></div>
|
||||
<div class="detail-row"><span class="label">出场价差</span><span class="value">${t.ExitSpread != null ? t.ExitSpread.toFixed(4) + '%' : '-'}</span></div>
|
||||
<div class="detail-row"><span class="label">收敛情况</span><span class="value ${t.Convergence === '价差收敛' ? 'text-green' : t.Convergence === '价差发散' ? 'text-red' : ''}">${t.Convergence || '-'}</span></div>
|
||||
<div class="detail-row"><span class="label">平仓原因</span><span class="value">${t.ExitReason || '-'}</span></div>
|
||||
</div>
|
||||
<div class="detail-section">
|
||||
<h3>手续费</h3>
|
||||
<div class="detail-row"><span class="label">开仓费</span><span class="value">${feeEntry}</span></div>
|
||||
<div class="detail-row"><span class="label">平仓费</span><span class="value">${feeExit}</span></div>
|
||||
<div class="detail-row"><span class="label">总手续费</span><span class="value">${totalFee}</span></div>
|
||||
</div>
|
||||
<div class="detail-section">
|
||||
<h3>多仓 ${t.LongExchange || '-'}</h3>
|
||||
<div class="detail-row"><span class="label">入场价</span><span class="value">$${le}</span></div>
|
||||
<div class="detail-row"><span class="label">出场价</span><span class="value">$${lx}</span></div>
|
||||
<div class="detail-row"><span class="label">盈亏</span><span class="value ${t.LongPnl > 0 ? 'text-green' : t.LongPnl < 0 ? 'text-red' : ''}">${lpnl}</span></div>
|
||||
</div>
|
||||
<div class="detail-section">
|
||||
<h3>空仓 ${t.ShortExchange || '-'}</h3>
|
||||
<div class="detail-row"><span class="label">入场价</span><span class="value">$${se}</span></div>
|
||||
<div class="detail-row"><span class="label">出场价</span><span class="value">$${sx}</span></div>
|
||||
<div class="detail-row"><span class="label">盈亏</span><span class="value ${t.ShortPnl > 0 ? 'text-green' : t.ShortPnl < 0 ? 'text-red' : ''}">${spnl}</span></div>
|
||||
</div>
|
||||
<div class="detail-section detail-section-full">
|
||||
<h3>净收益</h3>
|
||||
<div class="detail-row" style="font-size:16px"><span class="label">总计</span><span class="value ${pnlCls}" style="font-weight:700">${t.NetPnl != null ? t.NetPnl.toFixed(4) + '%' : '-'}</span></div>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
// Append orders table if available
|
||||
if (data.orders && data.orders.length > 0) {
|
||||
const ordersHtml = data.orders.map(o => {
|
||||
const typeLabel = o.Type === 'entry' ? '开仓' : o.Type === 'exit' ? '平仓' : o.Type === 'scale' ? '加仓' : o.Type;
|
||||
return `<tr><td>${typeLabel}</td><td>${o.Side === 'buy' ? '买' : '卖'}</td><td>${o.Exchange}</td><td>$${o.Price ? o.Price.toFixed(6) : '-'}</td><td>${o.Size || '-'}</td><td>${o.Fee ? o.Fee.toFixed(4) + '%' : '-'}</td><td>${o.OrderID ? o.OrderID.substring(0, 12) + '...' : '-'}</td></tr>`;
|
||||
}).join('');
|
||||
body.innerHTML += `<div class="detail-section detail-section-full" style="border-top:1px solid var(--border)">
|
||||
<h3>订单明细 (${data.orders.length})</h3>
|
||||
<table class="detail-orders">
|
||||
<thead><tr><th>类型</th><th>方向</th><th>交易所</th><th>价格</th><th>数量</th><th>手续费</th><th>订单ID</th></tr></thead>
|
||||
<tbody>${ordersHtml}</tbody>
|
||||
</table>
|
||||
</div>`;
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
body.innerHTML = '<div class="loading text-red">加载失败: ' + err.message + '</div>';
|
||||
});
|
||||
}
|
||||
|
||||
function closeTradeDetail() {
|
||||
document.getElementById('trade-modal').style.display = 'none';
|
||||
}
|
||||
|
||||
// Close modal on overlay click
|
||||
document.addEventListener('click', function(e) {
|
||||
const modal = document.getElementById('trade-modal');
|
||||
if (e.target === modal) closeTradeDetail();
|
||||
});
|
||||
|
||||
// Close on Escape
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape') closeTradeDetail();
|
||||
});
|
||||
|
||||
// Expose modal functions to global scope for HTML onclick handlers
|
||||
window.openTradeDetail = openTradeDetail;
|
||||
window.closeTradeDetail = closeTradeDetail;
|
||||
|
||||
// ---- Init ----
|
||||
function init() {
|
||||
connectSSE();
|
||||
loadTrades();
|
||||
setInterval(loadTrades, 10000);
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
|
||||
})();
|
||||
@@ -1,132 +0,0 @@
|
||||
<!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>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<header>
|
||||
<h1>⚡ 跨交易所套利监控</h1>
|
||||
<div class="header-meta">
|
||||
<span id="clock">--:--:--</span>
|
||||
<span class="sep">|</span>
|
||||
<span id="conn-status" class="status-offline">● 未连接</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="grid">
|
||||
<!-- Stats Summary -->
|
||||
<section class="card" id="stats-card">
|
||||
<h2>📊 统计数据</h2>
|
||||
<div class="stats-row">
|
||||
<div class="stat"><label>总交易</label><span id="stat-total">0</span></div>
|
||||
<div class="stat"><label>收敛</label><span id="stat-converged" class="pct-green">0</span></div>
|
||||
<div class="stat"><label>发散</label><span id="stat-diverged" class="pct-red">0</span></div>
|
||||
<div class="stat"><label>持平</label><span id="stat-flat" class="pct-gray">0</span></div>
|
||||
<div class="stat"><label>持仓</label><span id="stat-positions" class="pct-yellow">0 / <span id="stat-max-pos">5</span></span></div>
|
||||
<div class="stat"><label>币种</label><span id="stat-coins" class="pct-blue">0</span></div>
|
||||
<div class="stat" id="conn-stats"><label>连接</label><span id="conn-detail"></span></div>
|
||||
</div>
|
||||
<!-- Detailed PnL stats -->
|
||||
<div class="stats-row detail-stats" style="margin-top:4px;font-size:12px;opacity:0.85">
|
||||
<div class="stat"><label>总PnL</label><span id="stat-total-pnl">—</span></div>
|
||||
<div class="stat"><label>本金</label><span id="stat-capital">—</span></div>
|
||||
<div class="stat"><label>胜率</label><span id="stat-win-rate">—</span></div>
|
||||
<div class="stat"><label>最多盈利</label><span id="stat-max-profit">—</span></div>
|
||||
<div class="stat"><label>最多亏损</label><span id="stat-max-loss">—</span></div>
|
||||
<div class="stat"><label>平均持仓</label><span id="stat-avg-dur">—</span></div>
|
||||
</div>
|
||||
</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 -->
|
||||
<section class="card" id="bl-card">
|
||||
<h2>⛔ 黑名单</h2>
|
||||
<div class="stats-row" id="bl-body">
|
||||
<span class="text-dim">暂无</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Price Table -->
|
||||
<section class="card" id="prices-card">
|
||||
<h2>💰 实时价格 <span id="prices-age" class="text-dim" style="font-size:11px"></span></h2>
|
||||
<div class="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">
|
||||
<tr><td colspan="4" class="loading">等待数据...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Arbitrage Opportunities -->
|
||||
<section class="card" id="arb-card">
|
||||
<h2>🎯 套利机会 (BG↔HL)</h2>
|
||||
<div class="table-wrap">
|
||||
<table id="arb-table">
|
||||
<thead>
|
||||
<tr><th>币种</th><th>方向</th><th>买价</th><th>卖价</th><th>净利%</th></tr>
|
||||
</thead>
|
||||
<tbody id="arb-body">
|
||||
<tr><td colspan="5" class="loading">等待数据...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Recent Trades -->
|
||||
<section class="card card-wide" id="trades-card">
|
||||
<h2>📋 历史交易</h2>
|
||||
<div class="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">
|
||||
<tr><td colspan="8" class="loading">等待数据...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Trade Detail Modal -->
|
||||
<div id="trade-modal" class="modal-overlay" style="display:none">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h2>📋 交易详情</h2>
|
||||
<button class="modal-close" onclick="closeTradeDetail()">✕</button>
|
||||
</div>
|
||||
<div id="trade-detail-body">
|
||||
<div class="loading">加载中...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/static/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,220 +0,0 @@
|
||||
/* ============================================================
|
||||
Exchange Monitor Dashboard — Dark Theme
|
||||
============================================================ */
|
||||
|
||||
:root {
|
||||
--bg: #0d1117;
|
||||
--card: #161b22;
|
||||
--border: #30363d;
|
||||
--text: #c9d1d9;
|
||||
--text-dim: #8b949e;
|
||||
--accent: #58a6ff;
|
||||
--green: #3fb950;
|
||||
--red: #f85149;
|
||||
--yellow: #d29922;
|
||||
--blue: #58a6ff;
|
||||
}
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
#app { max-width: 1440px; margin: 0 auto; padding: 16px; }
|
||||
|
||||
/* Header */
|
||||
header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
header h1 { font-size: 18px; font-weight: 600; }
|
||||
.header-meta { display: flex; align-items: center; gap: 8px; font-size: 13px; color: var(--text-dim); }
|
||||
.sep { color: var(--border); }
|
||||
.status-offline { color: var(--red); }
|
||||
.status-online { color: var(--green); }
|
||||
|
||||
/* Grid layout */
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
.card-wide { grid-column: 1 / -1; }
|
||||
|
||||
/* Cards */
|
||||
.card {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.card h2 {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-dim);
|
||||
margin-bottom: 10px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* Stats row */
|
||||
.stats-row {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
min-width: 60px;
|
||||
}
|
||||
.stat label { font-size: 11px; color: var(--text-dim); margin-bottom: 2px; }
|
||||
.stat span { font-size: 20px; font-weight: 700; }
|
||||
.pct-green { color: var(--green); }
|
||||
.pct-red { color: var(--red); }
|
||||
.pct-gray { color: var(--text-dim); }
|
||||
.pct-yellow { color: var(--yellow); }
|
||||
.pct-blue { color: var(--blue); }
|
||||
|
||||
/* Connection status dots */
|
||||
#conn-details { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
|
||||
#conn-detail { font-size: 11px; white-space: nowrap; }
|
||||
#conn-detail span { margin-right: 4px; font-size: 10px; }
|
||||
|
||||
/* Tables */
|
||||
.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; }
|
||||
}
|
||||
|
||||
/* 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; }
|
||||
Reference in New Issue
Block a user