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 # 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 ## Architecture
``` ```
┌──────────────┐ ┌──────────────┐
┌──────────────┤ Binance │◄──── bookTicker WS (price reference) │ Bitget │◄──── ticker WS (trading exchange)
└──────────────┘ └──────────────┘
│ ┌──────────────┐
│──────────────┤ Bitget │◄──── ticker WS (trading exchange)
│ └──────────────┘
PriceStore ─────────┼──────────────┤ PriceStore ─────────┼──────────────┤
│ HyperLiquid │◄──── webData2 WS (trading exchange) │ HyperLiquid │◄──── webData2 WS (trading exchange)
│ └──────────────┘
│ ┌──────────────┐
└──────────────┤ dYdX │◄──── v4_markets WS (price reference)
└──────────────┘ └──────────────┘
┌─────────▼─────────┐ ┌─────────▼─────────┐
│ ScanBGHL (50ms) │ ScanBGHL (200ms) │
│ BG ↔ HL only │ │ BG ↔ HL only │
└─────────┬─────────┘ └─────────┬─────────┘
┌───────────────▼────────────────┐ ┌───────────────▼────────────────┐
│ Trader │ │ Trader │
│ TryEntry (async goroutine) │ │ TryEntry (async goroutine) │
│ → placeOrder (REST) │ → placeOrder (REST/mock)
│ Tick / Exit / Scale-in │ │ Tick / Exit / Scale-in │
Maker fees only Config-driven thresholds
│ NO display/stat calculations │
└───────────────┬────────────────┘ └───────────────┬────────────────┘
┌─────────▼─────────┐ ┌─────────▼─────────┐
@@ -41,136 +34,143 @@ PriceStore ─────────┼─────────────
│ │ │ │ │ │
┌─────▼─────┐ ┌────────▼───────┐ ┌─────▼─────┐ ┌─────▼─────┐ ┌────────▼───────┐ ┌─────▼─────┐
│ Notifier │ │ Dashboard │ │ DB │ │ Notifier │ │ Dashboard │ │ DB │
│ TG │ │ :8888 │ │ SQLite │ │ Telegram │ │ :8888 │ │ SQLite │
│ │ │ Stats calc │ │ trades.db │ │ │ │ Stats calc │ │ trades.db │
│ │ │ (calcDetailed) │ │ │ │ │ │ Blacklist UI │ │ │
└───────────┘ └────────────────┘ └───────────┘ └───────────┘ └────────────────┘ └───────────┘
``` ```
## Tracked Coins ## Tracked Coins
| Coin | Binance | Bitget | HyperLiquid | dYdX | | Coin | Bitget | HyperLiquid |
|:----:|:--------:|:---------:|:-----------:|:--------:| |:----:|:---------:|:-----------:|
| DOGE | DOGEUSDT | DOGEUSDT | DOGE | DOGE | | DOGE | DOGEUSDT | DOGE |
| LINK | LINKUSDT | LINKUSDT | LINK | LINK | | LINK | LINKUSDT | LINK |
| ONDO | ONDOUSDT | ONDOUSDT | ONDO | ONDO | | ONDO | ONDOUSDT | ONDO |
| OP | OPUSDT | OPUSDT | OP | OP | | OP | OPUSDT | OP |
| WIF | WIFUSDT | WIFUSDT | WIF | WIF | | WIF | WIFUSDT | WIF |
| ARB | ARBUSDT | ARBUSDT | ARB | ARB | | ARB | ARBUSDT | ARB |
- **Bitget / HyperLiquid** — trading exchanges (limit orders with maker fees) > **Note:** Binance and dYdX have been removed — only Bitget and HyperLiquid are monitored.
- **Binance / dYdX** — price reference only
## Requirements ## Requirements
- Go 1.25+ - Go 1.25+
- WebSocket connectivity to all 4 exchanges - WebSocket connectivity to Bitget and HyperLiquid
## Quick Start ## Quick Start
```bash ```bash
cd exchange-monitor-go cd exchange-monitor-go
go build -o exchange-monitor . go build -o exchange-monitor .
# Edit .env to configure (token, threshold, etc.) # Edit config.json to set parameters
./exchange-monitor ./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. Then open [http://localhost:8888](http://localhost:8888) for the Web dashboard.
## Configuration (.env) ## Configuration
| Variable | Code Default | Description | ### config.json (all trading parameters)
|:---------|:------------:|:------------|
| `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) |
> **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 ## 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 | | Exchange | Taker Fee |
|:---------|:-----:|:-----:| |:---------|:---------:|
| Bitget | 0.020% | 0.040% | | Bitget | configurable (`taker_fee_bitget`, default 0.060%) |
| HyperLiquid | 0.015% | 0.035% | | 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 ## Trading Logic
1. **Scanner** runs every 50ms, checks all 6 coins for BG ↔ HL spread 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) 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) - Uses scan-time prices directly (no re-read to avoid WS jitter)
- **Async goroutine** — `TryEntry` returns immediately, `executeEntry` runs in background - **Async goroutine** — `TryEntry` returns immediately, `executeEntry` runs in background
- Direction sanity check (0.1% tolerance) prevents entry on reversed spreads - Reversal tolerance check prevents entry on flipped spreads
- `entering` status prevents `checkExit` / `checkScaleIn` during leg placement - `entering` map prevents duplicate entries on same coin
- `entering` map prevents duplicate entries on the same coin 3. **Scale-in** adds another leg-worth when spread widens another `scale_step_pct` (default 0.10%)
3. **Scale-in** adds another leg-worth when spread widens another 0.10%
4. **Exit** conditions (whichever hits first): 4. **Exit** conditions (whichever hits first):
- Spread converges to ≤ 0.02% → **价差收敛,止盈平仓** - **Net profit ≥ `take_profit_pct`** → **利润止盈**
- Spread reverses below -0.02%**价差反转,止盈平仓** - **Spread converges to ≤ 0 (prices equal or reversed)****价差收敛止盈**
- Position held over 30 minutes**超时平仓** - **Position held > `position_timeout_sec`****超时平仓**
5. **Direction**: BG → HL (buy BG, sell HL) or HL → BG (buy HL, sell BG) 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 ## Spread Window Monitoring
`SpreadWindowTracker` runs every scan tick and measures how long each coin's spread stays above the trade threshold: `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 - Records window **start time** when net profit first hits threshold
- Tracks real **peak netProfit** during the window - Tracks real **peak net profit** during the window
- Logs window **duration + peak** when spread converges below threshold (sub-100ms windows filtered as noise) - Logs window **duration + peak** when spread converges below threshold
- Covers both directions (BG→HL and HL→BG) independently - 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 ## Web Dashboard
Built-in HTTP server at `:8888` with real-time SSE push (1-second refresh): 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 - **Price table** — live prices from both exchanges
- **BG↔HL spread** — per-coin arbitrage spread with chart - **BG↔HL spread** — per-coin arbitrage spread
- **Open positions** — live PnL estimate, scaling level, duration - **Open positions** — live PnL estimate, scaling level, duration, sorting by coin
- **Arb scan results** — current arbitrage opportunities
- **Trade history** — past trades with detail view - **Trade history** — past trades with detail view
- **Blacklist** — currently blacklisted coins and remaining time
- **Connection status** — exchange health (online / stale / offline) - **Connection status** — exchange health (online / stale / offline)
- Charts rendered via Chart.js (loaded from CDN)
## DB & Persistence ## DB & Persistence
- SQLite at `data/trades.db` - SQLite at current directory (auto-deleted on each restart in test mode)
- Tracks open positions across restarts - Tracks open positions across restarts (`restoreOpenPositions`)
- Stores all closed trades with full PnL details - Stores all closed trades with full PnL details
- Historical stats merged with in-memory session stats on startup
## Signals ## Signals
@@ -184,23 +184,21 @@ Built-in HTTP server at `:8888` with real-time SSE push (1-second refresh):
``` ```
exchange-monitor-go/ exchange-monitor-go/
├── main.go # Entry point, WS startup, main loop ├── 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 ├── types.go # PriceStore, TrackedCoin, ArbOpportunity
├── scanner.go # ScanBGHL — arbitrage scanner ├── scanner.go # ScanBGHL — arbitrage scanner
├── trader.go # Position management, entry/exit/scale-in (pure trading logic) ├── trader.go # Position management, entry/exit/scale-in
├── dashboard.go # Web server + SSE + stats calc (display layer) ├── dashboard.go # Web server + SSE + stats calc
├── toaster.go # Telegram notifications ├── toaster.go # Telegram notifications
├── static.go # Embedded web static files ├── static.go # Embedded web static files
├── start.sh # Startup script (port check + build + run) ├── .env # Secrets only (API keys)
├── .env # Local configuration (TELEGRAM, API keys, thresholds)
├── exchange/ ├── exchange/
│ ├── connector.go # Generic WS connector with reconnect │ ├── connector.go # Generic WS connector with reconnect
│ ├── binance.go # Binance bookTicker WS
│ ├── hyperliquid.go # HyperLiquid webData2 WS │ ├── hyperliquid.go # HyperLiquid webData2 WS
│ ├── hyperliquid_trade.go # HL REST trade API │ ├── 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 │ ├── bitget_trade.go # Bitget REST trade API
│ ├── dydx.go # dYdX v4_markets WS
│ ├── helpers.go # Package helpers │ ├── helpers.go # Package helpers
│ └── ping.go # Accessibility check tools │ └── ping.go # Accessibility check tools
├── db/ ├── db/
+119 -2
View File
@@ -4,6 +4,7 @@ import (
"encoding/json" "encoding/json"
"os" "os"
"strconv" "strconv"
"time"
) )
// Config holds all system configuration. // Config holds all system configuration.
@@ -18,13 +19,37 @@ type Config struct {
// Automated trading // Automated trading
TradeEnabled bool TradeEnabled bool
TradeThreshold float64 // minimum profit % to execute trade 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 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) // Test mode (no real API keys needed)
TestMode bool TestMode bool
MockSlippagePct float64 // simulated slippage per order (e.g. 0.01 = 0.01%) 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 // Bitget API
BitgetAPIKey string BitgetAPIKey string
BitgetAPISecret string BitgetAPISecret string
@@ -46,6 +71,22 @@ type jsonConfig struct {
TradeCooldownMs int `json:"trade_cooldown_ms"` TradeCooldownMs int `json:"trade_cooldown_ms"`
AlertCooldownSec int `json:"alert_cooldown_sec"` AlertCooldownSec int `json:"alert_cooldown_sec"`
MockSlippagePct float64 `json:"mock_slippage_pct"` 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 { func LoadConfig() *Config {
@@ -89,10 +130,30 @@ func LoadConfig() *Config {
TradeThreshold: getFloat("TRADE_THRESHOLD", jsonCfg.TradeThreshold), TradeThreshold: getFloat("TRADE_THRESHOLD", jsonCfg.TradeThreshold),
TradeAmountUSD: getFloat("TRADE_AMOUNT_USD", jsonCfg.TradeAmountUSD), TradeAmountUSD: getFloat("TRADE_AMOUNT_USD", jsonCfg.TradeAmountUSD),
TradeCooldownMs: int(getFloat("TRADE_COOLDOWN_MS", float64(jsonCfg.TradeCooldownMs))), 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), TestMode: getBool("TEST_MODE", jsonCfg.TestMode),
MockSlippagePct: getFloat("MOCK_SLIPPAGE_PCT", jsonCfg.MockSlippagePct), 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", ""), BitgetAPIKey: getEnv("BITGET_API_KEY", ""),
BitgetAPISecret: getEnv("BITGET_API_SECRET", ""), BitgetAPISecret: getEnv("BITGET_API_SECRET", ""),
BitgetPassphrase: getEnv("BITGET_PASSPHRASE", ""), BitgetPassphrase: getEnv("BITGET_PASSPHRASE", ""),
@@ -111,6 +172,24 @@ func loadJSONConfig() jsonConfig {
TradeCooldownMs: 30000, TradeCooldownMs: 30000,
AlertCooldownSec: 300, AlertCooldownSec: 300,
MockSlippagePct: 0.005, 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") data, err := os.ReadFile("config.json")
@@ -145,6 +224,44 @@ func loadJSONConfig() jsonConfig {
if cfg.MockSlippagePct != 0 { if cfg.MockSlippagePct != 0 {
def.MockSlippagePct = cfg.MockSlippagePct 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 // Boolean fields: zero default is false, so use OR logic
// When JSON has true → true || false = true (override) // When JSON has true → true || false = true (override)
+14 -2
View File
@@ -3,9 +3,21 @@
"trade_enabled": true, "trade_enabled": true,
"arb_threshold": 0.03, "arb_threshold": 0.03,
"scan_interval_ms": 200, "scan_interval_ms": 200,
"trade_threshold": 0.05, "trade_threshold": 0.10,
"trade_amount_usd": 5, "trade_amount_usd": 5,
"trade_cooldown_ms": 30000, "trade_cooldown_ms": 30000,
"alert_cooldown_sec": 300, "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
} }
+47 -23
View File
@@ -253,7 +253,8 @@ func (d *Dashboard) Run() {
// DetailedStats holds aggregated PnL and duration statistics. // DetailedStats holds aggregated PnL and duration statistics.
type DetailedStats struct { type DetailedStats struct {
TotalTrades int `json:"total_trades"` 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"` AvgPnlPct float64 `json:"avg_pnl_pct"`
MaxProfitPct float64 `json:"max_profit_pct"` MaxProfitPct float64 `json:"max_profit_pct"`
MaxLossPct float64 `json:"max_loss_pct"` MaxLossPct float64 `json:"max_loss_pct"`
@@ -266,7 +267,7 @@ type DetailedStats struct {
// calcDetailedStats computes trading statistics from a slice of closed trades. // calcDetailedStats computes trading statistics from a slice of closed trades.
// This is a pure function — no dependency on Trader internals. // This is a pure function — no dependency on Trader internals.
func calcDetailedStats(trades []TradeRecord) DetailedStats { func calcDetailedStats(trades []TradeRecord, initialCapital float64) DetailedStats {
ds := DetailedStats{} ds := DetailedStats{}
if len(trades) == 0 { if len(trades) == 0 {
return ds return ds
@@ -275,7 +276,7 @@ func calcDetailedStats(trades []TradeRecord) DetailedStats {
ds.MaxLossPct = 1e9 // sentinel ds.MaxLossPct = 1e9 // sentinel
for _, tr := range trades { for _, tr := range trades {
ds.TotalTrades++ ds.TotalTrades++
ds.TotalPnlPct += tr.PnlPct ds.TotalPnlUSD += tr.PnlUSD
if tr.PnlPct >= 0 { if tr.PnlPct >= 0 {
ds.WinningTrades++ ds.WinningTrades++
if tr.PnlPct > ds.MaxProfitPct { if tr.PnlPct > ds.MaxProfitPct {
@@ -295,7 +296,8 @@ func calcDetailedStats(trades []TradeRecord) DetailedStats {
ds.MaxLossPct = 0 ds.MaxLossPct = 0
} }
if ds.TotalTrades > 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 ds.WinRate = float64(ds.WinningTrades) / float64(ds.TotalTrades) * 100
} }
if totalDur > 0 { if totalDur > 0 {
@@ -354,15 +356,16 @@ func (d *Dashboard) broadcastLoop() {
positions := d.trader.ReadSnapshot() positions := d.trader.ReadSnapshot()
posList := make([]map[string]interface{}, 0, len(positions)) posList := make([]map[string]interface{}, 0, len(positions))
for _, pos := range positions { for _, pos := range positions {
posEntry := map[string]interface{}{ posEntry := map[string]interface{}{
"coin": pos.Coin, "coin": pos.Coin,
"direction": pos.Direction, "direction": pos.Direction,
"amount_usd": pos.AmountUSD, "amount_usd": pos.AmountUSD,
"entry_spread": pos.EntrySpread, "entry_spread": pos.EntrySpread,
"scales": pos.ScaleLevels, "scales": pos.ScaleLevels,
"duration": time.Since(pos.StartedAt).Round(time.Second).String(), "duration": time.Since(pos.StartedAt).Round(time.Second).String(),
"started_at": pos.StartedAt.Format("15:04:05"), "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 // Calculate live PnL from current prices — use weighted average for scale-ins
if exMap := snap[pos.Coin]; exMap != nil { if exMap := snap[pos.Coin]; exMap != nil {
@@ -420,7 +423,7 @@ func (d *Dashboard) broadcastLoop() {
// 4. Stats + connection status (P3-5) // 4. Stats + connection status (P3-5)
converged, diverged, flat, total := d.trader.GetClosedStats() 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{}{ stats := map[string]interface{}{
"total_trades": total, "total_trades": total,
"converged": converged, "converged": converged,
@@ -428,18 +431,20 @@ func (d *Dashboard) broadcastLoop() {
"flat": flat, "flat": flat,
"open_positions": len(positions), "open_positions": len(positions),
"coins": len(prices), "coins": len(prices),
"capital": d.trader.cfg.InitialCapital,
// Detailed PnL & duration stats (session only) // Detailed PnL & duration stats (session only)
"detail": map[string]interface{}{ "detail": map[string]interface{}{
"total_pnl": detail.TotalPnlPct, "total_pnl_usd": math.Round(detail.TotalPnlUSD*100) / 100,
"avg_pnl": detail.AvgPnlPct, "capital_pnl": math.Round(detail.CapitalPnlPct*10000) / 10000,
"max_profit": detail.MaxProfitPct, "avg_pnl": detail.AvgPnlPct,
"max_loss": detail.MaxLossPct, "max_profit": detail.MaxProfitPct,
"avg_dur": detail.AvgDuration, "max_loss": detail.MaxLossPct,
"win_rate": detail.WinRate, "avg_dur": detail.AvgDuration,
"wins": detail.WinningTrades, "win_rate": detail.WinRate,
"losses": detail.LosingTrades, "wins": detail.WinningTrades,
"total_dur": detail.TotalDuration, "losses": detail.LosingTrades,
"total_dur": detail.TotalDuration,
}, },
} }
@@ -459,6 +464,25 @@ func (d *Dashboard) broadcastLoop() {
d.connMu.RUnlock() d.connMu.RUnlock()
stats["connections"] = connInfo 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) 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" { if evt, _ := generic["event"].(string); evt == "error" {
log.Printf("[Bitget WS] Subscribe error response: %s", string(msg)) log.Printf("[Bitget WS] Subscribe error response: %s", string(msg))
return 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() loadDotEnv()
cfg := LoadConfig() 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() store := NewPriceStore()
notifier := NewNotifier(cfg.TelegramBotToken, cfg.TelegramChatID) notifier := NewNotifier(cfg.TelegramBotToken, cfg.TelegramChatID)
@@ -58,9 +62,8 @@ func main() {
// P3-4: wire real-time trade event broadcast // P3-4: wire real-time trade event broadcast
trader.OnTradeEvent = dashboard.BroadcastEvent trader.OnTradeEvent = dashboard.BroadcastEvent
if trader.IsConfigured() { if trader.IsConfigured() {
modeLabel := trader.ModeLabel() log.Printf("[Trader] %s mode: automated trading ENABLED (threshold >= %.2f%%, $%.0f/leg, max %d positions, $%.0f capital)",
log.Printf("[Trader] %s mode: automated trading ENABLED (threshold >= %.2f%%, $%.0f/trade)", trader.ModeLabel(), cfg.TradeThreshold, cfg.TradeAmountUSD, cfg.MaxPositions, cfg.InitialCapital)
modeLabel, cfg.TradeThreshold, cfg.TradeAmountUSD)
if cfg.TestMode { if cfg.TestMode {
log.Printf("[Trader] Using mock orders with %.3f%% slippage per leg", cfg.MockSlippagePct) 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) sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, os.Interrupt, syscall.SIGUSR1) signal.Notify(sigCh, os.Interrupt, syscall.SIGUSR1)
// Collect symbols — skip exchanges the coin isn't listed on // Collect symbols — only BG and HL for now (BN, dYdX disabled)
var bnSymbols, bgSymbols, hlSymbols, dydxSymbols []string var bgSymbols, hlSymbols []string
for _, c := range TrackedCoins { for _, c := range TrackedCoins {
if c.BN != "" {
bnSymbols = append(bnSymbols, c.BN)
}
if c.BG != "" { if c.BG != "" {
bgSymbols = append(bgSymbols, c.BG) bgSymbols = append(bgSymbols, c.BG)
} }
if c.HL != "" { if c.HL != "" {
hlSymbols = append(hlSymbols, 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) { startExchange := func(name string, runner func(func(string, float64, float64, float64)) error) {
go func() { go func() {
for { for {
@@ -112,10 +109,8 @@ func main() {
}() }()
} }
startExchange("Binance", exchange.NewBinanceWS(bnSymbols).Run)
startExchange("HyperLiquid", exchange.NewHyperLiquidWS(hlSymbols).Run) startExchange("HyperLiquid", exchange.NewHyperLiquidWS(hlSymbols).Run)
startExchange("Bitget", exchange.NewBitgetWS(bgSymbols).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...") log.Println("[Monitor] Waiting for initial data...")
time.Sleep(10 * time.Second) time.Sleep(10 * time.Second)
+1 -9
View File
@@ -13,7 +13,7 @@ const (
// Taker fee rates (%) — for IOC market orders on trading exchanges // Taker fee rates (%) — for IOC market orders on trading exchanges
var takerFees = map[string]float64{ var takerFees = map[string]float64{
ExHyperLiquid: 0.045, ExHyperLiquid: 0.045,
ExBitget: 0.030, ExBitget: 0.060,
} }
// TickerCoins defines all coins we monitor. // TickerCoins defines all coins we monitor.
@@ -52,13 +52,11 @@ var TrackedCoins = []TrackedCoin{
{Name: "BIO", BN: "", BG: "BIOUSDT", HL: "BIO"}, {Name: "BIO", BN: "", BG: "BIOUSDT", HL: "BIO"},
{Name: "BLUR", BN: "", BG: "BLURUSDT", HL: "BLUR"}, {Name: "BLUR", BN: "", BG: "BLURUSDT", HL: "BLUR"},
{Name: "BNB", BN: "", BG: "BNBUSDT", HL: "BNB"}, {Name: "BNB", BN: "", BG: "BNBUSDT", HL: "BNB"},
{Name: "BNT", BN: "", BG: "BNTUSDT", HL: "BNT"},
{Name: "BOME", BN: "", BG: "BOMEUSDT", HL: "BOME"}, {Name: "BOME", BN: "", BG: "BOMEUSDT", HL: "BOME"},
{Name: "BRETT", BN: "", BG: "BRETTUSDT", HL: "BRETT"}, {Name: "BRETT", BN: "", BG: "BRETTUSDT", HL: "BRETT"},
{Name: "BSV", BN: "", BG: "BSVUSDT", HL: "BSV"}, {Name: "BSV", BN: "", BG: "BSVUSDT", HL: "BSV"},
{Name: "BTC", BN: "", BG: "BTCUSDT", HL: "BTC"}, {Name: "BTC", BN: "", BG: "BTCUSDT", HL: "BTC"},
{Name: "CAKE", BN: "", BG: "CAKEUSDT", HL: "CAKE"}, {Name: "CAKE", BN: "", BG: "CAKEUSDT", HL: "CAKE"},
{Name: "CATI", BN: "", BG: "CATIUSDT", HL: "CATI"},
{Name: "CC", BN: "", BG: "CCUSDT", HL: "CC"}, {Name: "CC", BN: "", BG: "CCUSDT", HL: "CC"},
{Name: "CELO", BN: "", BG: "CELOUSDT", HL: "CELO"}, {Name: "CELO", BN: "", BG: "CELOUSDT", HL: "CELO"},
{Name: "CFX", BN: "", BG: "CFXUSDT", HL: "CFX"}, {Name: "CFX", BN: "", BG: "CFXUSDT", HL: "CFX"},
@@ -66,7 +64,6 @@ var TrackedCoins = []TrackedCoin{
{Name: "CHIP", BN: "", BG: "CHIPUSDT", HL: "CHIP"}, {Name: "CHIP", BN: "", BG: "CHIPUSDT", HL: "CHIP"},
{Name: "COMP", BN: "", BG: "COMPUSDT", HL: "COMP"}, {Name: "COMP", BN: "", BG: "COMPUSDT", HL: "COMP"},
{Name: "CRV", BN: "", BG: "CRVUSDT", HL: "CRV"}, {Name: "CRV", BN: "", BG: "CRVUSDT", HL: "CRV"},
{Name: "CYBER", BN: "", BG: "CYBERUSDT", HL: "CYBER"},
{Name: "DASH", BN: "", BG: "DASHUSDT", HL: "DASH"}, {Name: "DASH", BN: "", BG: "DASHUSDT", HL: "DASH"},
{Name: "DOOD", BN: "", BG: "DOODUSDT", HL: "DOOD"}, {Name: "DOOD", BN: "", BG: "DOODUSDT", HL: "DOOD"},
{Name: "DOT", BN: "", BG: "DOTUSDT", HL: "DOT"}, {Name: "DOT", BN: "", BG: "DOTUSDT", HL: "DOT"},
@@ -93,7 +90,6 @@ var TrackedCoins = []TrackedCoin{
{Name: "HYPE", BN: "", BG: "HYPEUSDT", HL: "HYPE"}, {Name: "HYPE", BN: "", BG: "HYPEUSDT", HL: "HYPE"},
{Name: "HYPER", BN: "", BG: "HYPERUSDT", HL: "HYPER"}, {Name: "HYPER", BN: "", BG: "HYPERUSDT", HL: "HYPER"},
{Name: "ICP", BN: "", BG: "ICPUSDT", HL: "ICP"}, {Name: "ICP", BN: "", BG: "ICPUSDT", HL: "ICP"},
{Name: "ILV", BN: "", BG: "ILVUSDT", HL: "ILV"},
{Name: "IMX", BN: "", BG: "IMXUSDT", HL: "IMX"}, {Name: "IMX", BN: "", BG: "IMXUSDT", HL: "IMX"},
{Name: "INIT", BN: "", BG: "INITUSDT", HL: "INIT"}, {Name: "INIT", BN: "", BG: "INITUSDT", HL: "INIT"},
{Name: "INJ", BN: "", BG: "INJUSDT", HL: "INJ"}, {Name: "INJ", BN: "", BG: "INJUSDT", HL: "INJ"},
@@ -107,7 +103,6 @@ var TrackedCoins = []TrackedCoin{
{Name: "LAYER", BN: "", BG: "LAYERUSDT", HL: "LAYER"}, {Name: "LAYER", BN: "", BG: "LAYERUSDT", HL: "LAYER"},
{Name: "LDO", BN: "", BG: "LDOUSDT", HL: "LDO"}, {Name: "LDO", BN: "", BG: "LDOUSDT", HL: "LDO"},
{Name: "LINEA", BN: "", BG: "LINEAUSDT", HL: "LINEA"}, {Name: "LINEA", BN: "", BG: "LINEAUSDT", HL: "LINEA"},
{Name: "LISTA", BN: "", BG: "LISTAUSDT", HL: "LISTA"},
{Name: "LIT", BN: "", BG: "LITUSDT", HL: "LIT"}, {Name: "LIT", BN: "", BG: "LITUSDT", HL: "LIT"},
{Name: "LTC", BN: "", BG: "LTCUSDT", HL: "LTC"}, {Name: "LTC", BN: "", BG: "LTCUSDT", HL: "LTC"},
{Name: "MANTA", BN: "", BG: "MANTAUSDT", HL: "MANTA"}, {Name: "MANTA", BN: "", BG: "MANTAUSDT", HL: "MANTA"},
@@ -129,13 +124,11 @@ var TrackedCoins = []TrackedCoin{
{Name: "NIL", BN: "", BG: "NILUSDT", HL: "NIL"}, {Name: "NIL", BN: "", BG: "NILUSDT", HL: "NIL"},
{Name: "NOT", BN: "", BG: "NOTUSDT", HL: "NOT"}, {Name: "NOT", BN: "", BG: "NOTUSDT", HL: "NOT"},
{Name: "NXPC", BN: "", BG: "NXPCUSDT", HL: "NXPC"}, {Name: "NXPC", BN: "", BG: "NXPCUSDT", HL: "NXPC"},
{Name: "OGN", BN: "", BG: "OGNUSDT", HL: "OGN"},
{Name: "ORDI", BN: "", BG: "ORDIUSDT", HL: "ORDI"}, {Name: "ORDI", BN: "", BG: "ORDIUSDT", HL: "ORDI"},
{Name: "PAXG", BN: "", BG: "PAXGUSDT", HL: "PAXG"}, {Name: "PAXG", BN: "", BG: "PAXGUSDT", HL: "PAXG"},
{Name: "PENDLE", BN: "", BG: "PENDLEUSDT", HL: "PENDLE"}, {Name: "PENDLE", BN: "", BG: "PENDLEUSDT", HL: "PENDLE"},
{Name: "PENGU", BN: "", BG: "PENGUUSDT", HL: "PENGU"}, {Name: "PENGU", BN: "", BG: "PENGUUSDT", HL: "PENGU"},
{Name: "PEOPLE", BN: "", BG: "PEOPLEUSDT", HL: "PEOPLE"}, {Name: "PEOPLE", BN: "", BG: "PEOPLEUSDT", HL: "PEOPLE"},
{Name: "PIXEL", BN: "", BG: "PIXELUSDT", HL: "PIXEL"},
{Name: "PNUT", BN: "", BG: "PNUTUSDT", HL: "PNUT"}, {Name: "PNUT", BN: "", BG: "PNUTUSDT", HL: "PNUT"},
{Name: "POL", BN: "", BG: "POLUSDT", HL: "POL"}, {Name: "POL", BN: "", BG: "POLUSDT", HL: "POL"},
{Name: "POLYX", BN: "", BG: "POLYXUSDT", HL: "POLYX"}, {Name: "POLYX", BN: "", BG: "POLYXUSDT", HL: "POLYX"},
@@ -160,7 +153,6 @@ var TrackedCoins = []TrackedCoin{
{Name: "SPX", BN: "", BG: "SPXUSDT", HL: "SPX"}, {Name: "SPX", BN: "", BG: "SPXUSDT", HL: "SPX"},
{Name: "STABLE", BN: "", BG: "STABLEUSDT", HL: "STABLE"}, {Name: "STABLE", BN: "", BG: "STABLEUSDT", HL: "STABLE"},
{Name: "STBL", BN: "", BG: "STBLUSDT", HL: "STBL"}, {Name: "STBL", BN: "", BG: "STBLUSDT", HL: "STBL"},
{Name: "STG", BN: "", BG: "STGUSDT", HL: "STG"},
{Name: "STRK", BN: "", BG: "STRKUSDT", HL: "STRK"}, {Name: "STRK", BN: "", BG: "STRKUSDT", HL: "STRK"},
{Name: "STX", BN: "", BG: "STXUSDT", HL: "STX"}, {Name: "STX", BN: "", BG: "STXUSDT", HL: "STX"},
{Name: "SUI", BN: "", BG: "SUIUSDT", HL: "SUI"}, {Name: "SUI", BN: "", BG: "SUIUSDT", HL: "SUI"},
+118 -39
View File
@@ -129,6 +129,7 @@ type Trader struct {
positions map[string]*ArbPosition // coin -> position positions map[string]*ArbPosition // coin -> position
entering map[string]bool // coin -> being entered (async goroutine) entering map[string]bool // coin -> being entered (async goroutine)
lastTradeTime map[string]time.Time lastTradeTime map[string]time.Time
blacklist map[string]time.Time // coin -> when blacklisted (stale spread)
closedTrades []TradeRecord // history of closed trades (current session) closedTrades []TradeRecord // history of closed trades (current session)
// Historical stats loaded from DB on startup — combined with session stats in GetClosedStats // Historical stats loaded from DB on startup — combined with session stats in GetClosedStats
@@ -148,6 +149,7 @@ type TradeRecord struct {
EntrySpread float64 EntrySpread float64
ExitSpread float64 ExitSpread float64
PnlPct float64 PnlPct float64
PnlUSD float64 // absolute PnL in USD
Convergence string // "收敛", "发散", "持平" Convergence string // "收敛", "发散", "持平"
Reason string // exit reason Reason string // exit reason
Duration string Duration string
@@ -172,6 +174,7 @@ func NewTrader(cfg *Config, database *db.DB) *Trader {
positions: make(map[string]*ArbPosition), positions: make(map[string]*ArbPosition),
entering: make(map[string]bool), entering: make(map[string]bool),
lastTradeTime: make(map[string]time.Time), lastTradeTime: make(map[string]time.Time),
blacklist: make(map[string]time.Time),
} }
// Restore open positions from DB on restart // Restore open positions from DB on restart
@@ -236,6 +239,7 @@ func (t *Trader) Tick(store *PriceStore, notifier *Notifier) {
lowP, highP = hlP, bgP lowP, highP = hlP, bgP
} }
diffPct := (highP - lowP) / lowP * 100 diffPct := (highP - lowP) / lowP * 100
elapsed := time.Since(pos.StartedAt)
// Retry close for positions that failed to close on previous attempt // Retry close for positions that failed to close on previous attempt
if pos.Status == "close_failed" { if pos.Status == "close_failed" {
@@ -248,6 +252,12 @@ func (t *Trader) Tick(store *PriceStore, notifier *Notifier) {
// Check exit: if spread converged, take profit // Check exit: if spread converged, take profit
t.checkExit(pos, bgP, hlP, diffPct, notifier) 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() t.mu.Unlock()
return false 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() t.mu.Unlock()
return false return false
} }
@@ -313,10 +335,11 @@ func (t *Trader) executeEntry(opp *ArbOpportunity, store *PriceStore, notifier *
currBg := exMap[ExBitget] currBg := exMap[ExBitget]
currHl := exMap[ExHyperLiquid] currHl := exMap[ExHyperLiquid]
if currBg > 0 && currHl > 0 { 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 return false // reversed beyond small tolerance
} }
if opp.BuyEx == ExHyperLiquid && currBg <= currHl*0.999 { if opp.BuyEx == ExHyperLiquid && currBg <= currHl*reversalMul {
return false return false
} }
} }
@@ -368,7 +391,7 @@ func (t *Trader) executeEntry(opp *ArbOpportunity, store *PriceStore, notifier *
t.cleanup(pos.Coin) t.cleanup(pos.Coin)
return false return false
} }
time.Sleep(300 * time.Millisecond) time.Sleep(t.cfg.LegDelay)
if err := t.placeOrder(pos.ShortLeg, "sell", store); err != "" { if err := t.placeOrder(pos.ShortLeg, "sell", store); err != "" {
// Leg1 placed successfully, leg2 failed — try to close leg1 // Leg1 placed successfully, leg2 failed — try to close leg1
if closeErr := t.closeLeg(pos.LongLeg); closeErr != "" { 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 nextLevel := float64(pos.ScaleLevels+1) * scaleStep
if diffPct < entryDiff+nextLevel { if diffPct < entryDiff+nextLevel {
return return
} }
// Cooldown: at least 5 seconds between scales // Cooldown: use configured interval between scales
if time.Since(pos.LastScaleAt) < 5*time.Second { if time.Since(pos.LastScaleAt) < t.cfg.ScaleCooldown {
return 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) log.Printf("[Trader] %s: Scale-in long failed: %s", pos.Coin, err)
return return
} }
time.Sleep(300 * time.Millisecond) time.Sleep(t.cfg.LegDelay)
if err := t.placeOrderAt(pos.ShortLeg, "sell", store, shortPrice); err != "" { 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) 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 // 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) 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) { func (t *Trader) checkExit(pos *ArbPosition, bgP, hlP, diffPct float64, notifier *Notifier) {
if pos.Status != "open" { if pos.Status != "open" {
return return
} }
// Exit when spread converges to near zero (<= 0.02%) // Current prices for P&L calculation
// 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
var longCurrent, shortCurrent float64 var longCurrent, shortCurrent float64
if pos.LongLeg.Exchange == ExBitget { if pos.LongLeg.Exchange == ExBitget {
longCurrent, shortCurrent = bgP, hlP 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]) // 开仓 + 平仓手续费 totalFees := 2 * (takerFees[ExBitget] + takerFees[ExHyperLiquid]) // 开仓 + 平仓手续费
netPnl := longPnl + shortPnl - totalFees 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 // Convergence analysis
convergedPct := (pos.EntrySpread - diffPct) / pos.EntrySpread * 100 convergedPct := (pos.EntrySpread - diffPct) / pos.EntrySpread * 100
convergenceLabel := "价差收敛" convergenceLabel := "价差收敛"
@@ -579,6 +602,7 @@ func (t *Trader) checkExit(pos *ArbPosition, bgP, hlP, diffPct float64, notifier
EntrySpread: pos.EntrySpread, EntrySpread: pos.EntrySpread,
ExitSpread: diffPct, ExitSpread: diffPct,
PnlPct: netPnl, PnlPct: netPnl,
PnlUSD: pos.AmountUSD * netPnl / 100,
Convergence: convergenceLabel, Convergence: convergenceLabel,
Reason: exitReason, Reason: exitReason,
Duration: elapsed.Round(time.Second).String(), Duration: elapsed.Round(time.Second).String(),
@@ -927,6 +951,10 @@ func (t *Trader) restoreOpenPositions() {
return return
} }
for i := range openTrades { 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] tr := &openTrades[i]
// Recreate position structure from DB record // Recreate position structure from DB record
pos := &ArbPosition{ pos := &ArbPosition{
@@ -958,6 +986,57 @@ func (t *Trader) restoreOpenPositions() {
t.lastTradeTime[tr.Coin] = tr.OpenedAt t.lastTradeTime[tr.Coin] = tr.OpenedAt
} }
if len(openTrades) > 0 { 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); setInterval(updateClock, 1000);
updateClock(); updateClock();
const EXCHANGES = ['Binance', 'HyperLiquid', 'Bitget', 'dYdX']; const EXCHANGES = ['HyperLiquid', 'Bitget'];
const COINS = ['DOGE', 'LINK', 'ONDO', 'OP', 'WIF', 'ARB']; const COINS = []; // populated dynamically from SSE data
function formatPrice(p) { function formatPrice(p) {
if (p == null || p <= 0) return '-'; if (p == null || p <= 0) return '-';
@@ -97,6 +97,13 @@ const eventHandlers = {};
eventHandlers.prices = (prices) => { eventHandlers.prices = (prices) => {
if (!prices || prices.length === 0) return; 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 html = '';
let coinsOnline = 0; let coinsOnline = 0;
@@ -110,7 +117,6 @@ eventHandlers.prices = (prices) => {
const cells = EXCHANGES.map(ex => { const cells = EXCHANGES.map(ex => {
const p = row[ex]; const p = row[ex];
const sp = row[ex + '_spread'];
const key = coin + '.' + ex; const key = coin + '.' + ex;
const prev = priceCache[key]; const prev = priceCache[key];
const curP = p || 0; const curP = p || 0;
@@ -131,9 +137,6 @@ eventHandlers.prices = (prices) => {
} }
let display = formatPrice(p); 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>`; return `<td class="${cls}">${display}</td>`;
}); });
@@ -171,14 +174,17 @@ eventHandlers.arb = (opps) => {
els.arbBody.innerHTML = html; els.arbBody.innerHTML = html;
}; };
// P3-3: Positions with live PnL // P3-3: Positions with live PnL — sorted by time (oldest first)
eventHandlers.positions = (positions) => { eventHandlers.positions = (positions) => {
if (!positions || positions.length === 0) { if (!positions || positions.length === 0) {
els.posBody.innerHTML = '<tr><td colspan="8" class="text-dim">无持仓</td></tr>'; els.posBody.innerHTML = '<tr><td colspan="8" class="text-dim">无持仓</td></tr>';
return; 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 pnl = p.pnl_est;
const pnlStr = pnl != null ? pnl.toFixed(4) + '%' : '-'; const pnlStr = pnl != null ? pnl.toFixed(4) + '%' : '-';
const curSpread = p.current_spread != null ? p.current_spread.toFixed(4) + '%' : '-'; const curSpread = p.current_spread != null ? p.current_spread.toFixed(4) + '%' : '-';
@@ -209,8 +215,12 @@ eventHandlers.stats = (stats) => {
// Detailed PnL stats // Detailed PnL stats
if (stats.detail) { if (stats.detail) {
const d = stats.detail; const d = stats.detail;
$('stat-total-pnl').textContent = (d.total_pnl != null) ? d.total_pnl.toFixed(2) + '%' : '—'; // Total PnL: show both USD and percentage of capital
$('stat-avg-pnl').textContent = (d.avg_pnl != null) ? d.avg_pnl.toFixed(2) + '%' : '—'; 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-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-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-max-loss').textContent = (d.max_loss != null) ? d.max_loss.toFixed(2) + '%' : '—';
@@ -225,6 +235,18 @@ eventHandlers.stats = (stats) => {
}).join(' '); }).join(' ');
els.connDetail.innerHTML = dots; 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 // 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-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-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-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"><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 class="stat" id="conn-stats"><label>连接</label><span id="conn-detail"></span></div>
</div> </div>
<!-- Detailed PnL stats --> <!-- Detailed PnL stats -->
<div class="stats-row detail-stats" style="margin-top:4px;font-size:12px;opacity:0.85"> <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-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-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-profit"></span></div>
<div class="stat"><label>最多亏损</label><span id="stat-max-loss"></span></div> <div class="stat"><label>最多亏损</label><span id="stat-max-loss"></span></div>
@@ -42,16 +42,24 @@
</div> </div>
</section> </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 --> <!-- Price Table -->
<section class="card" id="prices-card"> <section class="card" id="prices-card">
<h2>💰 实时价格 <span id="prices-age" class="text-dim" style="font-size:11px"></span></h2> <h2>💰 实时价格 <span id="prices-age" class="text-dim" style="font-size:11px"></span></h2>
<div class="table-wrap"> <div class="table-wrap">
<table id="price-table"> <table id="price-table">
<thead> <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> </thead>
<tbody id="price-body"> <tbody id="price-body">
<tr><td colspan="6" class="loading">等待数据...</td></tr> <tr><td colspan="4" class="loading">等待数据...</td></tr>
</tbody> </tbody>
</table> </table>
</div> </div>