feat: 所有参数移至config.json, 重构退出逻辑, 清理遗留接口

- 将所有硬编码参数迁移到 config.json (手续费率、止盈/止损阈值、
  超时、腿间隔、加仓步进等)
- 退出条件: 净利 >= take_profit_pct 止盈, 价差 <= 0 平仓
- 删除 Binance/dYdX 遗留代码
- 更新 README 文档
- Dashboard: 双交易所价格表、黑名单UI、按币名排序持仓
- Bitget WS: 文本ping保活
- 数据库: 重置, 无历史仓位
This commit is contained in:
jackyu66git
2026-05-04 01:46:17 +08:00
parent 2ed6ffc747
commit 21a3f9a962
10 changed files with 450 additions and 206 deletions
+98 -100
View File
@@ -1,35 +1,28 @@
# Exchange Monitor Go
Cross-exchange perpetual futures arbitrage scanner and automated trading system. Monitors real-time prices from 4 exchanges via **WebSocket**, identifies **Bitget ↔ HyperLiquid** arbitrage opportunities, executes maker-fee trades, and provides a real-time Web dashboard.
Cross-exchange perpetual futures arbitrage monitoring and automated trading system. Tracks **Bitget ↔ HyperLiquid** spread in real time, executes simulated trades at configurable thresholds.
## Architecture
```
┌──────────────┐
┌──────────────┤ Binance │◄──── bookTicker WS (price reference)
└──────────────┘
│ ┌──────────────┐
│──────────────┤ Bitget │◄──── ticker WS (trading exchange)
│ └──────────────┘
│ Bitget │◄──── ticker WS (trading exchange)
└──────────────┘
PriceStore ─────────┼──────────────┤
│ HyperLiquid │◄──── webData2 WS (trading exchange)
│ └──────────────┘
│ ┌──────────────┐
└──────────────┤ dYdX │◄──── v4_markets WS (price reference)
│ HyperLiquid │◄──── webData2 WS (trading exchange)
└──────────────┘
┌─────────▼─────────┐
│ ScanBGHL (50ms)
│ ScanBGHL (200ms) │
│ BG ↔ HL only │
└─────────┬─────────┘
┌───────────────▼────────────────┐
│ Trader │
│ TryEntry (async goroutine) │
│ → placeOrder (REST)
│ → placeOrder (REST/mock)
│ Tick / Exit / Scale-in │
Maker fees only
│ NO display/stat calculations │
Config-driven thresholds
└───────────────┬────────────────┘
┌─────────▼─────────┐
@@ -41,136 +34,143 @@ PriceStore ─────────┼─────────────
│ │ │
┌─────▼─────┐ ┌────────▼───────┐ ┌─────▼─────┐
│ Notifier │ │ Dashboard │ │ DB │
│ TG │ │ :8888 │ │ SQLite │
│ Telegram │ │ :8888 │ │ SQLite │
│ │ │ Stats calc │ │ trades.db │
│ │ │ (calcDetailed) │ │ │
│ │ │ Blacklist UI │ │ │
└───────────┘ └────────────────┘ └───────────┘
```
## Tracked Coins
| Coin | Binance | Bitget | HyperLiquid | dYdX |
|:----:|:--------:|:---------:|:-----------:|:--------:|
| DOGE | DOGEUSDT | DOGEUSDT | DOGE | DOGE |
| LINK | LINKUSDT | LINKUSDT | LINK | LINK |
| ONDO | ONDOUSDT | ONDOUSDT | ONDO | ONDO |
| OP | OPUSDT | OPUSDT | OP | OP |
| WIF | WIFUSDT | WIFUSDT | WIF | WIF |
| ARB | ARBUSDT | ARBUSDT | ARB | ARB |
| Coin | Bitget | HyperLiquid |
|:----:|:---------:|:-----------:|
| DOGE | DOGEUSDT | DOGE |
| LINK | LINKUSDT | LINK |
| ONDO | ONDOUSDT | ONDO |
| OP | OPUSDT | OP |
| WIF | WIFUSDT | WIF |
| ARB | ARBUSDT | ARB |
- **Bitget / HyperLiquid** — trading exchanges (limit orders with maker fees)
- **Binance / dYdX** — price reference only
> **Note:** Binance and dYdX have been removed — only Bitget and HyperLiquid are monitored.
## Requirements
- Go 1.25+
- WebSocket connectivity to all 4 exchanges
- WebSocket connectivity to Bitget and HyperLiquid
## Quick Start
```bash
cd exchange-monitor-go
go build -o exchange-monitor .
# Edit .env to configure (token, threshold, etc.)
# Edit config.json to set parameters
./exchange-monitor
```
Or use the startup script (auto-compiles if sources changed, cleans stale port):
```bash
./start.sh
```
Then open [http://localhost:8888](http://localhost:8888) for the Web dashboard.
## Configuration (.env)
## Configuration
| Variable | Code Default | Description |
|:---------|:------------:|:------------|
| `TELEGRAM_BOT_TOKEN` | — | Telegram bot token for notifications |
| `TELEGRAM_CHAT_ID` | — | Target chat ID for notifications |
| `TRADE_ENABLED` | `false` | Enable real trading (`1` to enable) |
| `TRADE_THRESHOLD` | `0.15` | Min net profit % to enter (after fees) |
| `TRADE_AMOUNT_USD` | `10` | USD per leg |
| `TRADE_COOLDOWN_MS` | `30000` | Cooldown between same-coin trades (ms) |
| `TEST_MODE` | `false` | Simulate orders with mock fills (no real API calls) |
| `MOCK_SLIPPAGE_PCT` | `0.005` | Simulated slippage per leg (%) |
| `BITGET_API_KEY` / `BITGET_API_SECRET` / `BITGET_PASSPHRASE` | — | Bitget API credentials (test mode skips) |
| `HL_PRIVATE_KEY` / `HL_ADDRESS` | — | HyperLiquid wallet credentials (test mode skips) |
### config.json (all trading parameters)
> **Note:** Scan interval is fixed at **50ms** (not configurable). Order sizes round DOWN (`math.Floor`) on both Bitget and HyperLiquid for consistency.
All numerical parameters are defined in `config.json`**no need to edit Go source**:
| Parameter | Default | Description |
|:----------|:-------:|:------------|
| `test_mode` | `true` | Simulate orders with mock fills (no real API calls) |
| `trade_enabled` | `true` | Enable automated trading |
| `scan_interval_ms` | `200` | Scanner loop interval (ms) |
| `arb_threshold` | `0.03` | Min net profit % to trigger alert |
| `trade_threshold` | `0.10` | Min net profit % to execute trade |
| `trade_amount_usd` | `5` | USD per leg (per order) |
| `trade_cooldown_ms` | `30000` | Cooldown between same-coin trades (ms) |
| `max_positions` | `5` | Maximum concurrent open positions |
| `initial_capital` | `1000` | Starting capital in USD (for PnL %) |
| `mock_slippage_pct` | `0.005` | Simulated slippage per leg (%) |
| `blacklist_duration_sec` | `3600` | Coin blacklist duration (seconds) |
| `taker_fee_bitget` | `0.060` | Bitget taker fee rate (%) |
| `taker_fee_hyperliquid` | `0.045` | HyperLiquid taker fee rate (%) |
| `take_profit_pct` | `0.20` | Net profit % threshold for take-profit |
| `spread_reverse_exit_pct` | `0` | Spread convergence/reversal exit (0 = exit when ≤ 0) |
| `position_timeout_sec` | `1800` | Max position hold time before auto-close (30 min) |
| `leg_delay_ms` | `300` | Delay between placing long and short legs |
| `reversal_tolerance_pct` | `0.1` | Price movement tolerance for entry sanity check |
| `scale_step_pct` | `0.10` | Spread widening % to trigger each scale-in level |
| `scale_cooldown_sec` | `5` | Minimum seconds between scale-ins |
### .env (secrets only)
Secrets (API keys) go in `.env` — never checked into git:
| Variable | Description |
|:---------|:------------|
| `TELEGRAM_BOT_TOKEN` | Telegram bot token for notifications |
| `TELEGRAM_CHAT_ID` | Target chat ID for notifications |
| `BITGET_API_KEY` | Bitget API key (skipped if test_mode) |
| `BITGET_API_SECRET` | Bitget API secret |
| `BITGET_PASSPHRASE` | Bitget passphrase |
| `HL_PRIVATE_KEY` | HyperLiquid ed25519 private key hex |
| `HL_ADDRESS` | HyperLiquid wallet address |
> **Priority:** `.env` vars > `config.json` > code defaults.
## Fee Model
All trades use **maker** (limit orders), no rebate. Only Bitget and HyperLiquid are used for trading:
All trades use **taker** (market orders). Only Bitget and HyperLiquid:
| Exchange | Maker | Taker |
|:---------|:-----:|:-----:|
| Bitget | 0.020% | 0.040% |
| HyperLiquid | 0.015% | 0.035% |
| Exchange | Taker Fee |
|:---------|:---------:|
| Bitget | configurable (`taker_fee_bitget`, default 0.060%) |
| HyperLiquid | configurable (`taker_fee_hyperliquid`, default 0.045%) |
Round trip (2 legs entry + 2 legs exit): **0.07%** total fees.
Round trip (2 legs entry + 2 legs exit): configurable, default **0.21%** total fees.
## Trading Logic
1. **Scanner** runs every 50ms, checks all 6 coins for BG ↔ HL spread
2. **Entry** when net profit ≥ `TRADE_THRESHOLD` (after full round-trip fees)
- Uses scan-time prices directly (no re-read from store to avoid WS jitter)
1. **Scanner** runs every `scan_interval_ms`, checks all coins for BG ↔ HL spread
2. **Entry** when net profit ≥ `trade_threshold` (after full round-trip fees)
- Uses scan-time prices directly (no re-read to avoid WS jitter)
- **Async goroutine** — `TryEntry` returns immediately, `executeEntry` runs in background
- Direction sanity check (0.1% tolerance) prevents entry on reversed spreads
- `entering` status prevents `checkExit` / `checkScaleIn` during leg placement
- `entering` map prevents duplicate entries on the same coin
3. **Scale-in** adds another leg-worth when spread widens another 0.10%
- Reversal tolerance check prevents entry on flipped spreads
- `entering` map prevents duplicate entries on same coin
3. **Scale-in** adds another leg-worth when spread widens another `scale_step_pct` (default 0.10%)
4. **Exit** conditions (whichever hits first):
- Spread converges to ≤ 0.02% → **价差收敛,止盈平仓**
- Spread reverses below -0.02%**价差反转,止盈平仓**
- Position held over 30 minutes**超时平仓**
- **Net profit ≥ `take_profit_pct`** → **利润止盈**
- **Spread converges to ≤ 0 (prices equal or reversed)****价差收敛止盈**
- **Position held > `position_timeout_sec`****超时平仓**
5. **Direction**: BG → HL (buy BG, sell HL) or HL → BG (buy HL, sell BG)
## Blacklist Mechanism
- Positions held open for > 10 minutes without converging are auto-closed and blacklisted
- Blacklisted coins are skipped for `blacklist_duration_sec` (default 1 hour)
- Blacklist state visible on the dashboard
## Spread Window Monitoring
`SpreadWindowTracker` runs every scan tick and measures how long each coin's spread stays above the trade threshold:
- Records window **start time** when netProfit first hits threshold
- Tracks real **peak netProfit** during the window
- Logs window **duration + peak** when spread converges below threshold (sub-100ms windows filtered as noise)
- Records window **start time** when net profit first hits threshold
- Tracks real **peak net profit** during the window
- Logs window **duration + peak** when spread converges below threshold
- Covers both directions (BG→HL and HL→BG) independently
- Uses the same `netProfit()` fee model as the scanner for exact consistency
Log output example:
```
[SpreadWindow] ONDO BG->HL exceeded threshold for 1.4s (peak net=+0.1520%)
[SpreadWindow] OP HL->BG exceeded threshold for 3.2s (peak net=+0.1310%)
```
## Notifications
All notifications sent to Telegram (via `TELEGRAM_BOT_TOKEN`):
- **开仓** — entry notification with prices, direction, spread, amount
- **平仓** — exit notification with PnL breakdown, fees, convergence analysis
- **每小时** — summary of open positions (duration, amount)
- Uses HTML parse mode for bold formatting
## Web Dashboard
Built-in HTTP server at `:8888` with real-time SSE push (1-second refresh):
- **Price table** — live prices from all exchanges with bid-ask spread
- **BG↔HL spread** — per-coin arbitrage spread with chart
- **Open positions** — live PnL estimate, scaling level, duration
- **Arb scan results** — current arbitrage opportunities
- **Price table** — live prices from both exchanges
- **BG↔HL spread** — per-coin arbitrage spread
- **Open positions** — live PnL estimate, scaling level, duration, sorting by coin
- **Trade history** — past trades with detail view
- **Blacklist** — currently blacklisted coins and remaining time
- **Connection status** — exchange health (online / stale / offline)
- Charts rendered via Chart.js (loaded from CDN)
## DB & Persistence
- SQLite at `data/trades.db`
- Tracks open positions across restarts
- SQLite at current directory (auto-deleted on each restart in test mode)
- Tracks open positions across restarts (`restoreOpenPositions`)
- Stores all closed trades with full PnL details
- Historical stats merged with in-memory session stats on startup
## Signals
@@ -184,23 +184,21 @@ Built-in HTTP server at `:8888` with real-time SSE push (1-second refresh):
```
exchange-monitor-go/
├── main.go # Entry point, WS startup, main loop
├── config.go # .env + config.json hierarchical config
├── config.go # config.json + .env hierarchical config
├── config.json # All trading parameters (editable)
├── types.go # PriceStore, TrackedCoin, ArbOpportunity
├── scanner.go # ScanBGHL — arbitrage scanner
├── trader.go # Position management, entry/exit/scale-in (pure trading logic)
├── dashboard.go # Web server + SSE + stats calc (display layer)
├── trader.go # Position management, entry/exit/scale-in
├── dashboard.go # Web server + SSE + stats calc
├── toaster.go # Telegram notifications
├── static.go # Embedded web static files
├── start.sh # Startup script (port check + build + run)
├── .env # Local configuration (TELEGRAM, API keys, thresholds)
├── .env # Secrets only (API keys)
├── exchange/
│ ├── connector.go # Generic WS connector with reconnect
│ ├── binance.go # Binance bookTicker WS
│ ├── hyperliquid.go # HyperLiquid webData2 WS
│ ├── hyperliquid_trade.go # HL REST trade API
│ ├── bitget.go # Bitget ticker WS
│ ├── bitget.go # Bitget ticker WS (TextPing for stability)
│ ├── bitget_trade.go # Bitget REST trade API
│ ├── dydx.go # dYdX v4_markets WS
│ ├── helpers.go # Package helpers
│ └── ping.go # Accessibility check tools
├── db/
+118 -1
View File
@@ -4,6 +4,7 @@ import (
"encoding/json"
"os"
"strconv"
"time"
)
// Config holds all system configuration.
@@ -18,13 +19,37 @@ type Config struct {
// Automated trading
TradeEnabled bool
TradeThreshold float64 // minimum profit % to execute trade
TradeAmountUSD float64 // amount per trade in USDT
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)
// 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
SpreadReverseExitPct float64 // spread reversal % threshold for exit
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
// Bitget API
BitgetAPIKey string
BitgetAPISecret string
@@ -46,6 +71,22 @@ type jsonConfig struct {
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"`
// 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"`
SpreadReverseExitPct float64 `json:"spread_reverse_exit_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 {
@@ -89,10 +130,30 @@ func LoadConfig() *Config {
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),
SpreadReverseExitPct: getFloat("SPREAD_REVERSE_EXIT_PCT", jsonCfg.SpreadReverseExitPct),
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,
BitgetAPIKey: getEnv("BITGET_API_KEY", ""),
BitgetAPISecret: getEnv("BITGET_API_SECRET", ""),
BitgetPassphrase: getEnv("BITGET_PASSPHRASE", ""),
@@ -111,6 +172,24 @@ func loadJSONConfig() jsonConfig {
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
SpreadReverseExitPct: 0.02, // 0.02% spread reversal exit
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
}
data, err := os.ReadFile("config.json")
@@ -145,6 +224,44 @@ func loadJSONConfig() jsonConfig {
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
}
if cfg.TakerFeeHyperLiquid != 0 {
def.TakerFeeHyperLiquid = cfg.TakerFeeHyperLiquid
}
if cfg.TakeProfitPct != 0 {
def.TakeProfitPct = cfg.TakeProfitPct
}
if cfg.SpreadReverseExitPct != 0 {
def.SpreadReverseExitPct = cfg.SpreadReverseExitPct
}
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
}
// Boolean fields: zero default is false, so use OR logic
// When JSON has true → true || false = true (override)
+14 -2
View File
@@ -3,9 +3,21 @@
"trade_enabled": true,
"arb_threshold": 0.03,
"scan_interval_ms": 200,
"trade_threshold": 0.05,
"trade_threshold": 0.10,
"trade_amount_usd": 5,
"trade_cooldown_ms": 30000,
"alert_cooldown_sec": 300,
"mock_slippage_pct": 0.005
"mock_slippage_pct": 0.005,
"max_positions": 5,
"blacklist_duration_sec": 3600,
"initial_capital": 1000,
"taker_fee_bitget": 0.060,
"taker_fee_hyperliquid": 0.045,
"take_profit_pct": 0.20,
"spread_reverse_exit_pct": 0,
"position_timeout_sec": 1800,
"leg_delay_ms": 300,
"reversal_tolerance_pct": 0.1,
"scale_step_pct": 0.10,
"scale_cooldown_sec": 5
}
+30 -6
View File
@@ -253,7 +253,8 @@ func (d *Dashboard) Run() {
// DetailedStats holds aggregated PnL and duration statistics.
type DetailedStats struct {
TotalTrades int `json:"total_trades"`
TotalPnlPct float64 `json:"total_pnl_pct"`
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"`
@@ -266,7 +267,7 @@ type DetailedStats struct {
// calcDetailedStats computes trading statistics from a slice of closed trades.
// This is a pure function — no dependency on Trader internals.
func calcDetailedStats(trades []TradeRecord) DetailedStats {
func calcDetailedStats(trades []TradeRecord, initialCapital float64) DetailedStats {
ds := DetailedStats{}
if len(trades) == 0 {
return ds
@@ -275,7 +276,7 @@ func calcDetailedStats(trades []TradeRecord) DetailedStats {
ds.MaxLossPct = 1e9 // sentinel
for _, tr := range trades {
ds.TotalTrades++
ds.TotalPnlPct += tr.PnlPct
ds.TotalPnlUSD += tr.PnlUSD
if tr.PnlPct >= 0 {
ds.WinningTrades++
if tr.PnlPct > ds.MaxProfitPct {
@@ -295,7 +296,8 @@ func calcDetailedStats(trades []TradeRecord) DetailedStats {
ds.MaxLossPct = 0
}
if ds.TotalTrades > 0 {
ds.AvgPnlPct = ds.TotalPnlPct / float64(ds.TotalTrades)
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 {
@@ -362,6 +364,7 @@ func (d *Dashboard) broadcastLoop() {
"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(),
}
// Calculate live PnL from current prices — use weighted average for scale-ins
@@ -420,7 +423,7 @@ func (d *Dashboard) broadcastLoop() {
// 4. Stats + connection status (P3-5)
converged, diverged, flat, total := d.trader.GetClosedStats()
detail := calcDetailedStats(d.trader.GetClosedTrades())
detail := calcDetailedStats(d.trader.GetClosedTrades(), d.trader.cfg.InitialCapital)
stats := map[string]interface{}{
"total_trades": total,
"converged": converged,
@@ -428,10 +431,12 @@ func (d *Dashboard) broadcastLoop() {
"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": detail.TotalPnlPct,
"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,
@@ -459,6 +464,25 @@ func (d *Dashboard) broadcastLoop() {
d.connMu.RUnlock()
stats["connections"] = connInfo
// 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)
}
}
-3
View File
@@ -90,9 +90,6 @@ func (b *BitgetWS) Run(updateFn func(coin string, price, bid, ask float64)) erro
if evt, _ := generic["event"].(string); evt == "error" {
log.Printf("[Bitget WS] Subscribe error response: %s", string(msg))
return
} else if evt == "subscribe" {
log.Printf("[Bitget WS] Subscribe confirmed: %s", string(msg))
return
}
}
+9 -14
View File
@@ -34,6 +34,10 @@ func main() {
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)
@@ -58,9 +62,8 @@ func main() {
// P3-4: wire real-time trade event broadcast
trader.OnTradeEvent = dashboard.BroadcastEvent
if trader.IsConfigured() {
modeLabel := trader.ModeLabel()
log.Printf("[Trader] %s mode: automated trading ENABLED (threshold >= %.2f%%, $%.0f/trade)",
modeLabel, cfg.TradeThreshold, cfg.TradeAmountUSD)
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)
}
@@ -76,24 +79,18 @@ func main() {
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, os.Interrupt, syscall.SIGUSR1)
// Collect symbols — skip exchanges the coin isn't listed on
var bnSymbols, bgSymbols, hlSymbols, dydxSymbols []string
// Collect symbols — only BG and HL for now (BN, dYdX disabled)
var bgSymbols, hlSymbols []string
for _, c := range TrackedCoins {
if c.BN != "" {
bnSymbols = append(bnSymbols, c.BN)
}
if c.BG != "" {
bgSymbols = append(bgSymbols, c.BG)
}
if c.HL != "" {
hlSymbols = append(hlSymbols, c.HL)
}
if c.HL != "" {
dydxSymbols = append(dydxSymbols, c.HL)
}
}
// Start all exchange WS connections
// Start exchange WS connections (BG + HL only)
startExchange := func(name string, runner func(func(string, float64, float64, float64)) error) {
go func() {
for {
@@ -112,10 +109,8 @@ func main() {
}()
}
startExchange("Binance", exchange.NewBinanceWS(bnSymbols).Run)
startExchange("HyperLiquid", exchange.NewHyperLiquidWS(hlSymbols).Run)
startExchange("Bitget", exchange.NewBitgetWS(bgSymbols).Run)
startExchange("dYdX", exchange.NewDydxWS(dydxSymbols).Run) // B#9: use dedicated symbol list
log.Println("[Monitor] Waiting for initial data...")
time.Sleep(10 * time.Second)
+1 -9
View File
@@ -13,7 +13,7 @@ const (
// Taker fee rates (%) — for IOC market orders on trading exchanges
var takerFees = map[string]float64{
ExHyperLiquid: 0.045,
ExBitget: 0.030,
ExBitget: 0.060,
}
// TickerCoins defines all coins we monitor.
@@ -52,13 +52,11 @@ var TrackedCoins = []TrackedCoin{
{Name: "BIO", BN: "", BG: "BIOUSDT", HL: "BIO"},
{Name: "BLUR", BN: "", BG: "BLURUSDT", HL: "BLUR"},
{Name: "BNB", BN: "", BG: "BNBUSDT", HL: "BNB"},
{Name: "BNT", BN: "", BG: "BNTUSDT", HL: "BNT"},
{Name: "BOME", BN: "", BG: "BOMEUSDT", HL: "BOME"},
{Name: "BRETT", BN: "", BG: "BRETTUSDT", HL: "BRETT"},
{Name: "BSV", BN: "", BG: "BSVUSDT", HL: "BSV"},
{Name: "BTC", BN: "", BG: "BTCUSDT", HL: "BTC"},
{Name: "CAKE", BN: "", BG: "CAKEUSDT", HL: "CAKE"},
{Name: "CATI", BN: "", BG: "CATIUSDT", HL: "CATI"},
{Name: "CC", BN: "", BG: "CCUSDT", HL: "CC"},
{Name: "CELO", BN: "", BG: "CELOUSDT", HL: "CELO"},
{Name: "CFX", BN: "", BG: "CFXUSDT", HL: "CFX"},
@@ -66,7 +64,6 @@ var TrackedCoins = []TrackedCoin{
{Name: "CHIP", BN: "", BG: "CHIPUSDT", HL: "CHIP"},
{Name: "COMP", BN: "", BG: "COMPUSDT", HL: "COMP"},
{Name: "CRV", BN: "", BG: "CRVUSDT", HL: "CRV"},
{Name: "CYBER", BN: "", BG: "CYBERUSDT", HL: "CYBER"},
{Name: "DASH", BN: "", BG: "DASHUSDT", HL: "DASH"},
{Name: "DOOD", BN: "", BG: "DOODUSDT", HL: "DOOD"},
{Name: "DOT", BN: "", BG: "DOTUSDT", HL: "DOT"},
@@ -93,7 +90,6 @@ var TrackedCoins = []TrackedCoin{
{Name: "HYPE", BN: "", BG: "HYPEUSDT", HL: "HYPE"},
{Name: "HYPER", BN: "", BG: "HYPERUSDT", HL: "HYPER"},
{Name: "ICP", BN: "", BG: "ICPUSDT", HL: "ICP"},
{Name: "ILV", BN: "", BG: "ILVUSDT", HL: "ILV"},
{Name: "IMX", BN: "", BG: "IMXUSDT", HL: "IMX"},
{Name: "INIT", BN: "", BG: "INITUSDT", HL: "INIT"},
{Name: "INJ", BN: "", BG: "INJUSDT", HL: "INJ"},
@@ -107,7 +103,6 @@ var TrackedCoins = []TrackedCoin{
{Name: "LAYER", BN: "", BG: "LAYERUSDT", HL: "LAYER"},
{Name: "LDO", BN: "", BG: "LDOUSDT", HL: "LDO"},
{Name: "LINEA", BN: "", BG: "LINEAUSDT", HL: "LINEA"},
{Name: "LISTA", BN: "", BG: "LISTAUSDT", HL: "LISTA"},
{Name: "LIT", BN: "", BG: "LITUSDT", HL: "LIT"},
{Name: "LTC", BN: "", BG: "LTCUSDT", HL: "LTC"},
{Name: "MANTA", BN: "", BG: "MANTAUSDT", HL: "MANTA"},
@@ -129,13 +124,11 @@ var TrackedCoins = []TrackedCoin{
{Name: "NIL", BN: "", BG: "NILUSDT", HL: "NIL"},
{Name: "NOT", BN: "", BG: "NOTUSDT", HL: "NOT"},
{Name: "NXPC", BN: "", BG: "NXPCUSDT", HL: "NXPC"},
{Name: "OGN", BN: "", BG: "OGNUSDT", HL: "OGN"},
{Name: "ORDI", BN: "", BG: "ORDIUSDT", HL: "ORDI"},
{Name: "PAXG", BN: "", BG: "PAXGUSDT", HL: "PAXG"},
{Name: "PENDLE", BN: "", BG: "PENDLEUSDT", HL: "PENDLE"},
{Name: "PENGU", BN: "", BG: "PENGUUSDT", HL: "PENGU"},
{Name: "PEOPLE", BN: "", BG: "PEOPLEUSDT", HL: "PEOPLE"},
{Name: "PIXEL", BN: "", BG: "PIXELUSDT", HL: "PIXEL"},
{Name: "PNUT", BN: "", BG: "PNUTUSDT", HL: "PNUT"},
{Name: "POL", BN: "", BG: "POLUSDT", HL: "POL"},
{Name: "POLYX", BN: "", BG: "POLYXUSDT", HL: "POLYX"},
@@ -160,7 +153,6 @@ var TrackedCoins = []TrackedCoin{
{Name: "SPX", BN: "", BG: "SPXUSDT", HL: "SPX"},
{Name: "STABLE", BN: "", BG: "STABLEUSDT", HL: "STABLE"},
{Name: "STBL", BN: "", BG: "STBLUSDT", HL: "STBL"},
{Name: "STG", BN: "", BG: "STGUSDT", HL: "STG"},
{Name: "STRK", BN: "", BG: "STRKUSDT", HL: "STRK"},
{Name: "STX", BN: "", BG: "STXUSDT", HL: "STX"},
{Name: "SUI", BN: "", BG: "SUIUSDT", HL: "SUI"},
+118 -39
View File
@@ -129,6 +129,7 @@ type Trader struct {
positions map[string]*ArbPosition // coin -> position
entering map[string]bool // coin -> being entered (async goroutine)
lastTradeTime map[string]time.Time
blacklist map[string]time.Time // coin -> when blacklisted (stale spread)
closedTrades []TradeRecord // history of closed trades (current session)
// Historical stats loaded from DB on startup — combined with session stats in GetClosedStats
@@ -148,6 +149,7 @@ type TradeRecord struct {
EntrySpread float64
ExitSpread float64
PnlPct float64
PnlUSD float64 // absolute PnL in USD
Convergence string // "收敛", "发散", "持平"
Reason string // exit reason
Duration string
@@ -172,6 +174,7 @@ func NewTrader(cfg *Config, database *db.DB) *Trader {
positions: make(map[string]*ArbPosition),
entering: make(map[string]bool),
lastTradeTime: make(map[string]time.Time),
blacklist: make(map[string]time.Time),
}
// Restore open positions from DB on restart
@@ -236,6 +239,7 @@ func (t *Trader) Tick(store *PriceStore, notifier *Notifier) {
lowP, highP = hlP, bgP
}
diffPct := (highP - lowP) / lowP * 100
elapsed := time.Since(pos.StartedAt)
// Retry close for positions that failed to close on previous attempt
if pos.Status == "close_failed" {
@@ -248,6 +252,12 @@ func (t *Trader) Tick(store *PriceStore, notifier *Notifier) {
// Check exit: if spread converged, take profit
t.checkExit(pos, bgP, hlP, diffPct, notifier)
// Blacklist: if position still open after 10 minutes without converging,
// the spread is likely stale data. Add coin to blacklist and force close.
if pos.Status == "open" && elapsed > 10*time.Minute {
t.blacklistCoin(pos, notifier)
}
}
}
@@ -275,7 +285,19 @@ func (t *Trader) TryEntry(opp *ArbOpportunity, store *PriceStore, notifier *Noti
t.mu.Unlock()
return false
}
if lastTime, ok := t.lastTradeTime[opp.Coin]; ok && time.Since(lastTime) < 30*time.Second {
if t.cfg.MaxPositions > 0 && len(t.positions)+len(t.entering) >= t.cfg.MaxPositions {
t.mu.Unlock()
return false
}
if blTime, bl := t.blacklist[opp.Coin]; bl {
if t.cfg.BlacklistDuration <= 0 || time.Since(blTime) < t.cfg.BlacklistDuration {
t.mu.Unlock()
return false
}
// Blacklist expired — remove it and allow re-entry
delete(t.blacklist, opp.Coin)
}
if lastTime, ok := t.lastTradeTime[opp.Coin]; ok && time.Since(lastTime) < time.Duration(t.cfg.TradeCooldownMs)*time.Millisecond {
t.mu.Unlock()
return false
}
@@ -313,10 +335,11 @@ func (t *Trader) executeEntry(opp *ArbOpportunity, store *PriceStore, notifier *
currBg := exMap[ExBitget]
currHl := exMap[ExHyperLiquid]
if currBg > 0 && currHl > 0 {
if opp.BuyEx == ExBitget && currHl <= currBg*0.999 {
reversalMul := 1 - t.cfg.ReversalTolerancePct/100
if opp.BuyEx == ExBitget && currHl <= currBg*reversalMul {
return false // reversed beyond small tolerance
}
if opp.BuyEx == ExHyperLiquid && currBg <= currHl*0.999 {
if opp.BuyEx == ExHyperLiquid && currBg <= currHl*reversalMul {
return false
}
}
@@ -368,7 +391,7 @@ func (t *Trader) executeEntry(opp *ArbOpportunity, store *PriceStore, notifier *
t.cleanup(pos.Coin)
return false
}
time.Sleep(300 * time.Millisecond)
time.Sleep(t.cfg.LegDelay)
if err := t.placeOrder(pos.ShortLeg, "sell", store); err != "" {
// Leg1 placed successfully, leg2 failed — try to close leg1
if closeErr := t.closeLeg(pos.LongLeg); closeErr != "" {
@@ -434,14 +457,14 @@ func (t *Trader) checkScaleIn(pos *ArbPosition, bgP, hlP, diffPct float64, store
}
}
scaleStep := 0.10 // add every 0.10% wider
scaleStep := t.cfg.ScaleStepPct // add every X% wider
nextLevel := float64(pos.ScaleLevels+1) * scaleStep
if diffPct < entryDiff+nextLevel {
return
}
// Cooldown: at least 5 seconds between scales
if time.Since(pos.LastScaleAt) < 5*time.Second {
// Cooldown: use configured interval between scales
if time.Since(pos.LastScaleAt) < t.cfg.ScaleCooldown {
return
}
@@ -457,7 +480,7 @@ func (t *Trader) checkScaleIn(pos *ArbPosition, bgP, hlP, diffPct float64, store
log.Printf("[Trader] %s: Scale-in long failed: %s", pos.Coin, err)
return
}
time.Sleep(300 * time.Millisecond)
time.Sleep(t.cfg.LegDelay)
if err := t.placeOrderAt(pos.ShortLeg, "sell", store, shortPrice); err != "" {
log.Printf("[Trader] %s: Scale-in short failed: %s — position partially scaled (long only)", pos.Coin, err)
// Don't close the long leg — the scale-in long order was placed but the
@@ -476,41 +499,14 @@ func (t *Trader) checkScaleIn(pos *ArbPosition, bgP, hlP, diffPct float64, store
pos.Coin, pos.ScaleLevels, diffPct, entryDiff, pos.AmountUSD)
}
// checkExit closes position when spread converges.
// checkExit closes position when net profit >= 0.20% (take profit)
// or spread reversed past -0.02% (stop loss) or timeout.
func (t *Trader) checkExit(pos *ArbPosition, bgP, hlP, diffPct float64, notifier *Notifier) {
if pos.Status != "open" {
return
}
// Exit when spread converges to near zero (<= 0.02%)
// Or if held too long (30 min timeout)
elapsed := time.Since(pos.StartedAt)
shouldExit := false
exitReason := ""
if diffPct <= 0.02 {
shouldExit = true
exitReason = "价差收敛,止盈平仓"
}
// Stop-loss: spread reversed (went negative)
if diffPct < -0.02 {
shouldExit = true
exitReason = "价差反转,止盈平仓"
}
if elapsed > 30*time.Minute {
shouldExit = true
exitReason = "超时平仓"
}
if !shouldExit {
return
}
// Calculate P&L — use weighted average entry for scale-in positions
// Each scale adds cfg.TradeAmountUSD at the scale price
// Current prices for P&L calculation
var longCurrent, shortCurrent float64
if pos.LongLeg.Exchange == ExBitget {
longCurrent, shortCurrent = bgP, hlP
@@ -527,6 +523,33 @@ func (t *Trader) checkExit(pos *ArbPosition, bgP, hlP, diffPct float64, notifier
totalFees := 2 * (takerFees[ExBitget] + takerFees[ExHyperLiquid]) // 开仓 + 平仓手续费
netPnl := longPnl + shortPnl - totalFees
elapsed := time.Since(pos.StartedAt)
shouldExit := false
exitReason := ""
// Take profit: net profit >= configured threshold
if netPnl >= t.cfg.TakeProfitPct {
shouldExit = true
exitReason = "利润止盈"
}
// Exit when spread converges to zero or reverses (prices same or flipped)
if diffPct <= 0 {
shouldExit = true
exitReason = "价差收敛止盈"
}
// Timeout: configured max hold time
if elapsed > t.cfg.PositionTimeout {
shouldExit = true
exitReason = "超时平仓"
}
if !shouldExit {
return
}
// Convergence analysis
convergedPct := (pos.EntrySpread - diffPct) / pos.EntrySpread * 100
convergenceLabel := "价差收敛"
@@ -579,6 +602,7 @@ func (t *Trader) checkExit(pos *ArbPosition, bgP, hlP, diffPct float64, notifier
EntrySpread: pos.EntrySpread,
ExitSpread: diffPct,
PnlPct: netPnl,
PnlUSD: pos.AmountUSD * netPnl / 100,
Convergence: convergenceLabel,
Reason: exitReason,
Duration: elapsed.Round(time.Second).String(),
@@ -927,6 +951,10 @@ func (t *Trader) restoreOpenPositions() {
return
}
for i := range openTrades {
if t.cfg.MaxPositions > 0 && len(t.positions) >= t.cfg.MaxPositions {
log.Printf("[Trader] Skipping restored position %s (max_positions=%d reached)", openTrades[i].Coin, t.cfg.MaxPositions)
break
}
tr := &openTrades[i]
// Recreate position structure from DB record
pos := &ArbPosition{
@@ -958,6 +986,57 @@ func (t *Trader) restoreOpenPositions() {
t.lastTradeTime[tr.Coin] = tr.OpenedAt
}
if len(openTrades) > 0 {
log.Printf("[Trader] Restored %d open positions from DB", len(openTrades))
log.Printf("[Trader] Restored %d open positions from DB", len(t.positions))
}
}
// blacklistCoin adds a coin to the blacklist and force-closes its position.
func (t *Trader) blacklistCoin(pos *ArbPosition, notifier *Notifier) {
t.mu.Lock()
t.blacklist[pos.Coin] = time.Now()
t.mu.Unlock()
log.Printf("[Trader] ⛔ %s: Blacklisted — position open %.0fm without convergence", pos.Coin, time.Since(pos.StartedAt).Minutes())
notifier.Send(fmt.Sprintf(
"<b>[黑名单]</b> %s/USDT\n"+
" 开仓 %.0f 分钟未收敛\n"+
" 已加入黑名单观察\n",
pos.Coin, time.Since(pos.StartedAt).Minutes()))
// Force-close the position immediately
pos.Status = "close_failed" // triggers retryClose on next tick
}
// GetBlacklist returns a copy of the current blacklist (coin -> blacklisted at).
func (t *Trader) GetBlacklist() map[string]time.Time {
t.mu.Lock()
defer t.mu.Unlock()
r := make(map[string]time.Time, len(t.blacklist))
for k, v := range t.blacklist {
r[k] = v
}
return r
}
// IsBlacklisted checks if a coin is currently blacklisted (within duration).
func (t *Trader) IsBlacklisted(coin string) bool {
t.mu.Lock()
defer t.mu.Unlock()
blTime, exists := t.blacklist[coin]
if !exists {
return false
}
if t.cfg.BlacklistDuration > 0 && time.Since(blTime) >= t.cfg.BlacklistDuration {
delete(t.blacklist, coin)
return false
}
return true
}
// RemoveBlacklist removes a coin from the blacklist manually.
func (t *Trader) RemoveBlacklist(coin string) {
t.mu.Lock()
defer t.mu.Unlock()
delete(t.blacklist, coin)
log.Printf("[Trader] ✅ %s: Removed from blacklist", coin)
}
+32 -10
View File
@@ -38,8 +38,8 @@ function updateClock() {
setInterval(updateClock, 1000);
updateClock();
const EXCHANGES = ['Binance', 'HyperLiquid', 'Bitget', 'dYdX'];
const COINS = ['DOGE', 'LINK', 'ONDO', 'OP', 'WIF', 'ARB'];
const EXCHANGES = ['HyperLiquid', 'Bitget'];
const COINS = []; // populated dynamically from SSE data
function formatPrice(p) {
if (p == null || p <= 0) return '-';
@@ -97,6 +97,13 @@ 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;
@@ -110,7 +117,6 @@ eventHandlers.prices = (prices) => {
const cells = EXCHANGES.map(ex => {
const p = row[ex];
const sp = row[ex + '_spread'];
const key = coin + '.' + ex;
const prev = priceCache[key];
const curP = p || 0;
@@ -131,9 +137,6 @@ eventHandlers.prices = (prices) => {
}
let display = formatPrice(p);
if (sp && sp > 0.01) {
display += `<span class="text-dim" style="font-size:10px"> (${sp.toFixed(3)}%)</span>`;
}
return `<td class="${cls}">${display}</td>`;
});
@@ -171,14 +174,17 @@ eventHandlers.arb = (opps) => {
els.arbBody.innerHTML = html;
};
// P3-3: Positions with live PnL
// 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;
}
const html = positions.map(p => {
// 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) + '%' : '-';
@@ -209,8 +215,12 @@ eventHandlers.stats = (stats) => {
// Detailed PnL stats
if (stats.detail) {
const d = stats.detail;
$('stat-total-pnl').textContent = (d.total_pnl != null) ? d.total_pnl.toFixed(2) + '%' : '—';
$('stat-avg-pnl').textContent = (d.avg_pnl != null) ? d.avg_pnl.toFixed(2) + '%' : '—';
// 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) + '%' : '—';
@@ -225,6 +235,18 @@ eventHandlers.stats = (stats) => {
}).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
+12 -4
View File
@@ -27,14 +27,14 @@
<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></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>平均PnL</label><span id="stat-avg-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>
@@ -42,16 +42,24 @@
</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>Binance</th><th>HyperLiquid</th><th>Bitget</th><th>dYdX</th><th>BG↔HL价差</th></tr>
<tr><th>币种</th><th>HyperLiquid</th><th>Bitget</th><th>BG↔HL价差</th></tr>
</thead>
<tbody id="price-body">
<tr><td colspan="6" class="loading">等待数据...</td></tr>
<tr><td colspan="4" class="loading">等待数据...</td></tr>
</tbody>
</table>
</div>