diff --git a/README.md b/README.md index b273d58..b58a878 100644 --- a/README.md +++ b/README.md @@ -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/ diff --git a/config.go b/config.go index 0fb369a..17d8b99 100644 --- a/config.go +++ b/config.go @@ -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 + 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) diff --git a/config.json b/config.json index e7eb586..71309bd 100644 --- a/config.json +++ b/config.json @@ -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 } diff --git a/dashboard.go b/dashboard.go index df7eb44..bfc8ce6 100644 --- a/dashboard.go +++ b/dashboard.go @@ -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 { @@ -354,15 +356,16 @@ func (d *Dashboard) broadcastLoop() { positions := d.trader.ReadSnapshot() posList := make([]map[string]interface{}, 0, len(positions)) for _, pos := range positions { - posEntry := map[string]interface{}{ - "coin": pos.Coin, - "direction": pos.Direction, - "amount_usd": pos.AmountUSD, - "entry_spread": pos.EntrySpread, - "scales": pos.ScaleLevels, - "duration": time.Since(pos.StartedAt).Round(time.Second).String(), - "started_at": pos.StartedAt.Format("15:04:05"), - } + posEntry := map[string]interface{}{ + "coin": pos.Coin, + "direction": pos.Direction, + "amount_usd": pos.AmountUSD, + "entry_spread": pos.EntrySpread, + "scales": pos.ScaleLevels, + "duration": time.Since(pos.StartedAt).Round(time.Second).String(), + "started_at": pos.StartedAt.Format("15:04:05"), + "started_ts": pos.StartedAt.UnixMilli(), + } // Calculate live PnL from current prices — use weighted average for scale-ins if exMap := snap[pos.Coin]; exMap != nil { @@ -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,18 +431,20 @@ 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, - "avg_pnl": detail.AvgPnlPct, - "max_profit": detail.MaxProfitPct, - "max_loss": detail.MaxLossPct, - "avg_dur": detail.AvgDuration, - "win_rate": detail.WinRate, - "wins": detail.WinningTrades, - "losses": detail.LosingTrades, - "total_dur": detail.TotalDuration, + "total_pnl_usd": math.Round(detail.TotalPnlUSD*100) / 100, + "capital_pnl": math.Round(detail.CapitalPnlPct*10000) / 10000, + "avg_pnl": detail.AvgPnlPct, + "max_profit": detail.MaxProfitPct, + "max_loss": detail.MaxLossPct, + "avg_dur": detail.AvgDuration, + "win_rate": detail.WinRate, + "wins": detail.WinningTrades, + "losses": detail.LosingTrades, + "total_dur": detail.TotalDuration, }, } @@ -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) } } diff --git a/exchange/bitget.go b/exchange/bitget.go index 68bab6a..45813a3 100644 --- a/exchange/bitget.go +++ b/exchange/bitget.go @@ -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 } } diff --git a/main.go b/main.go index 62397e9..9b8831b 100644 --- a/main.go +++ b/main.go @@ -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) diff --git a/scanner.go b/scanner.go index fa29f1f..e99f0bb 100644 --- a/scanner.go +++ b/scanner.go @@ -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"}, diff --git a/trader.go b/trader.go index 6074fe7..fae0295 100644 --- a/trader.go +++ b/trader.go @@ -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( + "[黑名单] %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) +} diff --git a/web/static/app.js b/web/static/app.js index e2105d5..5ba2b3e 100644 --- a/web/static/app.js +++ b/web/static/app.js @@ -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 += ` (${sp.toFixed(3)}%)`; - } return `${display}`; }); @@ -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 = '无持仓'; 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 `⛔ ${b.coin} (${b.since} 剩余 ${minLeft}:${secLeft.toString().padStart(2,'0')})`; + }).join(''); + $('bl-body').innerHTML = html; + } else { + $('bl-body').innerHTML = '暂无'; + } }; // P3-4: Real-time trade events diff --git a/web/static/index.html b/web/static/index.html index 43e0d09..9d3d113 100644 --- a/web/static/index.html +++ b/web/static/index.html @@ -27,14 +27,14 @@
0
0
0
-
0
+
0 / 5
0
-
+
@@ -42,16 +42,24 @@
+ +
+

⛔ 黑名单

+
+ 暂无 +
+
+

💰 实时价格

- + - +
币种BinanceHyperLiquidBitgetdYdXBG↔HL价差
币种HyperLiquidBitgetBG↔HL价差
等待数据...
等待数据...