feat: 所有参数移至config.json, 重构退出逻辑, 清理遗留接口
- 将所有硬编码参数迁移到 config.json (手续费率、止盈/止损阈值、 超时、腿间隔、加仓步进等) - 退出条件: 净利 >= take_profit_pct 止盈, 价差 <= 0 平仓 - 删除 Binance/dYdX 遗留代码 - 更新 README 文档 - Dashboard: 双交易所价格表、黑名单UI、按币名排序持仓 - Bitget WS: 文本ping保活 - 数据库: 重置, 无历史仓位
This commit is contained in:
@@ -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/
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Config holds all system configuration.
|
||||
@@ -18,13 +19,37 @@ type Config struct {
|
||||
// Automated trading
|
||||
TradeEnabled bool
|
||||
TradeThreshold float64 // minimum profit % to execute trade
|
||||
TradeAmountUSD float64 // amount per trade in USDT
|
||||
TradeAmountUSD float64 // amount per leg in USDT
|
||||
TradeCooldownMs int // ms between trades of same coin
|
||||
MaxPositions int // max concurrent open positions (0 = unlimited)
|
||||
|
||||
// Capital
|
||||
InitialCapital float64 // starting capital in USD (for PnL % calculation)
|
||||
|
||||
// Blacklist — stale spread observation
|
||||
BlacklistDuration time.Duration // how long a coin stays blacklisted (0 = permanent)
|
||||
|
||||
// Test mode (no real API keys needed)
|
||||
TestMode bool
|
||||
MockSlippagePct float64 // simulated slippage per order (e.g. 0.01 = 0.01%)
|
||||
|
||||
// Exchange fee rates (% per order)
|
||||
TakerFeeBitget float64
|
||||
TakerFeeHyperLiquid float64
|
||||
|
||||
// Exit/risk parameters
|
||||
TakeProfitPct float64 // net profit % threshold for take-profit
|
||||
SpreadReverseExitPct float64 // spread reversal % threshold for exit
|
||||
PositionTimeout time.Duration // max position hold time before auto-close
|
||||
LegDelay time.Duration // delay between placing long and short legs
|
||||
|
||||
// Scale-in parameters
|
||||
ScaleStepPct float64 // spread widening % trigger for each scale level
|
||||
ScaleCooldown time.Duration // minimum time between scale-ins
|
||||
|
||||
// Entry sanity check: reject if price moved beyond this % in the wrong direction
|
||||
ReversalTolerancePct float64
|
||||
|
||||
// Bitget API
|
||||
BitgetAPIKey string
|
||||
BitgetAPISecret string
|
||||
@@ -46,6 +71,22 @@ type jsonConfig struct {
|
||||
TradeCooldownMs int `json:"trade_cooldown_ms"`
|
||||
AlertCooldownSec int `json:"alert_cooldown_sec"`
|
||||
MockSlippagePct float64 `json:"mock_slippage_pct"`
|
||||
MaxPositions int `json:"max_positions"`
|
||||
BlacklistDuration int `json:"blacklist_duration_sec"`
|
||||
InitialCapital float64 `json:"initial_capital"`
|
||||
|
||||
// New: exchange fees
|
||||
TakerFeeBitget float64 `json:"taker_fee_bitget"`
|
||||
TakerFeeHyperLiquid float64 `json:"taker_fee_hyperliquid"`
|
||||
|
||||
// New: exit/risk parameters
|
||||
TakeProfitPct float64 `json:"take_profit_pct"`
|
||||
SpreadReverseExitPct float64 `json:"spread_reverse_exit_pct"`
|
||||
PositionTimeoutSec int `json:"position_timeout_sec"`
|
||||
LegDelayMs int `json:"leg_delay_ms"`
|
||||
ReversalTolerancePct float64 `json:"reversal_tolerance_pct"`
|
||||
ScaleStepPct float64 `json:"scale_step_pct"`
|
||||
ScaleCooldownSec int `json:"scale_cooldown_sec"`
|
||||
}
|
||||
|
||||
func LoadConfig() *Config {
|
||||
@@ -89,10 +130,30 @@ func LoadConfig() *Config {
|
||||
TradeThreshold: getFloat("TRADE_THRESHOLD", jsonCfg.TradeThreshold),
|
||||
TradeAmountUSD: getFloat("TRADE_AMOUNT_USD", jsonCfg.TradeAmountUSD),
|
||||
TradeCooldownMs: int(getFloat("TRADE_COOLDOWN_MS", float64(jsonCfg.TradeCooldownMs))),
|
||||
MaxPositions: int(getFloat("MAX_POSITIONS", float64(jsonCfg.MaxPositions))),
|
||||
|
||||
InitialCapital: getFloat("INITIAL_CAPITAL", jsonCfg.InitialCapital),
|
||||
|
||||
BlacklistDuration: time.Duration(getFloat("BLACKLIST_DURATION_SEC", float64(jsonCfg.BlacklistDuration))) * time.Second,
|
||||
|
||||
TestMode: getBool("TEST_MODE", jsonCfg.TestMode),
|
||||
MockSlippagePct: getFloat("MOCK_SLIPPAGE_PCT", jsonCfg.MockSlippagePct),
|
||||
|
||||
// Exchange fee rates
|
||||
TakerFeeBitget: getFloat("TAKER_FEE_BITGET", jsonCfg.TakerFeeBitget),
|
||||
TakerFeeHyperLiquid: getFloat("TAKER_FEE_HYPERLIQUID", jsonCfg.TakerFeeHyperLiquid),
|
||||
|
||||
// Exit/risk parameters
|
||||
TakeProfitPct: getFloat("TAKE_PROFIT_PCT", jsonCfg.TakeProfitPct),
|
||||
SpreadReverseExitPct: getFloat("SPREAD_REVERSE_EXIT_PCT", jsonCfg.SpreadReverseExitPct),
|
||||
PositionTimeout: time.Duration(getFloat("POSITION_TIMEOUT_SEC", float64(jsonCfg.PositionTimeoutSec))) * time.Second,
|
||||
LegDelay: time.Duration(getFloat("LEG_DELAY_MS", float64(jsonCfg.LegDelayMs))) * time.Millisecond,
|
||||
ReversalTolerancePct: getFloat("REVERSAL_TOLERANCE_PCT", jsonCfg.ReversalTolerancePct),
|
||||
|
||||
// Scale-in parameters
|
||||
ScaleStepPct: getFloat("SCALE_STEP_PCT", jsonCfg.ScaleStepPct),
|
||||
ScaleCooldown: time.Duration(getFloat("SCALE_COOLDOWN_SEC", float64(jsonCfg.ScaleCooldownSec))) * time.Second,
|
||||
|
||||
BitgetAPIKey: getEnv("BITGET_API_KEY", ""),
|
||||
BitgetAPISecret: getEnv("BITGET_API_SECRET", ""),
|
||||
BitgetPassphrase: getEnv("BITGET_PASSPHRASE", ""),
|
||||
@@ -111,6 +172,24 @@ func loadJSONConfig() jsonConfig {
|
||||
TradeCooldownMs: 30000,
|
||||
AlertCooldownSec: 300,
|
||||
MockSlippagePct: 0.005,
|
||||
MaxPositions: 5, // default max 5 concurrent positions
|
||||
BlacklistDuration: 3600, // default 1 hour blacklist observation
|
||||
InitialCapital: 1000, // default $1000 starting capital
|
||||
|
||||
// Exchange fee rates
|
||||
TakerFeeBitget: 0.060, // 0.060%
|
||||
TakerFeeHyperLiquid: 0.045, // 0.045%
|
||||
|
||||
// Exit/risk parameters
|
||||
TakeProfitPct: 0.20, // 0.20% net profit take-profit
|
||||
SpreadReverseExitPct: 0.02, // 0.02% spread reversal exit
|
||||
PositionTimeoutSec: 1800, // 30 minutes
|
||||
LegDelayMs: 300, // 300ms between legs
|
||||
ReversalTolerancePct: 0.1, // 0.1% tolerance for entry sanity check
|
||||
|
||||
// Scale-in parameters
|
||||
ScaleStepPct: 0.10, // 0.10% spread widening per scale level
|
||||
ScaleCooldownSec: 5, // 5 seconds between scales
|
||||
}
|
||||
|
||||
data, err := os.ReadFile("config.json")
|
||||
@@ -145,6 +224,44 @@ func loadJSONConfig() jsonConfig {
|
||||
if cfg.MockSlippagePct != 0 {
|
||||
def.MockSlippagePct = cfg.MockSlippagePct
|
||||
}
|
||||
if cfg.MaxPositions != 0 {
|
||||
def.MaxPositions = cfg.MaxPositions
|
||||
}
|
||||
if cfg.BlacklistDuration != 0 {
|
||||
def.BlacklistDuration = cfg.BlacklistDuration
|
||||
}
|
||||
if cfg.InitialCapital != 0 {
|
||||
def.InitialCapital = cfg.InitialCapital
|
||||
}
|
||||
|
||||
// New config fields
|
||||
if cfg.TakerFeeBitget != 0 {
|
||||
def.TakerFeeBitget = cfg.TakerFeeBitget
|
||||
}
|
||||
if cfg.TakerFeeHyperLiquid != 0 {
|
||||
def.TakerFeeHyperLiquid = cfg.TakerFeeHyperLiquid
|
||||
}
|
||||
if cfg.TakeProfitPct != 0 {
|
||||
def.TakeProfitPct = cfg.TakeProfitPct
|
||||
}
|
||||
if cfg.SpreadReverseExitPct != 0 {
|
||||
def.SpreadReverseExitPct = cfg.SpreadReverseExitPct
|
||||
}
|
||||
if cfg.PositionTimeoutSec != 0 {
|
||||
def.PositionTimeoutSec = cfg.PositionTimeoutSec
|
||||
}
|
||||
if cfg.LegDelayMs != 0 {
|
||||
def.LegDelayMs = cfg.LegDelayMs
|
||||
}
|
||||
if cfg.ReversalTolerancePct != 0 {
|
||||
def.ReversalTolerancePct = cfg.ReversalTolerancePct
|
||||
}
|
||||
if cfg.ScaleStepPct != 0 {
|
||||
def.ScaleStepPct = cfg.ScaleStepPct
|
||||
}
|
||||
if cfg.ScaleCooldownSec != 0 {
|
||||
def.ScaleCooldownSec = cfg.ScaleCooldownSec
|
||||
}
|
||||
|
||||
// Boolean fields: zero default is false, so use OR logic
|
||||
// When JSON has true → true || false = true (override)
|
||||
|
||||
+14
-2
@@ -3,9 +3,21 @@
|
||||
"trade_enabled": true,
|
||||
"arb_threshold": 0.03,
|
||||
"scan_interval_ms": 200,
|
||||
"trade_threshold": 0.05,
|
||||
"trade_threshold": 0.10,
|
||||
"trade_amount_usd": 5,
|
||||
"trade_cooldown_ms": 30000,
|
||||
"alert_cooldown_sec": 300,
|
||||
"mock_slippage_pct": 0.005
|
||||
"mock_slippage_pct": 0.005,
|
||||
"max_positions": 5,
|
||||
"blacklist_duration_sec": 3600,
|
||||
"initial_capital": 1000,
|
||||
"taker_fee_bitget": 0.060,
|
||||
"taker_fee_hyperliquid": 0.045,
|
||||
"take_profit_pct": 0.20,
|
||||
"spread_reverse_exit_pct": 0,
|
||||
"position_timeout_sec": 1800,
|
||||
"leg_delay_ms": 300,
|
||||
"reversal_tolerance_pct": 0.1,
|
||||
"scale_step_pct": 0.10,
|
||||
"scale_cooldown_sec": 5
|
||||
}
|
||||
|
||||
+30
-6
@@ -253,7 +253,8 @@ func (d *Dashboard) Run() {
|
||||
// DetailedStats holds aggregated PnL and duration statistics.
|
||||
type DetailedStats struct {
|
||||
TotalTrades int `json:"total_trades"`
|
||||
TotalPnlPct float64 `json:"total_pnl_pct"`
|
||||
TotalPnlUSD float64 `json:"total_pnl_usd"` // sum of all trade PnL in USD
|
||||
CapitalPnlPct float64 `json:"capital_pnl_pct"` // TotalPnlUSD / InitialCapital * 100
|
||||
AvgPnlPct float64 `json:"avg_pnl_pct"`
|
||||
MaxProfitPct float64 `json:"max_profit_pct"`
|
||||
MaxLossPct float64 `json:"max_loss_pct"`
|
||||
@@ -266,7 +267,7 @@ type DetailedStats struct {
|
||||
|
||||
// calcDetailedStats computes trading statistics from a slice of closed trades.
|
||||
// This is a pure function — no dependency on Trader internals.
|
||||
func calcDetailedStats(trades []TradeRecord) DetailedStats {
|
||||
func calcDetailedStats(trades []TradeRecord, initialCapital float64) DetailedStats {
|
||||
ds := DetailedStats{}
|
||||
if len(trades) == 0 {
|
||||
return ds
|
||||
@@ -275,7 +276,7 @@ func calcDetailedStats(trades []TradeRecord) DetailedStats {
|
||||
ds.MaxLossPct = 1e9 // sentinel
|
||||
for _, tr := range trades {
|
||||
ds.TotalTrades++
|
||||
ds.TotalPnlPct += tr.PnlPct
|
||||
ds.TotalPnlUSD += tr.PnlUSD
|
||||
if tr.PnlPct >= 0 {
|
||||
ds.WinningTrades++
|
||||
if tr.PnlPct > ds.MaxProfitPct {
|
||||
@@ -295,7 +296,8 @@ func calcDetailedStats(trades []TradeRecord) DetailedStats {
|
||||
ds.MaxLossPct = 0
|
||||
}
|
||||
if ds.TotalTrades > 0 {
|
||||
ds.AvgPnlPct = ds.TotalPnlPct / float64(ds.TotalTrades)
|
||||
ds.CapitalPnlPct = ds.TotalPnlUSD / initialCapital * 100
|
||||
ds.AvgPnlPct = ds.TotalPnlUSD / float64(ds.TotalTrades) / initialCapital * 100
|
||||
ds.WinRate = float64(ds.WinningTrades) / float64(ds.TotalTrades) * 100
|
||||
}
|
||||
if totalDur > 0 {
|
||||
@@ -362,6 +364,7 @@ func (d *Dashboard) broadcastLoop() {
|
||||
"scales": pos.ScaleLevels,
|
||||
"duration": time.Since(pos.StartedAt).Round(time.Second).String(),
|
||||
"started_at": pos.StartedAt.Format("15:04:05"),
|
||||
"started_ts": pos.StartedAt.UnixMilli(),
|
||||
}
|
||||
|
||||
// Calculate live PnL from current prices — use weighted average for scale-ins
|
||||
@@ -420,7 +423,7 @@ func (d *Dashboard) broadcastLoop() {
|
||||
|
||||
// 4. Stats + connection status (P3-5)
|
||||
converged, diverged, flat, total := d.trader.GetClosedStats()
|
||||
detail := calcDetailedStats(d.trader.GetClosedTrades())
|
||||
detail := calcDetailedStats(d.trader.GetClosedTrades(), d.trader.cfg.InitialCapital)
|
||||
stats := map[string]interface{}{
|
||||
"total_trades": total,
|
||||
"converged": converged,
|
||||
@@ -428,10 +431,12 @@ func (d *Dashboard) broadcastLoop() {
|
||||
"flat": flat,
|
||||
"open_positions": len(positions),
|
||||
"coins": len(prices),
|
||||
"capital": d.trader.cfg.InitialCapital,
|
||||
|
||||
// Detailed PnL & duration stats (session only)
|
||||
"detail": map[string]interface{}{
|
||||
"total_pnl": detail.TotalPnlPct,
|
||||
"total_pnl_usd": math.Round(detail.TotalPnlUSD*100) / 100,
|
||||
"capital_pnl": math.Round(detail.CapitalPnlPct*10000) / 10000,
|
||||
"avg_pnl": detail.AvgPnlPct,
|
||||
"max_profit": detail.MaxProfitPct,
|
||||
"max_loss": detail.MaxLossPct,
|
||||
@@ -459,6 +464,25 @@ func (d *Dashboard) broadcastLoop() {
|
||||
d.connMu.RUnlock()
|
||||
stats["connections"] = connInfo
|
||||
|
||||
// Blacklist — stale spread coins
|
||||
bl := d.trader.GetBlacklist()
|
||||
blList := make([]map[string]interface{}, 0, len(bl))
|
||||
for coin, t := range bl {
|
||||
if d.trader.cfg.BlacklistDuration > 0 && time.Since(t) >= d.trader.cfg.BlacklistDuration {
|
||||
continue // expired, will be cleaned up on next check
|
||||
}
|
||||
remaining := time.Duration(0)
|
||||
if d.trader.cfg.BlacklistDuration > 0 {
|
||||
remaining = d.trader.cfg.BlacklistDuration - time.Since(t)
|
||||
}
|
||||
blList = append(blList, map[string]interface{}{
|
||||
"coin": coin,
|
||||
"since": t.Format("15:04:05"),
|
||||
"remaining_sec": int(remaining.Seconds()),
|
||||
})
|
||||
}
|
||||
stats["blacklist"] = blList
|
||||
|
||||
d.hub.Broadcast("stats", stats)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -34,6 +34,10 @@ func main() {
|
||||
loadDotEnv()
|
||||
cfg := LoadConfig()
|
||||
|
||||
// Populate package-level taker fees from config (so scanner/dashboard/trader all use it)
|
||||
takerFees[ExBitget] = cfg.TakerFeeBitget
|
||||
takerFees[ExHyperLiquid] = cfg.TakerFeeHyperLiquid
|
||||
|
||||
store := NewPriceStore()
|
||||
notifier := NewNotifier(cfg.TelegramBotToken, cfg.TelegramChatID)
|
||||
|
||||
@@ -58,9 +62,8 @@ func main() {
|
||||
// P3-4: wire real-time trade event broadcast
|
||||
trader.OnTradeEvent = dashboard.BroadcastEvent
|
||||
if trader.IsConfigured() {
|
||||
modeLabel := trader.ModeLabel()
|
||||
log.Printf("[Trader] %s mode: automated trading ENABLED (threshold >= %.2f%%, $%.0f/trade)",
|
||||
modeLabel, cfg.TradeThreshold, cfg.TradeAmountUSD)
|
||||
log.Printf("[Trader] %s mode: automated trading ENABLED (threshold >= %.2f%%, $%.0f/leg, max %d positions, $%.0f capital)",
|
||||
trader.ModeLabel(), cfg.TradeThreshold, cfg.TradeAmountUSD, cfg.MaxPositions, cfg.InitialCapital)
|
||||
if cfg.TestMode {
|
||||
log.Printf("[Trader] Using mock orders with %.3f%% slippage per leg", cfg.MockSlippagePct)
|
||||
}
|
||||
@@ -76,24 +79,18 @@ func main() {
|
||||
sigCh := make(chan os.Signal, 1)
|
||||
signal.Notify(sigCh, os.Interrupt, syscall.SIGUSR1)
|
||||
|
||||
// Collect symbols — skip exchanges the coin isn't listed on
|
||||
var bnSymbols, bgSymbols, hlSymbols, dydxSymbols []string
|
||||
// Collect symbols — only BG and HL for now (BN, dYdX disabled)
|
||||
var bgSymbols, hlSymbols []string
|
||||
for _, c := range TrackedCoins {
|
||||
if c.BN != "" {
|
||||
bnSymbols = append(bnSymbols, c.BN)
|
||||
}
|
||||
if c.BG != "" {
|
||||
bgSymbols = append(bgSymbols, c.BG)
|
||||
}
|
||||
if c.HL != "" {
|
||||
hlSymbols = append(hlSymbols, c.HL)
|
||||
}
|
||||
if c.HL != "" {
|
||||
dydxSymbols = append(dydxSymbols, c.HL)
|
||||
}
|
||||
}
|
||||
|
||||
// Start all exchange WS connections
|
||||
// Start exchange WS connections (BG + HL only)
|
||||
startExchange := func(name string, runner func(func(string, float64, float64, float64)) error) {
|
||||
go func() {
|
||||
for {
|
||||
@@ -112,10 +109,8 @@ func main() {
|
||||
}()
|
||||
}
|
||||
|
||||
startExchange("Binance", exchange.NewBinanceWS(bnSymbols).Run)
|
||||
startExchange("HyperLiquid", exchange.NewHyperLiquidWS(hlSymbols).Run)
|
||||
startExchange("Bitget", exchange.NewBitgetWS(bgSymbols).Run)
|
||||
startExchange("dYdX", exchange.NewDydxWS(dydxSymbols).Run) // B#9: use dedicated symbol list
|
||||
|
||||
log.Println("[Monitor] Waiting for initial data...")
|
||||
time.Sleep(10 * time.Second)
|
||||
|
||||
+1
-9
@@ -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"},
|
||||
|
||||
@@ -129,6 +129,7 @@ type Trader struct {
|
||||
positions map[string]*ArbPosition // coin -> position
|
||||
entering map[string]bool // coin -> being entered (async goroutine)
|
||||
lastTradeTime map[string]time.Time
|
||||
blacklist map[string]time.Time // coin -> when blacklisted (stale spread)
|
||||
closedTrades []TradeRecord // history of closed trades (current session)
|
||||
|
||||
// Historical stats loaded from DB on startup — combined with session stats in GetClosedStats
|
||||
@@ -148,6 +149,7 @@ type TradeRecord struct {
|
||||
EntrySpread float64
|
||||
ExitSpread float64
|
||||
PnlPct float64
|
||||
PnlUSD float64 // absolute PnL in USD
|
||||
Convergence string // "收敛", "发散", "持平"
|
||||
Reason string // exit reason
|
||||
Duration string
|
||||
@@ -172,6 +174,7 @@ func NewTrader(cfg *Config, database *db.DB) *Trader {
|
||||
positions: make(map[string]*ArbPosition),
|
||||
entering: make(map[string]bool),
|
||||
lastTradeTime: make(map[string]time.Time),
|
||||
blacklist: make(map[string]time.Time),
|
||||
}
|
||||
|
||||
// Restore open positions from DB on restart
|
||||
@@ -236,6 +239,7 @@ func (t *Trader) Tick(store *PriceStore, notifier *Notifier) {
|
||||
lowP, highP = hlP, bgP
|
||||
}
|
||||
diffPct := (highP - lowP) / lowP * 100
|
||||
elapsed := time.Since(pos.StartedAt)
|
||||
|
||||
// Retry close for positions that failed to close on previous attempt
|
||||
if pos.Status == "close_failed" {
|
||||
@@ -248,6 +252,12 @@ func (t *Trader) Tick(store *PriceStore, notifier *Notifier) {
|
||||
|
||||
// Check exit: if spread converged, take profit
|
||||
t.checkExit(pos, bgP, hlP, diffPct, notifier)
|
||||
|
||||
// Blacklist: if position still open after 10 minutes without converging,
|
||||
// the spread is likely stale data. Add coin to blacklist and force close.
|
||||
if pos.Status == "open" && elapsed > 10*time.Minute {
|
||||
t.blacklistCoin(pos, notifier)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -275,7 +285,19 @@ func (t *Trader) TryEntry(opp *ArbOpportunity, store *PriceStore, notifier *Noti
|
||||
t.mu.Unlock()
|
||||
return false
|
||||
}
|
||||
if lastTime, ok := t.lastTradeTime[opp.Coin]; ok && time.Since(lastTime) < 30*time.Second {
|
||||
if t.cfg.MaxPositions > 0 && len(t.positions)+len(t.entering) >= t.cfg.MaxPositions {
|
||||
t.mu.Unlock()
|
||||
return false
|
||||
}
|
||||
if blTime, bl := t.blacklist[opp.Coin]; bl {
|
||||
if t.cfg.BlacklistDuration <= 0 || time.Since(blTime) < t.cfg.BlacklistDuration {
|
||||
t.mu.Unlock()
|
||||
return false
|
||||
}
|
||||
// Blacklist expired — remove it and allow re-entry
|
||||
delete(t.blacklist, opp.Coin)
|
||||
}
|
||||
if lastTime, ok := t.lastTradeTime[opp.Coin]; ok && time.Since(lastTime) < time.Duration(t.cfg.TradeCooldownMs)*time.Millisecond {
|
||||
t.mu.Unlock()
|
||||
return false
|
||||
}
|
||||
@@ -313,10 +335,11 @@ func (t *Trader) executeEntry(opp *ArbOpportunity, store *PriceStore, notifier *
|
||||
currBg := exMap[ExBitget]
|
||||
currHl := exMap[ExHyperLiquid]
|
||||
if currBg > 0 && currHl > 0 {
|
||||
if opp.BuyEx == ExBitget && currHl <= currBg*0.999 {
|
||||
reversalMul := 1 - t.cfg.ReversalTolerancePct/100
|
||||
if opp.BuyEx == ExBitget && currHl <= currBg*reversalMul {
|
||||
return false // reversed beyond small tolerance
|
||||
}
|
||||
if opp.BuyEx == ExHyperLiquid && currBg <= currHl*0.999 {
|
||||
if opp.BuyEx == ExHyperLiquid && currBg <= currHl*reversalMul {
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -368,7 +391,7 @@ func (t *Trader) executeEntry(opp *ArbOpportunity, store *PriceStore, notifier *
|
||||
t.cleanup(pos.Coin)
|
||||
return false
|
||||
}
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
time.Sleep(t.cfg.LegDelay)
|
||||
if err := t.placeOrder(pos.ShortLeg, "sell", store); err != "" {
|
||||
// Leg1 placed successfully, leg2 failed — try to close leg1
|
||||
if closeErr := t.closeLeg(pos.LongLeg); closeErr != "" {
|
||||
@@ -434,14 +457,14 @@ func (t *Trader) checkScaleIn(pos *ArbPosition, bgP, hlP, diffPct float64, store
|
||||
}
|
||||
}
|
||||
|
||||
scaleStep := 0.10 // add every 0.10% wider
|
||||
scaleStep := t.cfg.ScaleStepPct // add every X% wider
|
||||
nextLevel := float64(pos.ScaleLevels+1) * scaleStep
|
||||
if diffPct < entryDiff+nextLevel {
|
||||
return
|
||||
}
|
||||
|
||||
// Cooldown: at least 5 seconds between scales
|
||||
if time.Since(pos.LastScaleAt) < 5*time.Second {
|
||||
// Cooldown: use configured interval between scales
|
||||
if time.Since(pos.LastScaleAt) < t.cfg.ScaleCooldown {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -457,7 +480,7 @@ func (t *Trader) checkScaleIn(pos *ArbPosition, bgP, hlP, diffPct float64, store
|
||||
log.Printf("[Trader] %s: Scale-in long failed: %s", pos.Coin, err)
|
||||
return
|
||||
}
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
time.Sleep(t.cfg.LegDelay)
|
||||
if err := t.placeOrderAt(pos.ShortLeg, "sell", store, shortPrice); err != "" {
|
||||
log.Printf("[Trader] %s: Scale-in short failed: %s — position partially scaled (long only)", pos.Coin, err)
|
||||
// Don't close the long leg — the scale-in long order was placed but the
|
||||
@@ -476,41 +499,14 @@ func (t *Trader) checkScaleIn(pos *ArbPosition, bgP, hlP, diffPct float64, store
|
||||
pos.Coin, pos.ScaleLevels, diffPct, entryDiff, pos.AmountUSD)
|
||||
}
|
||||
|
||||
// checkExit closes position when spread converges.
|
||||
// checkExit closes position when net profit >= 0.20% (take profit)
|
||||
// or spread reversed past -0.02% (stop loss) or timeout.
|
||||
func (t *Trader) checkExit(pos *ArbPosition, bgP, hlP, diffPct float64, notifier *Notifier) {
|
||||
if pos.Status != "open" {
|
||||
return
|
||||
}
|
||||
|
||||
// Exit when spread converges to near zero (<= 0.02%)
|
||||
// Or if held too long (30 min timeout)
|
||||
elapsed := time.Since(pos.StartedAt)
|
||||
|
||||
shouldExit := false
|
||||
exitReason := ""
|
||||
|
||||
if diffPct <= 0.02 {
|
||||
shouldExit = true
|
||||
exitReason = "价差收敛,止盈平仓"
|
||||
}
|
||||
|
||||
// Stop-loss: spread reversed (went negative)
|
||||
if diffPct < -0.02 {
|
||||
shouldExit = true
|
||||
exitReason = "价差反转,止盈平仓"
|
||||
}
|
||||
|
||||
if elapsed > 30*time.Minute {
|
||||
shouldExit = true
|
||||
exitReason = "超时平仓"
|
||||
}
|
||||
|
||||
if !shouldExit {
|
||||
return
|
||||
}
|
||||
|
||||
// Calculate P&L — use weighted average entry for scale-in positions
|
||||
// Each scale adds cfg.TradeAmountUSD at the scale price
|
||||
// Current prices for P&L calculation
|
||||
var longCurrent, shortCurrent float64
|
||||
if pos.LongLeg.Exchange == ExBitget {
|
||||
longCurrent, shortCurrent = bgP, hlP
|
||||
@@ -527,6 +523,33 @@ func (t *Trader) checkExit(pos *ArbPosition, bgP, hlP, diffPct float64, notifier
|
||||
totalFees := 2 * (takerFees[ExBitget] + takerFees[ExHyperLiquid]) // 开仓 + 平仓手续费
|
||||
netPnl := longPnl + shortPnl - totalFees
|
||||
|
||||
elapsed := time.Since(pos.StartedAt)
|
||||
|
||||
shouldExit := false
|
||||
exitReason := ""
|
||||
|
||||
// Take profit: net profit >= configured threshold
|
||||
if netPnl >= t.cfg.TakeProfitPct {
|
||||
shouldExit = true
|
||||
exitReason = "利润止盈"
|
||||
}
|
||||
|
||||
// Exit when spread converges to zero or reverses (prices same or flipped)
|
||||
if diffPct <= 0 {
|
||||
shouldExit = true
|
||||
exitReason = "价差收敛止盈"
|
||||
}
|
||||
|
||||
// Timeout: configured max hold time
|
||||
if elapsed > t.cfg.PositionTimeout {
|
||||
shouldExit = true
|
||||
exitReason = "超时平仓"
|
||||
}
|
||||
|
||||
if !shouldExit {
|
||||
return
|
||||
}
|
||||
|
||||
// Convergence analysis
|
||||
convergedPct := (pos.EntrySpread - diffPct) / pos.EntrySpread * 100
|
||||
convergenceLabel := "价差收敛"
|
||||
@@ -579,6 +602,7 @@ func (t *Trader) checkExit(pos *ArbPosition, bgP, hlP, diffPct float64, notifier
|
||||
EntrySpread: pos.EntrySpread,
|
||||
ExitSpread: diffPct,
|
||||
PnlPct: netPnl,
|
||||
PnlUSD: pos.AmountUSD * netPnl / 100,
|
||||
Convergence: convergenceLabel,
|
||||
Reason: exitReason,
|
||||
Duration: elapsed.Round(time.Second).String(),
|
||||
@@ -927,6 +951,10 @@ func (t *Trader) restoreOpenPositions() {
|
||||
return
|
||||
}
|
||||
for i := range openTrades {
|
||||
if t.cfg.MaxPositions > 0 && len(t.positions) >= t.cfg.MaxPositions {
|
||||
log.Printf("[Trader] Skipping restored position %s (max_positions=%d reached)", openTrades[i].Coin, t.cfg.MaxPositions)
|
||||
break
|
||||
}
|
||||
tr := &openTrades[i]
|
||||
// Recreate position structure from DB record
|
||||
pos := &ArbPosition{
|
||||
@@ -958,6 +986,57 @@ func (t *Trader) restoreOpenPositions() {
|
||||
t.lastTradeTime[tr.Coin] = tr.OpenedAt
|
||||
}
|
||||
if len(openTrades) > 0 {
|
||||
log.Printf("[Trader] Restored %d open positions from DB", len(openTrades))
|
||||
log.Printf("[Trader] Restored %d open positions from DB", len(t.positions))
|
||||
}
|
||||
}
|
||||
|
||||
// blacklistCoin adds a coin to the blacklist and force-closes its position.
|
||||
func (t *Trader) blacklistCoin(pos *ArbPosition, notifier *Notifier) {
|
||||
t.mu.Lock()
|
||||
t.blacklist[pos.Coin] = time.Now()
|
||||
t.mu.Unlock()
|
||||
|
||||
log.Printf("[Trader] ⛔ %s: Blacklisted — position open %.0fm without convergence", pos.Coin, time.Since(pos.StartedAt).Minutes())
|
||||
notifier.Send(fmt.Sprintf(
|
||||
"<b>[黑名单]</b> %s/USDT\n"+
|
||||
" 开仓 %.0f 分钟未收敛\n"+
|
||||
" 已加入黑名单观察\n",
|
||||
pos.Coin, time.Since(pos.StartedAt).Minutes()))
|
||||
|
||||
// Force-close the position immediately
|
||||
pos.Status = "close_failed" // triggers retryClose on next tick
|
||||
}
|
||||
|
||||
// GetBlacklist returns a copy of the current blacklist (coin -> blacklisted at).
|
||||
func (t *Trader) GetBlacklist() map[string]time.Time {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
r := make(map[string]time.Time, len(t.blacklist))
|
||||
for k, v := range t.blacklist {
|
||||
r[k] = v
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// IsBlacklisted checks if a coin is currently blacklisted (within duration).
|
||||
func (t *Trader) IsBlacklisted(coin string) bool {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
blTime, exists := t.blacklist[coin]
|
||||
if !exists {
|
||||
return false
|
||||
}
|
||||
if t.cfg.BlacklistDuration > 0 && time.Since(blTime) >= t.cfg.BlacklistDuration {
|
||||
delete(t.blacklist, coin)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// RemoveBlacklist removes a coin from the blacklist manually.
|
||||
func (t *Trader) RemoveBlacklist(coin string) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
delete(t.blacklist, coin)
|
||||
log.Printf("[Trader] ✅ %s: Removed from blacklist", coin)
|
||||
}
|
||||
|
||||
+32
-10
@@ -38,8 +38,8 @@ function updateClock() {
|
||||
setInterval(updateClock, 1000);
|
||||
updateClock();
|
||||
|
||||
const EXCHANGES = ['Binance', 'HyperLiquid', 'Bitget', 'dYdX'];
|
||||
const COINS = ['DOGE', 'LINK', 'ONDO', 'OP', 'WIF', 'ARB'];
|
||||
const EXCHANGES = ['HyperLiquid', 'Bitget'];
|
||||
const COINS = []; // populated dynamically from SSE data
|
||||
|
||||
function formatPrice(p) {
|
||||
if (p == null || p <= 0) return '-';
|
||||
@@ -97,6 +97,13 @@ const eventHandlers = {};
|
||||
eventHandlers.prices = (prices) => {
|
||||
if (!prices || prices.length === 0) return;
|
||||
|
||||
// Dynamically populate COINS list on first data
|
||||
if (COINS.length === 0) {
|
||||
for (const row of prices) {
|
||||
COINS.push(row.coin);
|
||||
}
|
||||
}
|
||||
|
||||
let html = '';
|
||||
let coinsOnline = 0;
|
||||
|
||||
@@ -110,7 +117,6 @@ eventHandlers.prices = (prices) => {
|
||||
|
||||
const cells = EXCHANGES.map(ex => {
|
||||
const p = row[ex];
|
||||
const sp = row[ex + '_spread'];
|
||||
const key = coin + '.' + ex;
|
||||
const prev = priceCache[key];
|
||||
const curP = p || 0;
|
||||
@@ -131,9 +137,6 @@ eventHandlers.prices = (prices) => {
|
||||
}
|
||||
|
||||
let display = formatPrice(p);
|
||||
if (sp && sp > 0.01) {
|
||||
display += `<span class="text-dim" style="font-size:10px"> (${sp.toFixed(3)}%)</span>`;
|
||||
}
|
||||
return `<td class="${cls}">${display}</td>`;
|
||||
});
|
||||
|
||||
@@ -171,14 +174,17 @@ eventHandlers.arb = (opps) => {
|
||||
els.arbBody.innerHTML = html;
|
||||
};
|
||||
|
||||
// P3-3: Positions with live PnL
|
||||
// P3-3: Positions with live PnL — sorted by time (oldest first)
|
||||
eventHandlers.positions = (positions) => {
|
||||
if (!positions || positions.length === 0) {
|
||||
els.posBody.innerHTML = '<tr><td colspan="8" class="text-dim">无持仓</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
const html = positions.map(p => {
|
||||
// Sort by coin name (stable, deterministic)
|
||||
const sorted = [...positions].sort((a, b) => a.coin.localeCompare(b.coin));
|
||||
|
||||
const html = sorted.map(p => {
|
||||
const pnl = p.pnl_est;
|
||||
const pnlStr = pnl != null ? pnl.toFixed(4) + '%' : '-';
|
||||
const curSpread = p.current_spread != null ? p.current_spread.toFixed(4) + '%' : '-';
|
||||
@@ -209,8 +215,12 @@ eventHandlers.stats = (stats) => {
|
||||
// Detailed PnL stats
|
||||
if (stats.detail) {
|
||||
const d = stats.detail;
|
||||
$('stat-total-pnl').textContent = (d.total_pnl != null) ? d.total_pnl.toFixed(2) + '%' : '—';
|
||||
$('stat-avg-pnl').textContent = (d.avg_pnl != null) ? d.avg_pnl.toFixed(2) + '%' : '—';
|
||||
// Total PnL: show both USD and percentage of capital
|
||||
const usdStr = (d.total_pnl_usd != null) ? '$' + d.total_pnl_usd.toFixed(2) : '—';
|
||||
const pctStr = (d.capital_pnl != null) ? d.capital_pnl.toFixed(4) + '%' : '—';
|
||||
$('stat-total-pnl').textContent = usdStr + ' (' + pctStr + ')';
|
||||
$('stat-total-pnl').className = pnlClass(d.capital_pnl);
|
||||
$('stat-capital').textContent = (stats.capital != null) ? '$' + stats.capital.toFixed(0) : '—';
|
||||
$('stat-win-rate').textContent = (d.win_rate != null) ? d.win_rate.toFixed(1) + '%' : '—';
|
||||
$('stat-max-profit').textContent = (d.max_profit != null) ? '+' + d.max_profit.toFixed(2) + '%' : '—';
|
||||
$('stat-max-loss').textContent = (d.max_loss != null) ? d.max_loss.toFixed(2) + '%' : '—';
|
||||
@@ -225,6 +235,18 @@ eventHandlers.stats = (stats) => {
|
||||
}).join(' ');
|
||||
els.connDetail.innerHTML = dots;
|
||||
}
|
||||
|
||||
// Blacklist — stale spread coins
|
||||
if (stats.blacklist && stats.blacklist.length > 0) {
|
||||
const html = stats.blacklist.map(b => {
|
||||
const minLeft = Math.floor(b.remaining_sec / 60);
|
||||
const secLeft = b.remaining_sec % 60;
|
||||
return `<span style="color:#f85149;margin-right:12px;font-size:13px">⛔ ${b.coin} (${b.since} 剩余 ${minLeft}:${secLeft.toString().padStart(2,'0')})</span>`;
|
||||
}).join('');
|
||||
$('bl-body').innerHTML = html;
|
||||
} else {
|
||||
$('bl-body').innerHTML = '<span class="text-dim">暂无</span>';
|
||||
}
|
||||
};
|
||||
|
||||
// P3-4: Real-time trade events
|
||||
|
||||
+12
-4
@@ -27,14 +27,14 @@
|
||||
<div class="stat"><label>收敛</label><span id="stat-converged" class="pct-green">0</span></div>
|
||||
<div class="stat"><label>发散</label><span id="stat-diverged" class="pct-red">0</span></div>
|
||||
<div class="stat"><label>持平</label><span id="stat-flat" class="pct-gray">0</span></div>
|
||||
<div class="stat"><label>持仓</label><span id="stat-positions" class="pct-yellow">0</span></div>
|
||||
<div class="stat"><label>持仓</label><span id="stat-positions" class="pct-yellow">0 / <span id="stat-max-pos">5</span></span></div>
|
||||
<div class="stat"><label>币种</label><span id="stat-coins" class="pct-blue">0</span></div>
|
||||
<div class="stat" id="conn-stats"><label>连接</label><span id="conn-detail"></span></div>
|
||||
</div>
|
||||
<!-- Detailed PnL stats -->
|
||||
<div class="stats-row detail-stats" style="margin-top:4px;font-size:12px;opacity:0.85">
|
||||
<div class="stat"><label>总PnL</label><span id="stat-total-pnl">—</span></div>
|
||||
<div class="stat"><label>平均PnL</label><span id="stat-avg-pnl">—</span></div>
|
||||
<div class="stat"><label>本金</label><span id="stat-capital">—</span></div>
|
||||
<div class="stat"><label>胜率</label><span id="stat-win-rate">—</span></div>
|
||||
<div class="stat"><label>最多盈利</label><span id="stat-max-profit">—</span></div>
|
||||
<div class="stat"><label>最多亏损</label><span id="stat-max-loss">—</span></div>
|
||||
@@ -42,16 +42,24 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Blacklist -->
|
||||
<section class="card" id="bl-card">
|
||||
<h2>⛔ 黑名单</h2>
|
||||
<div class="stats-row" id="bl-body">
|
||||
<span class="text-dim">暂无</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Price Table -->
|
||||
<section class="card" id="prices-card">
|
||||
<h2>💰 实时价格 <span id="prices-age" class="text-dim" style="font-size:11px"></span></h2>
|
||||
<div class="table-wrap">
|
||||
<table id="price-table">
|
||||
<thead>
|
||||
<tr><th>币种</th><th>Binance</th><th>HyperLiquid</th><th>Bitget</th><th>dYdX</th><th>BG↔HL价差</th></tr>
|
||||
<tr><th>币种</th><th>HyperLiquid</th><th>Bitget</th><th>BG↔HL价差</th></tr>
|
||||
</thead>
|
||||
<tbody id="price-body">
|
||||
<tr><td colspan="6" class="loading">等待数据...</td></tr>
|
||||
<tr><td colspan="4" class="loading">等待数据...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user