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:
jackyu66git
2026-05-08 02:01:18 +08:00
co-authored by Claude Opus 4.6
parent 559d7bb870
commit d38782490c
36 changed files with 1201 additions and 6374 deletions
+15 -28
View File
@@ -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
+52 -44
View File
@@ -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.
+55 -69
View File
@@ -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 异动信号记录 |
+36 -174
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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)
+19
View File
@@ -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 {
+58
View File
@@ -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()
}
-325
View File
@@ -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
}
-279
View File
@@ -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)
}
}
-66
View File
@@ -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()
}
-266
View File
@@ -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")
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
View File
@@ -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}
+2 -2
View File
@@ -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
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -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
)
-123
View File
@@ -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))
}
+37 -125
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 APIsummary, trades, prices, config
3. 前端 index.html:概览页(指标卡片 + 当前持仓 + 最近成交)
Phase 3 — 实时
1. SSE stream:价格、持仓、交易实时推送
2. Chart.js 实时价差折线图
Phase 4 — 完善
1. 交易记录页(分页、筛选、详情弹窗)
2. 配置页(在线修改参数)
3. 连接状态页
4. PnL 曲线图
```
+223 -229
View File
@@ -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
+2 -3
View File
@@ -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
+336
View File
@@ -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
}
-2118
View File
File diff suppressed because it is too large Load Diff
+11 -14
View File
@@ -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++
-15
View File
@@ -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 {
-91
View File
@@ -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)
}
}
}
}
}
-420
View File
@@ -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();
}
})();
-132
View File
@@ -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>
-220
View File
@@ -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; }