diff --git a/.env.example b/.env.example index ad68996..abe93a8 100644 --- a/.env.example +++ b/.env.example @@ -1,39 +1,26 @@ -# 交易所监控 + 自动套利 +# 三所价差异动监控 # 复制为 .env 并填入实际值 -# Telegram 推送 +# Telegram 推送 (可选) TELEGRAM_BOT_TOKEN=*** TELEGRAM_CHAT_ID=你的聊天ID -# ============================================ -# 自动交易开关 (设置为 1 启用) -TRADE_ENABLED=0 - -# 交易参数 -TRADE_THRESHOLD=0.15 # 最低套利利润率 (%) -TRADE_AMOUNT_USD=10 # 每腿金额 (USDT) -TRADE_COOLDOWN_MS=30000 # 同一币种套利冷却 (毫秒) - -# Bitget API (需开通合约API) +# Bitget API (WS 行情) BITGET_API_KEY=*** BITGET_API_SECRET=*** BITGET_PASSPHRASE=你的密码短语 -# HyperLiquid API (钱包私钥) -HL_PRIVATE_KEY=你的ed25519私钥(hex) -HL_ADDRESS=你的钱包地址 +# Binance API (WS 行情 + K线 REST) +BINANCE_API_KEY=*** +BINANCE_API_SECRET=*** + +# OKX 行情为公开 WS,无需 API Key + +# 网络代理 (国内环境需要) +HTTPS_PROXY=http://127.0.0.1:7890 # ============================================ -# 以下为 ema-monitor 使用的参数 (保持不变) -DATA_API_BASE=http://localhost:80 -SYMBOL=BTC/USDT:USDT -FETCH_LIMIT_BASE=8000 -POLL_INTERVAL=10 -PROXIMITY_THRESHOLD_PCT=0.15 -ALERT_COOLDOWN=3600 - -# ============================================ -# 测试模式 (模拟交易,不需要真实 API Key) -# TEST_MODE=true 时,TRADE_ENABLED 被忽略 -TEST_MODE=false -MOCK_SLIPPAGE_PCT=0.005 # 每腿模拟滑点 (%) +# 扫描参数 (可选,config.json 已有默认值) +# SURGE_ENABLED=true +# MOMENTUM_ENABLED=true +# TREND_ENABLED=true diff --git a/CLAUDE.md b/CLAUDE.md index d3443cc..13d1db1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project Overview -Cross-exchange perpetual contract arbitrage system between Bitget and HyperLiquid. Scans ~150 coins for price spreads, executes automated arbitrage trades with scale-in/exit logic, and displays real-time data on a React dashboard. +3-exchange spread surge detection system using Binance, OKX, and Bitget. Scans ~150 coins for inter-exchange price spread anomalies, detects surge events with per-coin adaptive baselines, and displays real-time data on a React dashboard. ## Build & Run Commands @@ -23,91 +23,99 @@ cd frontend && npm run dev # Frontend production build cd frontend && npm run build - -# IPC commands (talk to running daemon via Unix socket) -./exchange-monitor status -./exchange-monitor close-all -./exchange-monitor close DOGE -./exchange-monitor stop -./exchange-monitor start ``` ## Architecture ### Data Flow ``` -Exchange WS (HL + BG) → PriceStore (in-memory) → scanner → Trader (entry/exit/scale) - ↓ - dashboard (SSE hub, :8888) - ↓ - React frontend (SSE events) +Exchange WS (Bitget + Binance + OKX) → PriceStore (in-memory) + ↓ + scanner (Scan3Ex) + ↓ + ┌───────────────┼───────────────┐ + ↓ ↓ ↓ + surge_detector momentum.go trend.go + cumulative.go trend_filter.go + ↓ ↓ ↓ + dashboard (SSE hub, :8888) + ↓ + React frontend (SSE events) ``` -### Main Loop (main.go:148-245) -Fixed 50ms tick: trader.Tick() → scan.scanBGHL() → TryEntry() for each opportunity. Every 30s: status log. Hourly: Telegram summary. +### Main Loop (main.go) +Fixed 50ms tick: reads snap from PriceStore → Scan3Ex() → surgeDetector.Tick() → momentum.Tick() etc. Every 30s: status log. Hourly: Telegram summary. ### Package Layout | Package | Files | Responsibility | |---------|-------|---------------| -| `main` (root) | `main.go`, `scanner.go`, `trader.go`, `dashboard.go`, `config.go`, `types.go`, `notifier.go`, `ipc.go` | All core logic in a single flat package | -| `exchange/` | `connector.go`, `bitget.go`, `hyperliquid.go`, `bitget_trade.go`, `hyperliquid_trade.go`, `helpers.go` | WS reconnector + exchange-specific REST/WS APIs | -| `db/` | `db.go`, `trade_repo.go` | SQLite persistence (trades, orders, system_orders, config_log) | +| `main` (root) | `main.go`, `scanner.go`, `dashboard.go`, `config.go`, `types.go`, `surge_detector.go`, `momentum.go`, `trend.go`, `cumulative.go`, `trend_filter.go` | All core logic in a single flat package | +| `exchange/` | `connector.go`, `bitget.go`, `binance.go`, `okx.go`, `helpers.go` | WS reconnector + exchange-specific REST/WS APIs | +| `db/` | `db.go`, `surge_event_repo.go` | SQLite persistence (surge_events, cm_events, trend_events, trend_signals) | | `frontend/` | Vite + React | Real-time dashboard consuming SSE from backend | ### Key Types - **PriceStore** — Thread-safe in-memory map of coin→exchange→price, with bid/ask spread tracking -- **ArbOpportunity** — Scanning result: coin, direction (BG→HL or HL→BG), prices, net profit % -- **ArbPosition** — Open position with long/short legs, scale-in tracking, entry prices array -- **Trader** — Manages positions, entry/exit logic, fund tracking, blacklist, DB persistence +- **ThreeExSpread** — 3-exchange scan result: coin, prices, spread %, max/min exchange +- **SurgeDetector** — Per-coin adaptive baseline surge detection with rolling window median +- **SurgeEvent** — Detected surge: coin, prices, spread, baseline, direction, leading exchange +- **MomentumTracker** — Multi-window (1s/5s/15s/60s) price change tracking per exchange +- **TrendDetector** — Cross-exchange trend state machine (idle→alert→confirmed→exhausting) +- **CumulativeTracker** — 1m/5m/1h consensus change tracking across exchanges +- **TrendFilter** — K-line based quiet detection + EMA52 trend filtering ### Exchange Connector -`PriceConnector` (exchange/connector.go) is a reusable WebSocket reconnector with exponential backoff (1s-30s), configurable ping interval, and read deadline. Bitget uses text ping frames; HyperLiquid uses standard ping/pong. +`PriceConnector` (exchange/connector.go) is a reusable WebSocket reconnector with exponential backoff (1s-30s), configurable ping interval, and read deadline. Bitget uses text ping frames; Binance and OKX use standard ping/pong. -### Trading Logic +### Surge Detection Logic -- **Entry (TryEntry → executeEntry)**: Checks threshold, margin, blacklist, cooldown, max positions. Places both legs asynchronously with 300ms delay. Persists DB record immediately on "entering" status for crash recovery. -- **Scale-in (checkScaleIn)**: Adds position when spread widens by ScaleStepPct per level. Posts additional orders on both legs. -- **Exit (checkExit)**: Take profit at threshold, converged spread ≤ 0.02%, or timeout. Retries failed closes up to 30 times. -- **Blacklist**: Force-closes position open >10min without convergence, prevents re-entry for BlacklistDuration. - -### Net Profit Calculation - -```go -netProfit(buyPrice, sellPrice, buyFee, sellFee) = (revenue/cost - 1)*100 - 2*(buyFee + sellFee) -``` -Where cost = buyPrice * (1 + buyFee/100), revenue = sellPrice * (1 - sellFee/100). Four total fees (2 entry + 2 exit). +- **Adaptive baseline**: Per-coin rolling window (600 samples, ~30s at 50ms tick) of 3-exchange max spreads +- **Threshold**: median(spreads) × multiplier (default 3.0), with min floor (0.05%) +- **Trigger**: currentSpread > threshold AND cooldown (60s) passed +- **Direction**: Compare highest exchange deviation from median vs lowest exchange deviation +- **Leading exchange**: The exchange furthest from median price (first to reflect price move) ### Configuration Priority `.env` vars > `config.json` > code defaults. Config struct in `config.go`. -Key env vars: `BITGET_API_KEY`, `BITGET_API_SECRET`, `BITGET_PASSPHRASE`, `HL_PRIVATE_KEY`, `HL_ADDRESS`, `HL_API_ADDRESS`, `TELEGRAM_BOT_TOKEN`, `TELEGRAM_CHAT_ID`, `TRADE_ENABLED`, `TEST_MODE`. +Key env vars: `BITGET_API_KEY`, `BITGET_API_SECRET`, `BITGET_PASSPHRASE`, `BINANCE_API_KEY`, `BINANCE_API_SECRET`, `HTTPS_PROXY`, `TELEGRAM_BOT_TOKEN`, `TELEGRAM_CHAT_ID`. ### Dashboard API | Endpoint | Description | |----------|-------------| | `GET /` | Serves index.html (disk first, fallback embed) | -| `GET /api/status` | Prices, positions, stats, exchange funds | -| `GET /events` | SSE stream (prices, positions, arb, stats — 1s interval) | +| `GET /api/status` | Current prices snapshot | +| `GET /events` | SSE stream (prices, spread_3ex, momentum, trend, cumulative, trend_filter, surge, status) | | `GET /api/history?coin=&exchange=` | Price history ring buffer (500 pts) | -| `GET /api/spread-history?coin=` | BG↔HL spread history | -| `GET /api/trades?page=&limit=&coin=` | Paginated trade history from DB | -| `GET /api/trade/{id}` | Trade detail + orders | +| `GET /api/spread-history?coin=` | 3-exchange spread history | +| `GET /api/surge-events?limit=` | Surge event history from DB or memory | +| `GET /api/cm-history` | Cumulative change event history | +| `GET /api/trend-signals` | Trend filter signal history | | `GET /api/connections` | Exchange WS health (online/stale/offline) | -| `POST /api/stop` | Stop trading + force-close positions | -| `POST /api/start` | Resume trading | + +### SSE Events + +| Event | Data | Frequency | +|-------|------|-----------| +| `prices` | All coin prices + 3-ex spread | Every tick | +| `spread_3ex` | Top 3-ex spreads scan results | Every tick | +| `momentum` | Multi-window price change % | Every tick | +| `trend` | Trend state machine snapshots | Every tick | +| `cumulative` | Cumulative consensus changes | Every tick | +| `trend_filter` | K-line filter states | Every tick | +| `trend_signal` | Individual trend signal (enter/exit) | On event | +| `surge` | Current spread/baseline snapshots | Every tick | +| `surge_event` | New surge detection event | On detection | +| `status` | Connection health + coin count | Every tick | ### Database -SQLite at `~/Project/exchange-monitor-go/data/trades.db` (single-writer mode). Tables: `trades` (trade-level), `orders` (per-leg filled orders), `system_orders` (linked long+short order pairs), `config_log`. +SQLite at `~/Project/exchange-monitor-go/data/trades.db` (single-writer mode). Tables: `surge_events`, `cm_events`, `trend_events`, `trend_signals`. ### Coin Tracking -~150 coins in `TrackedCoins` slice (scanner.go). Each entry has Name, BN (Binance, currently unused), BG (Bitget symbol), HL (HyperLiquid symbol). Only BG+HL are actively connected. - -### IPC (Unix Socket) - -`/tmp/exchange-monitor.sock` — JSON commands from CLI to daemon. Actions: status, close-all, close {coin}, stop, start. +~150 coins in `TrackedCoins` slice (scanner.go). Each entry has Name, BN (Binance symbol), BG (Bitget symbol), OKX (OKX symbol). Active WebSocket connections: Bitget + Binance + OKX. diff --git a/README.md b/README.md index d217101..f314c4d 100644 --- a/README.md +++ b/README.md @@ -1,33 +1,38 @@ -# ⚡ 跨交易所永续合约套利监控 +# 三所价差异动监控 -Bitget ↔ HyperLiquid 跨交易所永续合约价差套利系统。支持模拟盘/实盘交易、价差监控、自动开仓/加仓/平仓、Web 仪表盘。 +Binance + OKX + Bitget 三交易所价差异动实时检测系统。通过追踪不同交易所之间的价差异常,捕捉币价「启动」的瞬间。 ## 功能特点 -- **实时价差监控** — 200ms 扫描间隔,追踪 DOGE/LINK/ONDO/OP/WIF/ARB 六个币种 -- **自动套利交易** — 价差超过阈值自动开仓,收敛自动平仓,支持多级加仓 -- **模拟/实盘双模式** — `TestMode` 控制,模拟模式无需真实 API Key +- **三所价差扫描** — 实时计算 Binance/OKX/Bitget 之间的最大价差,按价差排序展示 +- **自适应 Surge 检测** — 每个币维护独立的滚动窗口基线,检测价差异常飙升 +- **方向判断** — 根据领先交易所判断上涨/下跌启动方向 +- **动量扫描** — 多时间窗口 (1s/5s/15s/60s) 价格变动率追踪 +- **趋势检测** — 跨交易所一致性确认的趋势状态机 (idle→alert→confirmed→exhausting) +- **累积变动** — 1m/5m/1h 多交易所共识变动追踪 +- **趋势过滤** — K 线数据 + EMA52 趋势过滤,识别安静后的异动 - **Web 仪表盘** — Go 内置 HTTP Server + Vite React 前端,SSE 实时推送 -- **SQLite 持久化** — 交易记录、订单明细、手续费明细全量存储 -- **手续费精确计算** — 逐笔累加实际 USD 手续费(开仓费+平仓费),非百分比估算 -- **交易所独立资金管理** — 每交易所 $500 初始资金,开仓前检查两边余额充足 -- **模拟滑点** — 可配置 `mock_slippage_pct`,模拟真实滑点对净利的影响 -- **持仓详情弹窗** — 点击持仓卡片查看完整交易详情(价差、PnL、手续费分腿) -- **Telegram 通知** — 开仓/平仓/异常实时推送 +- **SQLite 持久化** — Surge 事件、累积变动事件全量存储 ## 架构 ``` ┌─────────────────────────────────────────────────┐ -│ scanner.go ← 每 200ms 扫描价差 │ -│ ↓ 发现机会 (NetProfit > 阈值) │ -│ trader.go ← 开仓/加仓/平仓逻辑 │ -│ ↓ 持久化 │ -│ db/ ← SQLite (trades / orders / system) │ -│ ↓ SSE 推送 │ -│ dashboard.go ← HTTP Server :8888 │ -│ ↓ │ -│ frontend/ ← Vite + React 仪表盘 │ +│ Exchange WS (Bitget + Binance + OKX) │ +│ ↓ 价格推送 │ +│ PriceStore ← 内存价格存储 │ +│ ↓ │ +│ scanner.go ← 三所价差扫描 │ +│ ↓ │ +│ surge_detector.go ← 自适应Surge检测 │ +│ momentum.go ← 动量扫描 │ +│ trend.go ← 趋势检测 │ +│ cumulative.go ← 累积变动追踪 │ +│ trend_filter.go ← K线趋势过滤 │ +│ ↓ SSE 推送 │ +│ dashboard.go ← HTTP Server :8888 │ +│ ↓ │ +│ frontend/ ← Vite + React 仪表盘 │ └─────────────────────────────────────────────────┘ ``` @@ -39,22 +44,29 @@ Bitget ↔ HyperLiquid 跨交易所永续合约价差套利系统。支持模拟 ```json { + "arb_threshold": 0.3, "scan_interval_ms": 200, - "trade_enabled": true, - "test_mode": true, - "trade_threshold": 0.20, - "take_profit_pct": 0.20, - "mock_slippage_pct": 0.05, - "trade_amount_usd": 5, - "max_positions": 5, - "initial_capital": 500, - "taker_fee_bitget": 0.060, - "taker_fee_hyperliquid": 0.045, - "telegram_bot_token": "xxx", - "telegram_chat_id": "xxx" + "alert_cooldown_sec": 300, + "surge_enabled": true, + "surge_window_size": 600, + "surge_baseline_multiplier": 3.0, + "surge_min_abs_spread_pct": 0.05, + "surge_cooldown_sec": 60, + "momentum_enabled": true, + "momentum_threshold_pct": 0.25, + "trend_enabled": true, + "trend_baseline_window": 600, + "trend_anomaly_mul": 3.0, + "trend_confirm_ticks": 3, + "trend_alert_cooldown_ms": 60000 } ``` +环境变量 (`.env`): +- `BITGET_API_KEY`, `BITGET_API_SECRET`, `BITGET_PASSPHRASE` — Bitget API +- `BINANCE_API_KEY`, `BINANCE_API_SECRET` — Binance API (可选,用于K线) +- `HTTPS_PROXY` — 网络代理 (国内环境需要) + ### 2. 启动 ```bash @@ -79,54 +91,28 @@ npm run dev # 开发模式 (Vite HMR :5173) npm run build # 构建生产版本 ``` -后端优先从 `frontend/dist/` 读取静态文件(热加载),回退到 Go embed。 - ## 配置参数 | 参数 | 说明 | 默认 | |------|------|------| -| `scan_interval_ms` | 扫描间隔 (ms) | 200 | -| `trade_threshold` | 开仓净利阈值 (%) | 0.20 | -| `take_profit_pct` | 止盈净利 (%) | 0.20 | -| `mock_slippage_pct` | 模拟滑点 (%) | 0.05 | -| `trade_amount_usd` | 每腿交易额 ($) | 5 | -| `max_positions` | 最大并行持仓 | 5 | -| `taker_fee_bitget` | Bitget 吃单费率 (%) | 0.060 | -| `taker_fee_hyperliquid` | HyperLiquid 吃单费率 (%) | 0.045 | -| `scale_step_pct` | 加仓步长 (%) | 0.10 | -| `position_timeout` | 最长持仓时间 | 10m | -| `leg_delay` | 两腿下单间隔 | 300ms | -| `initial_capital` | 每交易所初始资金 ($) | 500 | +| `surge_enabled` | 启用 Surge 检测 | true | +| `surge_window_size` | 滚动窗口样本数 | 600 | +| `surge_baseline_multiplier` | 基线倍数(阈值 = 基线 × N) | 3.0 | +| `surge_min_abs_spread_pct` | 最小绝对价差 % | 0.05 | +| `surge_cooldown_sec` | 同币冷却秒数 | 60 | +| `momentum_enabled` | 启用动量扫描 | true | +| `trend_enabled` | 启用趋势检测 | true | +| `trend_baseline_window` | 趋势基线窗口 | 600 | +| `trend_anomaly_mul` | 异常检测倍数 | 3.0 | +| `trend_confirm_ticks` | 确认所需次数 | 3 | ## 数据库 -SQLite (`data/trades.db`),三张核心表: +SQLite (`data/trades.db`),核心表: | 表 | 说明 | |----|------| -| `trades` | 交易主表 — 价差、PnL、手续费 ($) | -| `orders` | 订单明细 — 每腿的开仓/加仓/平仓、手续费 ($) | -| `system_orders` | 系统订单 — 双向关联 long↔short 订单 | - -## 版本历史 - -### v1.3 (当前) -- ✨ **交易所独立资金管理** — 每交易所 $500 初始资金,开仓前检查余额 -- ✨ **持仓点击详情弹窗** — 点击持仓卡片弹出完整交易详情 -- ✨ **模拟滑点** — 新增 `mock_slippage_pct` 配置,模拟真实成交滑点 -- ✨ **止损硬编码** — 取消 `SpreadReverseExitPct` 配置,价差 <= 0 硬止损 -- 🐛 修复 DB 迁移 tab 字符损坏导致历史交易数据不显示 -- 📊 PnL 统计分腿存储(`pnl_long_usd`/`pnl_short_usd`/`fee_long_usd`/`fee_short_usd`) - -### v1.2.1 -- ✨ scale-in 后 leg.EntryPrice 更新为加权平均(`weightedAvgPrice`) -- ✨ 进程重启恢复仓位时,从 `orders` 表加载 scale prices,重建完整价格切片 -- ✨ 新增 `GetScalePrices()` DB 方法 - -### v1.2 -- ✨ `system_orders` 表,记录系统级开仓/加仓/平仓 -- ✨ 手续费改为逐笔累加 USD,不再用百分比估算 -- ✨ Vite + React 前端,支持热加载 -- ✨ Web 仪表盘持仓 PnL 美元化显示 -- 🐛 修复 `persistTrade` 费用在 `SaveTrade` 后才累加导致 fee=0 的 bug -- 🗑 移除老版 Chart.js 图表 +| `surge_events` | Surge 异动事件 — 币种、价格、价差、方向、领先交易所 | +| `cm_events` | 累积变动事件 — 1m/5m/1h 多所共识变动 | +| `trend_events` | 趋势状态变迁 — idle→alert→confirmed→exhausting 全生命周期 | +| `trend_signals` | 趋势过滤信号 — K 线安静 + EMA 异动信号记录 | diff --git a/config.go b/config.go index b1072be..a78d961 100644 --- a/config.go +++ b/config.go @@ -4,7 +4,6 @@ import ( "encoding/json" "os" "strconv" - "time" ) // Config holds all system configuration. @@ -13,44 +12,15 @@ type Config struct { TelegramBotToken string TelegramChatID string AlertCooldownSec int // seconds between alerts for same coin - ArbThreshold float64 // minimum net profit % to trigger alert + ArbThreshold float64 // minimum spread % to trigger alert ScanIntervalMs int // how often scanner runs (milliseconds) - // Automated trading - TradeEnabled bool - TradeThreshold float64 // minimum profit % to execute trade - TradeAmountUSD float64 // amount per leg in USDT - TradeCooldownMs int // ms between trades of same coin - MaxPositions int // max concurrent open positions (0 = unlimited) - - // Capital - InitialCapital float64 // starting capital in USD (for PnL % calculation) - - // Blacklist — stale spread observation - BlacklistDuration time.Duration // how long a coin stays blacklisted (0 = permanent) - - // ExcludedCoins — coins to never trade (hard block) - ExcludedCoins []string - - // Test mode (no real API keys needed) - TestMode bool - MockSlippagePct float64 // simulated slippage per order (e.g. 0.01 = 0.01%) - - // Exchange fee rates (% per order) - TakerFeeBitget float64 - TakerFeeHyperLiquid float64 - - // Exit/risk parameters - TakeProfitPct float64 // net profit % threshold for take-profit - PositionTimeout time.Duration // max position hold time before auto-close - LegDelay time.Duration // delay between placing long and short legs - - // Scale-in parameters - ScaleStepPct float64 // spread widening % trigger for each scale level - ScaleCooldown time.Duration // minimum time between scale-ins - - // Entry sanity check: reject if price moved beyond this % in the wrong direction - ReversalTolerancePct float64 + // Surge detection + SurgeEnabled bool + SurgeWindowSize int // rolling window samples (default: 600 = ~30s) + SurgeBaselineMultiplier float64 // baseline * N = threshold (default: 3.0) + SurgeMinAbsSpreadPct float64 // minimum absolute spread % (default: 0.05) + SurgeCooldownSec int // cooldown seconds per coin (default: 60) // Momentum scanning mode MomentumEnabled bool @@ -62,33 +32,20 @@ type Config struct { TrendAnomalyMul float64 // z-score multiplier for alert threshold (default: 3.0) TrendConfirmTicks int // ticks needed for state confirmation (default: 3) TrendAlertCooldown int64 // ms cooldown between alerts for same coin (default: 60000) - - // Bitget API - BitgetAPIKey string - BitgetAPISecret string - BitgetPassphrase string - - // HyperLiquid API - HLPrivateKey string // ed25519 private key hex - HLAddress string // main account address - HLAPIAddress string // API wallet address (signer, auto-derived if empty) } // jsonConfig maps config.json fields (non-secret defaults checked into git). type jsonConfig struct { - TestMode bool `json:"test_mode"` - TradeEnabled bool `json:"trade_enabled"` - ArbThreshold float64 `json:"arb_threshold"` - ScanIntervalMs int `json:"scan_interval_ms"` - TradeThreshold float64 `json:"trade_threshold"` - TradeAmountUSD float64 `json:"trade_amount_usd"` - TradeCooldownMs int `json:"trade_cooldown_ms"` - AlertCooldownSec int `json:"alert_cooldown_sec"` - MockSlippagePct float64 `json:"mock_slippage_pct"` - MaxPositions int `json:"max_positions"` - BlacklistDuration int `json:"blacklist_duration_sec"` - InitialCapital float64 `json:"initial_capital"` - ExcludedCoins []string `json:"excluded_coins"` + ArbThreshold float64 `json:"arb_threshold"` + ScanIntervalMs int `json:"scan_interval_ms"` + AlertCooldownSec int `json:"alert_cooldown_sec"` + + // Surge detection + SurgeEnabled bool `json:"surge_enabled"` + SurgeWindowSize int `json:"surge_window_size"` + SurgeBaselineMultiplier float64 `json:"surge_baseline_multiplier"` + SurgeMinAbsSpreadPct float64 `json:"surge_min_abs_spread_pct"` + SurgeCooldownSec int `json:"surge_cooldown_sec"` // Momentum scanning MomentumEnabled bool `json:"momentum_enabled"` @@ -100,18 +57,6 @@ type jsonConfig struct { TrendAnomalyMul float64 `json:"trend_anomaly_mul"` TrendConfirmTicks int `json:"trend_confirm_ticks"` TrendAlertCooldown int64 `json:"trend_alert_cooldown_ms"` - - // New: exchange fees - TakerFeeBitget float64 `json:"taker_fee_bitget"` - TakerFeeHyperLiquid float64 `json:"taker_fee_hyperliquid"` - - // New: exit/risk parameters - TakeProfitPct float64 `json:"take_profit_pct"` - PositionTimeoutSec int `json:"position_timeout_sec"` - LegDelayMs int `json:"leg_delay_ms"` - ReversalTolerancePct float64 `json:"reversal_tolerance_pct"` - ScaleStepPct float64 `json:"scale_step_pct"` - ScaleCooldownSec int `json:"scale_cooldown_sec"` } func LoadConfig() *Config { @@ -151,34 +96,12 @@ func LoadConfig() *Config { ArbThreshold: getFloat("ARB_THRESHOLD", jsonCfg.ArbThreshold), ScanIntervalMs: int(getFloat("SCAN_INTERVAL_MS", float64(jsonCfg.ScanIntervalMs))), - TradeEnabled: getBool("TRADE_ENABLED", jsonCfg.TradeEnabled), - TradeThreshold: getFloat("TRADE_THRESHOLD", jsonCfg.TradeThreshold), - TradeAmountUSD: getFloat("TRADE_AMOUNT_USD", jsonCfg.TradeAmountUSD), - TradeCooldownMs: int(getFloat("TRADE_COOLDOWN_MS", float64(jsonCfg.TradeCooldownMs))), - MaxPositions: int(getFloat("MAX_POSITIONS", float64(jsonCfg.MaxPositions))), - - InitialCapital: getFloat("INITIAL_CAPITAL", jsonCfg.InitialCapital), - - BlacklistDuration: time.Duration(getFloat("BLACKLIST_DURATION_SEC", float64(jsonCfg.BlacklistDuration))) * time.Second, - - TestMode: getBool("TEST_MODE", jsonCfg.TestMode), - MockSlippagePct: getFloat("MOCK_SLIPPAGE_PCT", jsonCfg.MockSlippagePct), - - // Exchange fee rates - TakerFeeBitget: getFloat("TAKER_FEE_BITGET", jsonCfg.TakerFeeBitget), - TakerFeeHyperLiquid: getFloat("TAKER_FEE_HYPERLIQUID", jsonCfg.TakerFeeHyperLiquid), - - // Exit/risk parameters - TakeProfitPct: getFloat("TAKE_PROFIT_PCT", jsonCfg.TakeProfitPct), - PositionTimeout: time.Duration(getFloat("POSITION_TIMEOUT_SEC", float64(jsonCfg.PositionTimeoutSec))) * time.Second, - LegDelay: time.Duration(getFloat("LEG_DELAY_MS", float64(jsonCfg.LegDelayMs))) * time.Millisecond, - ReversalTolerancePct: getFloat("REVERSAL_TOLERANCE_PCT", jsonCfg.ReversalTolerancePct), - - // Scale-in parameters - ScaleStepPct: getFloat("SCALE_STEP_PCT", jsonCfg.ScaleStepPct), - ScaleCooldown: time.Duration(getFloat("SCALE_COOLDOWN_SEC", float64(jsonCfg.ScaleCooldownSec))) * time.Second, - - ExcludedCoins: jsonCfg.ExcludedCoins, + // Surge detection + SurgeEnabled: getBool("SURGE_ENABLED", jsonCfg.SurgeEnabled), + SurgeWindowSize: int(getFloat("SURGE_WINDOW_SIZE", float64(jsonCfg.SurgeWindowSize))), + SurgeBaselineMultiplier: getFloat("SURGE_BASELINE_MULTIPLIER", jsonCfg.SurgeBaselineMultiplier), + SurgeMinAbsSpreadPct: getFloat("SURGE_MIN_ABS_SPREAD_PCT", jsonCfg.SurgeMinAbsSpreadPct), + SurgeCooldownSec: int(getFloat("SURGE_COOLDOWN_SEC", float64(jsonCfg.SurgeCooldownSec))), // Momentum scanning MomentumEnabled: getBool("MOMENTUM_ENABLED", jsonCfg.MomentumEnabled), @@ -190,14 +113,6 @@ func LoadConfig() *Config { TrendAnomalyMul: getFloat("TREND_ANOMALY_MUL", jsonCfg.TrendAnomalyMul), TrendConfirmTicks: int(getFloat("TREND_CONFIRM_TICKS", float64(jsonCfg.TrendConfirmTicks))), TrendAlertCooldown: int64(getFloat("TREND_ALERT_COOLDOWN_MS", float64(jsonCfg.TrendAlertCooldown))), - - BitgetAPIKey: getEnv("BITGET_API_KEY", ""), - BitgetAPISecret: getEnv("BITGET_API_SECRET", ""), - BitgetPassphrase: getEnv("BITGET_PASSPHRASE", ""), - - HLPrivateKey: getEnv("HL_PRIVATE_KEY", ""), - HLAddress: getEnv("HL_ADDRESS", ""), - HLAPIAddress: getEnv("HL_API_ADDRESS", ""), } } @@ -205,36 +120,22 @@ func loadJSONConfig() jsonConfig { def := jsonConfig{ ArbThreshold: 0.03, ScanIntervalMs: 500, - TradeThreshold: 0.15, - TradeAmountUSD: 10, - TradeCooldownMs: 30000, AlertCooldownSec: 300, - MockSlippagePct: 0.005, - MaxPositions: 5, // default max 5 concurrent positions - BlacklistDuration: 3600, // default 1 hour blacklist observation - InitialCapital: 1000, // default $1000 starting capital - // Exchange fee rates - TakerFeeBitget: 0.060, // 0.060% - TakerFeeHyperLiquid: 0.045, // 0.045% - - // Exit/risk parameters - TakeProfitPct: 0.20, // 0.20% net profit take-profit - PositionTimeoutSec: 1800, // 30 minutes - LegDelayMs: 300, // 300ms between legs - ReversalTolerancePct: 0.1, // 0.1% tolerance for entry sanity check - - // Scale-in parameters - ScaleStepPct: 0.10, // 0.10% spread widening per scale level - ScaleCooldownSec: 5, // 5 seconds between scales + // Surge detection + SurgeEnabled: true, + SurgeWindowSize: 600, // ~30s at 50ms tick + SurgeBaselineMultiplier: 3.0, // baseline * N = threshold + SurgeMinAbsSpreadPct: 0.05, // minimum absolute spread % + SurgeCooldownSec: 60, // seconds between alerts for same coin // Momentum scanning MomentumThresholdPct: 0.25, // 0.25% change flags momentum // Trend detection - TrendBaselineWindow: 600, // ~30s at 50ms tick - TrendAnomalyMul: 3.0, // 3 sigma z-score threshold - TrendConfirmTicks: 3, // 3 consecutive ticks for confirmation + TrendBaselineWindow: 600, // ~30s at 50ms tick + TrendAnomalyMul: 3.0, // 3 sigma z-score threshold + TrendConfirmTicks: 3, // 3 consecutive ticks for confirmation TrendAlertCooldown: 60000, // 1 min cooldown } @@ -255,58 +156,22 @@ func loadJSONConfig() jsonConfig { if cfg.ScanIntervalMs != 0 { def.ScanIntervalMs = cfg.ScanIntervalMs } - if cfg.TradeThreshold != 0 { - def.TradeThreshold = cfg.TradeThreshold - } - if cfg.TradeAmountUSD != 0 { - def.TradeAmountUSD = cfg.TradeAmountUSD - } - if cfg.TradeCooldownMs != 0 { - def.TradeCooldownMs = cfg.TradeCooldownMs - } if cfg.AlertCooldownSec != 0 { def.AlertCooldownSec = cfg.AlertCooldownSec } - if cfg.MockSlippagePct != 0 { - def.MockSlippagePct = cfg.MockSlippagePct - } - if cfg.MaxPositions != 0 { - def.MaxPositions = cfg.MaxPositions - } - if cfg.BlacklistDuration != 0 { - def.BlacklistDuration = cfg.BlacklistDuration - } - if cfg.InitialCapital != 0 { - def.InitialCapital = cfg.InitialCapital - } - // New config fields - if cfg.TakerFeeBitget != 0 { - def.TakerFeeBitget = cfg.TakerFeeBitget + // Surge detection JSON overrides + if cfg.SurgeWindowSize != 0 { + def.SurgeWindowSize = cfg.SurgeWindowSize } - if cfg.TakerFeeHyperLiquid != 0 { - def.TakerFeeHyperLiquid = cfg.TakerFeeHyperLiquid + if cfg.SurgeBaselineMultiplier != 0 { + def.SurgeBaselineMultiplier = cfg.SurgeBaselineMultiplier } - if cfg.TakeProfitPct != 0 { - def.TakeProfitPct = cfg.TakeProfitPct + if cfg.SurgeMinAbsSpreadPct != 0 { + def.SurgeMinAbsSpreadPct = cfg.SurgeMinAbsSpreadPct } - if cfg.PositionTimeoutSec != 0 { - def.PositionTimeoutSec = cfg.PositionTimeoutSec - } - if cfg.LegDelayMs != 0 { - def.LegDelayMs = cfg.LegDelayMs - } - if cfg.ReversalTolerancePct != 0 { - def.ReversalTolerancePct = cfg.ReversalTolerancePct - } - if cfg.ScaleStepPct != 0 { - def.ScaleStepPct = cfg.ScaleStepPct - } - if cfg.ScaleCooldownSec != 0 { - def.ScaleCooldownSec = cfg.ScaleCooldownSec - } - if len(cfg.ExcludedCoins) > 0 { - def.ExcludedCoins = cfg.ExcludedCoins + if cfg.SurgeCooldownSec != 0 { + def.SurgeCooldownSec = cfg.SurgeCooldownSec } if cfg.MomentumThresholdPct != 0 { @@ -328,12 +193,9 @@ func loadJSONConfig() jsonConfig { } // Boolean fields: zero default is false, so use OR logic - // When JSON has true → true || false = true (override) - // When JSON has false → false || false = false (keep default) - def.TestMode = cfg.TestMode || def.TestMode - def.TradeEnabled = cfg.TradeEnabled || def.TradeEnabled def.MomentumEnabled = cfg.MomentumEnabled || def.MomentumEnabled def.TrendEnabled = cfg.TrendEnabled || def.TrendEnabled + def.SurgeEnabled = cfg.SurgeEnabled || def.SurgeEnabled return def } diff --git a/config.json b/config.json index 67378fb..991853e 100644 --- a/config.json +++ b/config.json @@ -1,26 +1,17 @@ { - "test_mode": false, - "trade_enabled": true, "arb_threshold": 0.3, "scan_interval_ms": 200, - "trade_threshold": 0.3, - "trade_amount_usd": 20, - "trade_cooldown_ms": 30000, "alert_cooldown_sec": 300, - "mock_slippage_pct": 0.05, - "max_positions": 1, - "blacklist_duration_sec": 3600, - "initial_capital": 1000, - "taker_fee_bitget": 0.060, - "taker_fee_hyperliquid": 0.045, - "take_profit_pct": 0.3, - "spread_reverse_exit_pct": 0, - "position_timeout_sec": 1800, - "leg_delay_ms": 300, - "reversal_tolerance_pct": 0.1, - "scale_step_pct": 0.3, - "scale_cooldown_sec": 5, + "surge_enabled": true, + "surge_window_size": 600, + "surge_baseline_multiplier": 3.0, + "surge_min_abs_spread_pct": 0.05, + "surge_cooldown_sec": 60, "momentum_enabled": true, "momentum_threshold_pct": 0.25, - "trend_enabled": true + "trend_enabled": true, + "trend_baseline_window": 600, + "trend_anomaly_mul": 3.0, + "trend_confirm_ticks": 3, + "trend_alert_cooldown_ms": 60000 } diff --git a/cumulative.go b/cumulative.go index 18c64f1..1f62b5c 100644 --- a/cumulative.go +++ b/cumulative.go @@ -27,8 +27,6 @@ func shortExName(name string) string { switch name { case ExBitget: return "bg" - case ExHyperLiquid: - return "hl" case ExBinance: return "bn" case ExOKX: @@ -459,7 +457,7 @@ func (ct *CumulativeTracker) makeEvent(coin, prevState, newState, direction stri ExAgree: exAgree, ExTotal: exTotal, BGChange1m: exChanges[ExBitget], - HLChange1m: exChanges[ExHyperLiquid], + HLChange1m: 0, BNChange1m: exChanges[ExBinance], OKXChange1m: exChanges[ExOKX], Timestamp: time.Now().UnixMilli(), diff --git a/dashboard.go b/dashboard.go index b85c38c..32b539c 100644 --- a/dashboard.go +++ b/dashboard.go @@ -5,7 +5,6 @@ import ( "fmt" "io/fs" "log" - "math" "net/http" "os" "sync" @@ -132,17 +131,17 @@ func (ph *priceHistory) GetHistory(coin, exchange string, limit int) []pricePoin } // ============================================================ -// Spread History — tracks BG↔HL spread % per coin (P3-2) +// Spread History — tracks 3-exchange max spread % per coin // ============================================================ type spreadPoint struct { T int64 `json:"t"` - Spread float64 `json:"s"` // spread % (positive = BG cheaper than HL for BG->HL direction) + Spread float64 `json:"s"` // 3-exchange max spread % } type spreadHistory struct { mu sync.RWMutex - buffers map[string][]spreadPoint // coin -> spread points + buffers map[string][]spreadPoint } func newSpreadHistory() *spreadHistory { @@ -186,19 +185,18 @@ type Dashboard struct { history *priceHistory spreads *spreadHistory store *PriceStore - trader *Trader db *db.DB addr string cfg *Config - // cached arb scan results + // cached scan results mu sync.RWMutex - lastScan []*ArbOpportunity + lastScan []ThreeExSpread scanTime time.Time - // P3-5: connection status — exchange -> last update time + // connection status — exchange -> last update time connMu sync.RWMutex - connMap map[string]time.Time // exchange name -> last price timestamp + connMap map[string]time.Time // Momentum tracker momentumTracker *MomentumTracker @@ -211,23 +209,26 @@ type Dashboard struct { // Trend filter (K-line based quiet + EMA filter) trendFilter *TrendFilter + + // Surge detector + surgeDetector *SurgeDetector } -func NewDashboard(store *PriceStore, trader *Trader, database *db.DB, addr string, cfg *Config, momentumTracker *MomentumTracker, trendDetector *TrendDetector, cumulativeTracker *CumulativeTracker, trendFilter *TrendFilter) *Dashboard { +func NewDashboard(store *PriceStore, database *db.DB, addr string, cfg *Config, momentumTracker *MomentumTracker, trendDetector *TrendDetector, cumulativeTracker *CumulativeTracker, trendFilter *TrendFilter, surgeDetector *SurgeDetector) *Dashboard { d := &Dashboard{ - hub: NewSSEHub(), - history: newPriceHistory(), - spreads: newSpreadHistory(), - store: store, - trader: trader, - db: database, - addr: addr, - cfg: cfg, - connMap: make(map[string]time.Time), + hub: NewSSEHub(), + history: newPriceHistory(), + spreads: newSpreadHistory(), + store: store, + db: database, + addr: addr, + cfg: cfg, + connMap: make(map[string]time.Time), momentumTracker: momentumTracker, trendDetector: trendDetector, cumulativeTracker: cumulativeTracker, trendFilter: trendFilter, + surgeDetector: surgeDetector, } // Wire trend event persistence to SQLite @@ -269,28 +270,22 @@ func (d *Dashboard) Run() { if diskFS := os.DirFS("frontend/dist"); true { if _, diskErr := fs.Stat(diskFS, "index.html"); diskErr == nil { staticSub = diskFS - log.Printf("[Web] Serving from disk: frontend/dist/ (hot reload enabled)") } } - if err != nil && staticSub == nil { - log.Printf("[Web] Failed to create static sub-fs: %v", err) - } else { + if err == nil && staticSub != nil { mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticSub)))) } mux.HandleFunc("GET /", d.handleIndex) mux.HandleFunc("GET /api/status", d.handleStatus) mux.HandleFunc("GET /api/history", d.handleHistory) - mux.HandleFunc("GET /api/spread-history", d.handleSpreadHistory) // P3-2 - mux.HandleFunc("GET /api/trades", d.handleTrades) - mux.HandleFunc("GET /api/trade/", d.handleTradeDetail) - mux.HandleFunc("GET /api/connections", d.handleConnStatus) // P3-5 + mux.HandleFunc("GET /api/spread-history", d.handleSpreadHistory) + mux.HandleFunc("GET /api/connections", d.handleConnStatus) mux.HandleFunc("GET /api/trend-history", d.handleTrendHistory) mux.HandleFunc("GET /api/cm-history", d.handleCmHistory) mux.HandleFunc("GET /api/trend-signals", d.handleTrendSignals) + mux.HandleFunc("GET /api/surge-events", d.handleSurgeEvents) mux.HandleFunc("GET /events", d.handleSSE) - mux.HandleFunc("POST /api/stop", d.handleStop) - mux.HandleFunc("POST /api/start", d.handleStart) server := &http.Server{ Addr: d.addr, @@ -305,68 +300,6 @@ func (d *Dashboard) Run() { } } -// ============================================================ -// Stats computation — kept separate from trading logic -// ============================================================ - -// DetailedStats holds aggregated PnL and duration statistics. -type DetailedStats struct { - TotalTrades int `json:"total_trades"` - TotalPnlUSD float64 `json:"total_pnl_usd"` // sum of all trade PnL in USD - CapitalPnlPct float64 `json:"capital_pnl_pct"` // TotalPnlUSD / InitialCapital * 100 - AvgPnlPct float64 `json:"avg_pnl_pct"` - MaxProfitPct float64 `json:"max_profit_pct"` - MaxLossPct float64 `json:"max_loss_pct"` - AvgDuration string `json:"avg_duration"` - TotalDuration string `json:"total_duration"` - WinningTrades int `json:"winning_trades"` - LosingTrades int `json:"losing_trades"` - WinRate float64 `json:"win_rate"` -} - -// calcDetailedStats computes trading statistics from a slice of closed trades. -// This is a pure function — no dependency on Trader internals. -func calcDetailedStats(trades []TradeRecord, initialCapital float64) DetailedStats { - ds := DetailedStats{} - if len(trades) == 0 { - return ds - } - var totalDur time.Duration - ds.MaxLossPct = 1e9 // sentinel - for _, tr := range trades { - ds.TotalTrades++ - ds.TotalPnlUSD += tr.PnlUSD - if tr.PnlPct >= 0 { - ds.WinningTrades++ - if tr.PnlPct > ds.MaxProfitPct { - ds.MaxProfitPct = tr.PnlPct - } - } else { - ds.LosingTrades++ - if tr.PnlPct < ds.MaxLossPct { - ds.MaxLossPct = tr.PnlPct - } - } - if !tr.ClosedAt.IsZero() && !tr.OpenedAt.IsZero() { - totalDur += tr.ClosedAt.Sub(tr.OpenedAt) - } - } - if ds.MaxLossPct == 1e9 { - ds.MaxLossPct = 0 - } - if ds.TotalTrades > 0 { - ds.CapitalPnlPct = ds.TotalPnlUSD / initialCapital * 100 - ds.AvgPnlPct = ds.TotalPnlUSD / float64(ds.TotalTrades) / initialCapital * 100 - ds.WinRate = float64(ds.WinningTrades) / float64(ds.TotalTrades) * 100 - } - if totalDur > 0 { - avgDur := totalDur / time.Duration(ds.TotalTrades) - ds.AvgDuration = avgDur.Round(time.Second).String() - ds.TotalDuration = totalDur.Round(time.Second).String() - } - return ds -} - // broadcastLoop pushes data to SSE clients every 1 second. func (d *Dashboard) broadcastLoop() { tick := time.NewTicker(1 * time.Second) @@ -378,7 +311,7 @@ func (d *Dashboard) broadcastLoop() { continue } - // 1. Prices + spreads + connection status + // 1. Prices + 3-exchange spreads var prices []map[string]interface{} for _, coin := range TrackedCoins { exMap := snap[coin.Name] @@ -398,135 +331,48 @@ func (d *Dashboard) broadcastLoop() { } } - // P3-2: Calculate BG↔HL spread and record + // Calculate 3-exchange max spread + bnP := exMap[ExBinance] + okxP := exMap[ExOKX] bgP := exMap[ExBitget] - hlP := exMap[ExHyperLiquid] - if bgP > 0 && hlP > 0 { - spreadPct := (hlP - bgP) / bgP * 100 - entry["bg_hl_spread"] = spreadPct + if bnP > 0 && okxP > 0 && bgP > 0 { + prices_ := []float64{bnP, okxP, bgP} + minP, maxP := prices_[0], prices_[0] + for _, p := range prices_[1:] { + if p < minP { minP = p } + if p > maxP { maxP = p } + } + spreadPct := (maxP - minP) / minP * 100 + entry["spread_3ex"] = spreadPct d.spreads.Record(coin.Name, spreadPct) - - // Both directions net profit after fees (4 taker fees: 2 entry + 2 exit) - cost := bgP * (1 + takerFees[ExBitget]/100) - revenue := hlP * (1 - takerFees[ExHyperLiquid]/100) - netBG := (revenue/cost-1)*100 - 2*(takerFees[ExBitget]+takerFees[ExHyperLiquid]) - - cost = hlP * (1 + takerFees[ExHyperLiquid]/100) - revenue = bgP * (1 - takerFees[ExBitget]/100) - netHL := (revenue/cost-1)*100 - 2*(takerFees[ExHyperLiquid]+takerFees[ExBitget]) - - entry["net_bg_to_hl"] = math.Round(netBG*10000) / 10000 - entry["net_hl_to_bg"] = math.Round(netHL*10000) / 10000 } prices = append(prices, entry) } d.hub.Broadcast("prices", prices) - // 2. Open positions with live PnL (P3-3) — read from decoupled snapshot, never blocks trader - positions := d.trader.ReadSnapshot() - posList := make([]map[string]interface{}, 0, len(positions)) - for _, pos := range positions { - posEntry := map[string]interface{}{ - "coin": pos.Coin, - "direction": pos.Direction, - "amount_usd": pos.AmountUSD, - "entry_spread": pos.EntrySpread, - "scales": pos.ScaleLevels, - "duration": time.Since(pos.StartedAt).Round(time.Second).String(), - "started_at": pos.StartedAt.Format("15:04:05"), - "started_ts": pos.StartedAt.UnixMilli(), - "long_exchange": pos.LongLeg.Exchange, - "short_exchange": pos.ShortLeg.Exchange, - "long_entry": pos.LongLeg.EntryPrice, - "short_entry": pos.ShortLeg.EntryPrice, - "db_trade_id": pos.DBTradeID, - } - - // Calculate live PnL from current prices — use weighted average for scale-ins - if exMap := snap[pos.Coin]; exMap != nil { - bgP := exMap[ExBitget] - hlP := exMap[ExHyperLiquid] - if bgP > 0 && hlP > 0 { - var longCurrent, shortCurrent float64 - if pos.LongLeg.Exchange == ExBitget { - longCurrent, shortCurrent = bgP, hlP - } else { - longCurrent, shortCurrent = hlP, bgP - } - longAvg := weightedAvgPrice(pos.LongEntryPrices, pos.AmountUSD/float64(max(1, len(pos.LongEntryPrices)))) - shortAvg := weightedAvgPrice(pos.ShortEntryPrices, pos.AmountUSD/float64(max(1, len(pos.ShortEntryPrices)))) - longPnl := (longCurrent - longAvg) / longAvg * 100 - shortPnl := (shortAvg - shortCurrent) / shortAvg * 100 - feeEntryUSD := float64(1+pos.ScaleLevels) * (pos.AmountUSD / float64(max(1, 1+pos.ScaleLevels))) * (takerFees[ExBitget] + takerFees[ExHyperLiquid]) / 100 - feeExitUSD := pos.AmountUSD * (takerFees[ExBitget] + takerFees[ExHyperLiquid]) / 100 - pricePnLUSD := pos.AmountUSD * (longPnl + shortPnl) / 100 - netPnLUSD := pricePnLUSD - feeEntryUSD - feeExitUSD - - currentSpread := (hlP - bgP) / bgP * 100 - if pos.LongLeg.Exchange == ExHyperLiquid { - // HL→BG: spread positive when bgP > hlP - currentSpread = (bgP - hlP) / hlP * 100 - } - posEntry["current_spread"] = math.Round(currentSpread*10000) / 10000 - posEntry["pnl_est"] = math.Round(netPnLUSD*10000) / 10000 - } - } - - posList = append(posList, posEntry) - } - d.hub.Broadcast("positions", posList) - - // 3. Arb scan results + // 2. 3-exchange scan results d.mu.RLock() scanCopy := d.lastScan d.mu.RUnlock() if len(scanCopy) > 0 { scanList := make([]map[string]interface{}, 0, len(scanCopy)) - for _, opp := range scanCopy { + for _, s := range scanCopy { scanList = append(scanList, map[string]interface{}{ - "coin": opp.Coin, - "direction": opp.Direction, - "buy_ex": opp.BuyEx, - "sell_ex": opp.SellEx, - "buy_price": opp.BuyPrice, - "sell_price": opp.SellPrice, - "net_profit": opp.NetProfit, - "gross": opp.GrossBasis, + "coin": s.Coin, + "spread_pct": s.SpreadPct, + "bn_price": s.BnPrice, + "okx_price": s.OkxPrice, + "bg_price": s.BgPrice, + "max_ex": s.MaxEx, + "min_ex": s.MinEx, }) } - d.hub.Broadcast("arb", scanList) + d.hub.Broadcast("spread_3ex", scanList) } - // 4. Stats + connection status (P3-5) - converged, diverged, flat, total := d.trader.GetClosedStats() - detail := calcDetailedStats(d.trader.GetClosedTrades(), d.trader.cfg.InitialCapital) - stats := map[string]interface{}{ - "total_trades": total, - "converged": converged, - "diverged": diverged, - "flat": flat, - "open_positions": len(positions), - "coins": len(prices), - "capital": d.trader.cfg.InitialCapital, - - // Detailed PnL & duration stats (session only) - "detail": map[string]interface{}{ - "total_pnl_usd": math.Round(detail.TotalPnlUSD*100) / 100, - "capital_pnl": math.Round(detail.CapitalPnlPct*10000) / 10000, - "avg_pnl": detail.AvgPnlPct, - "max_profit": detail.MaxProfitPct, - "max_loss": detail.MaxLossPct, - "avg_dur": detail.AvgDuration, - "win_rate": detail.WinRate, - "wins": detail.WinningTrades, - "losses": detail.LosingTrades, - "total_dur": detail.TotalDuration, - }, - } - - // Connection status + // 3. Connection status d.connMu.RLock() connInfo := make(map[string]string) for ex, lastTime := range d.connMap { @@ -540,51 +386,14 @@ func (d *Dashboard) broadcastLoop() { } } d.connMu.RUnlock() - stats["connections"] = connInfo - // Trading status - stats["trading"] = map[string]interface{}{ - "active": !d.trader.IsShuttingDown(), - "mode": d.trader.ModeLabel(), - "test": d.trader.cfg.TestMode, - "target": d.trader.realTradesTarget, - "done": d.trader.realTradesDone, + status := map[string]interface{}{ + "coins": len(prices), + "connections": connInfo, } + d.hub.Broadcast("status", status) - // Per-exchange fund tracking - exFunds := d.trader.GetExchangeFunds() - exFundsMap := make(map[string]map[string]float64, len(exFunds)) - for ex, ef := range exFunds { - exFundsMap[ex] = map[string]float64{ - "balance": math.Round(ef.Balance*100) / 100, - "total_fee": math.Round(ef.TotalFee*100) / 100, - "total_pnl": math.Round(ef.TotalPnl*100) / 100, - } - } - stats["exchange_funds"] = exFundsMap - - // Blacklist — stale spread coins - bl := d.trader.GetBlacklist() - blList := make([]map[string]interface{}, 0, len(bl)) - for coin, t := range bl { - if d.trader.cfg.BlacklistDuration > 0 && time.Since(t) >= d.trader.cfg.BlacklistDuration { - continue // expired, will be cleaned up on next check - } - remaining := time.Duration(0) - if d.trader.cfg.BlacklistDuration > 0 { - remaining = d.trader.cfg.BlacklistDuration - time.Since(t) - } - blList = append(blList, map[string]interface{}{ - "coin": coin, - "since": t.Format("15:04:05"), - "remaining_sec": int(remaining.Seconds()), - }) - } - stats["blacklist"] = blList - - d.hub.Broadcast("stats", stats) - - // 5. Momentum data (if enabled and tracker is available) + // 4. Momentum data (if enabled) if d.momentumTracker != nil && d.cfg.MomentumEnabled { momentumData := d.momentumTracker.Snapshot(d.cfg.MomentumThresholdPct) if len(momentumData) > 0 { @@ -592,7 +401,7 @@ func (d *Dashboard) broadcastLoop() { } } - // 6. Trend detection (if enabled) + // 5. Trend detection (if enabled) if d.trendDetector != nil && d.cfg.TrendEnabled { d.trendDetector.Tick() trendData := d.trendDetector.Snapshot() @@ -601,7 +410,7 @@ func (d *Dashboard) broadcastLoop() { } } - // 7. Cumulative change tracking (always on if tracker exists) + // 6. Cumulative change tracking if d.cumulativeTracker != nil { d.cumulativeTracker.Tick() cmData := d.cumulativeTracker.GetTopCoins(30) @@ -610,7 +419,7 @@ func (d *Dashboard) broadcastLoop() { } } - // 8. Trend filter (K-line based quiet + EMA) + // 7. Trend filter (K-line based quiet + EMA) if d.trendFilter != nil { d.trendFilter.Tick() filterData := d.trendFilter.Snapshot(0) @@ -618,16 +427,24 @@ func (d *Dashboard) broadcastLoop() { d.hub.Broadcast("trend_filter", filterData) } } + + // 8. Surge status (current spread/baseline for all coins) + if d.surgeDetector != nil && d.cfg.SurgeEnabled { + surgeSnap := d.surgeDetector.Snapshot() + if len(surgeSnap) > 0 { + d.hub.Broadcast("surge", surgeSnap) + } + } } } // ============================================================ -// Public methods called from main.go / trader +// Public methods called from main.go // ============================================================ -func (d *Dashboard) UpdateScan(opps []*ArbOpportunity) { +func (d *Dashboard) UpdateScan(spreads []ThreeExSpread) { d.mu.Lock() - d.lastScan = opps + d.lastScan = spreads d.scanTime = time.Now() d.mu.Unlock() } @@ -636,14 +453,14 @@ func (d *Dashboard) RecordPrice(coin, exchange string, price float64) { d.history.Record(coin, exchange, price) } -// RecordConnStatus updates the last-seen time for an exchange (P3-5). +// RecordConnStatus updates the last-seen time for an exchange. func (d *Dashboard) RecordConnStatus(exchange string) { d.connMu.Lock() d.connMap[exchange] = time.Now() d.connMu.Unlock() } -// BroadcastEvent sends an immediate SSE event (P3-4). +// BroadcastEvent sends an immediate SSE event. func (d *Dashboard) BroadcastEvent(event string, data interface{}) { d.hub.Broadcast(event, data) } @@ -659,7 +476,6 @@ func (d *Dashboard) handleIndex(w http.ResponseWriter, r *http.Request) { // Try disk first (hot reload) data, err = os.ReadFile("frontend/dist/index.html") if err != nil { - // Fall back to embed data, err = staticFS.ReadFile("frontend/dist/index.html") } if err != nil { @@ -672,25 +488,9 @@ func (d *Dashboard) handleIndex(w http.ResponseWriter, r *http.Request) { func (d *Dashboard) handleStatus(w http.ResponseWriter, r *http.Request) { snap := d.store.GetAll() - positions := d.trader.ReadSnapshot() - converged, diverged, flat, total := d.trader.GetClosedStats() - - // Format exchange funds (snake_case, like SSE) - exFunds := d.trader.GetExchangeFunds() - exFundsMap := make(map[string]map[string]float64, len(exFunds)) - for ex, ef := range exFunds { - exFundsMap[ex] = map[string]float64{ - "balance": math.Round(ef.Balance*100) / 100, - "total_fee": math.Round(ef.TotalFee*100) / 100, - "total_pnl": math.Round(ef.TotalPnl*100) / 100, - } - } - resp := map[string]interface{}{ - "prices": snap, - "positions": len(positions), - "stats": map[string]int{"total": total, "converged": converged, "diverged": diverged, "flat": flat}, - "exchange_funds": exFundsMap, + "prices": snap, + "coins": len(snap), } writeJSON(w, resp) } @@ -704,7 +504,7 @@ func (d *Dashboard) handleHistory(w http.ResponseWriter, r *http.Request) { for c := range snap { coins = append(coins, c) } - writeJSON(w, map[string]interface{}{"coins": coins, "exchanges": []string{"Binance", "HyperLiquid", "Bitget", "dYdX"}}) + writeJSON(w, map[string]interface{}{"coins": coins, "exchanges": []string{ExBinance, ExOKX, ExBitget}}) return } points := d.history.GetHistory(coin, exchange, 300) @@ -715,7 +515,7 @@ func (d *Dashboard) handleHistory(w http.ResponseWriter, r *http.Request) { }) } -// handleSpreadHistory returns BG↔HL spread history for a coin (P3-2). +// handleSpreadHistory returns 3-exchange max spread history for a coin. func (d *Dashboard) handleSpreadHistory(w http.ResponseWriter, r *http.Request) { coin := r.URL.Query().Get("coin") if coin == "" { @@ -729,7 +529,7 @@ func (d *Dashboard) handleSpreadHistory(w http.ResponseWriter, r *http.Request) }) } -// handleConnStatus returns connection health for all exchanges (P3-5). +// handleConnStatus returns connection health for all exchanges. func (d *Dashboard) handleConnStatus(w http.ResponseWriter, r *http.Request) { d.connMu.RLock() conns := make(map[string]string) @@ -757,7 +557,6 @@ func (d *Dashboard) handleTrendHistory(w http.ResponseWriter, r *http.Request) { } } if events == nil { - // Fallback to in-memory ring buffer if d.trendDetector != nil { events = d.trendDetector.GetEvents(200) } else { @@ -796,51 +595,25 @@ func (d *Dashboard) handleTrendSignals(w http.ResponseWriter, r *http.Request) { writeJSON(w, map[string]interface{}{"signals": signals}) } -func (d *Dashboard) handleTrades(w http.ResponseWriter, r *http.Request) { - if d.db == nil { - writeJSON(w, map[string]interface{}{"trades": []interface{}{}, "total": 0}) - return - } - page := 1 - limit := 20 - coin := r.URL.Query().Get("coin") - if l := r.URL.Query().Get("limit"); l != "" { - if n, err := fmt.Sscanf(l, "%d", &limit); err != nil || n != 1 { - limit = 20 +func (d *Dashboard) handleSurgeEvents(w http.ResponseWriter, r *http.Request) { + limit := 100 + + // Try DB first + if d.db != nil { + events, err := d.db.GetSurgeEvents(limit) + if err == nil { + writeJSON(w, map[string]interface{}{"events": events, "total": len(events)}) + return } } - trades, total, err := d.db.GetTrades(page, limit, coin) - if err != nil { - http.Error(w, err.Error(), 500) - return - } - writeJSON(w, map[string]interface{}{ - "trades": trades, - "total": total, - "page": page, - "limit": limit, - }) -} -func (d *Dashboard) handleTradeDetail(w http.ResponseWriter, r *http.Request) { - if d.db == nil { - http.Error(w, "DB not available", 503) - return + // Fallback to in-memory + if d.surgeDetector != nil { + events := d.surgeDetector.GetRecentEvents(limit) + writeJSON(w, map[string]interface{}{"events": events, "total": len(events)}) + } else { + writeJSON(w, map[string]interface{}{"events": []interface{}{}, "total": 0}) } - var id int64 - if _, err := fmt.Sscanf(r.URL.Path, "/api/trade/%d", &id); err != nil { - http.Error(w, "Invalid trade ID", 400) - return - } - trade, orders, err := d.db.GetTradeByID(id) - if err != nil { - http.Error(w, err.Error(), 404) - return - } - writeJSON(w, map[string]interface{}{ - "trade": trade, - "orders": orders, - }) } func (d *Dashboard) handleSSE(w http.ResponseWriter, r *http.Request) { @@ -875,16 +648,6 @@ func (d *Dashboard) handleSSE(w http.ResponseWriter, r *http.Request) { } } -func (d *Dashboard) handleStop(w http.ResponseWriter, r *http.Request) { - d.trader.Stop() - writeJSON(w, map[string]string{"status": "stopped", "message": "Trading stopped, positions closing"}) -} - -func (d *Dashboard) handleStart(w http.ResponseWriter, r *http.Request) { - d.trader.Start() - writeJSON(w, map[string]string{"status": "started", "message": "Trading resumed"}) -} - func writeJSON(w http.ResponseWriter, v interface{}) { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(v) diff --git a/db/db.go b/db/db.go index 16a37ed..658a349 100644 --- a/db/db.go +++ b/db/db.go @@ -168,6 +168,25 @@ func (d *DB) migrate() error { ); CREATE INDEX IF NOT EXISTS idx_trend_signals_coin ON trend_signals(coin); CREATE INDEX IF NOT EXISTS idx_trend_signals_created ON trend_signals(created_at); + + CREATE TABLE IF NOT EXISTS surge_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + coin TEXT NOT NULL, + timestamp DATETIME NOT NULL, + bn_price REAL, + okx_price REAL, + bg_price REAL, + spread_pct REAL NOT NULL, + baseline_pct REAL, + threshold_pct REAL, + ratio REAL, + direction TEXT NOT NULL, + leading_exchange TEXT NOT NULL, + mid_price REAL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + CREATE INDEX IF NOT EXISTS idx_surge_events_coin ON surge_events(coin); + CREATE INDEX IF NOT EXISTS idx_surge_events_created ON surge_events(created_at); ` _, err := d.Exec(schema) if err != nil { diff --git a/db/surge_event_repo.go b/db/surge_event_repo.go new file mode 100644 index 0000000..81c702e --- /dev/null +++ b/db/surge_event_repo.go @@ -0,0 +1,58 @@ +package db + +import "time" + +// SurgeEventRecord represents a persisted surge detection event. +type SurgeEventRecord struct { + ID int64 `json:"id"` + Coin string `json:"coin"` + Timestamp string `json:"timestamp"` + BnPrice float64 `json:"bn_price"` + OkxPrice float64 `json:"okx_price"` + BgPrice float64 `json:"bg_price"` + SpreadPct float64 `json:"spread_pct"` + BaselinePct float64 `json:"baseline_pct"` + ThresholdPct float64 `json:"threshold_pct"` + Ratio float64 `json:"ratio"` + Direction string `json:"direction"` + LeadingExchange string `json:"leading_exchange"` + MidPrice float64 `json:"mid_price"` + CreatedAt string `json:"created_at"` +} + +// InsertSurgeEvent saves a surge event to the database. +func (d *DB) InsertSurgeEvent(coin string, ts time.Time, bnPrice, okxPrice, bgPrice, spreadPct, baselinePct, thresholdPct, ratio float64, direction, leadingExchange string, midPrice float64) error { + _, err := d.Exec(` + INSERT INTO surge_events (coin, timestamp, bn_price, okx_price, bg_price, spread_pct, baseline_pct, threshold_pct, ratio, direction, leading_exchange, mid_price, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + coin, ts.Format(time.RFC3339), bnPrice, okxPrice, bgPrice, spreadPct, baselinePct, thresholdPct, ratio, direction, leadingExchange, midPrice, Now().Format(time.RFC3339)) + return err +} + +// GetSurgeEvents returns surge events ordered by creation time descending. +func (d *DB) GetSurgeEvents(limit int) ([]SurgeEventRecord, error) { + if limit <= 0 { + limit = 100 + } + rows, err := d.Query(` + SELECT id, coin, timestamp, bn_price, okx_price, bg_price, spread_pct, baseline_pct, threshold_pct, ratio, direction, leading_exchange, mid_price, created_at + FROM surge_events + ORDER BY created_at DESC + LIMIT ?`, limit) + if err != nil { + return nil, err + } + defer rows.Close() + + var result []SurgeEventRecord + for rows.Next() { + var r SurgeEventRecord + if err := rows.Scan(&r.ID, &r.Coin, &r.Timestamp, &r.BnPrice, &r.OkxPrice, &r.BgPrice, + &r.SpreadPct, &r.BaselinePct, &r.ThresholdPct, &r.Ratio, &r.Direction, + &r.LeadingExchange, &r.MidPrice, &r.CreatedAt); err != nil { + return nil, err + } + result = append(result, r) + } + return result, rows.Err() +} diff --git a/db/trade_repo.go b/db/trade_repo.go deleted file mode 100644 index 6b809ec..0000000 --- a/db/trade_repo.go +++ /dev/null @@ -1,325 +0,0 @@ -package db - -import ( - "database/sql" - "time" -) - -// TradeRecord mirrors the database row for trades table. -type TradeRecord struct { - ID int64 - Coin string - Direction string - Status string // open / closed - EntrySpread *float64 - ExitSpread *float64 - LongExchange string - ShortExchange string - LongEntry *float64 - LongExit *float64 - ShortEntry *float64 - ShortExit *float64 - LongPnl *float64 - ShortPnl *float64 - FeeEntry *float64 - FeeExit *float64 - NetPnl *float64 - AmountUSD float64 - ScaleCount int - ExitReason *string - Convergence *string - OpenedAt time.Time - ClosedAt *time.Time - PnlLongUSD *float64 // per-exchange PnL in USD - PnlShortUSD *float64 - FeeLongUSD *float64 // per-exchange fee in USD - FeeShortUSD *float64 -} - -// OrderRecord mirrors the database row for orders table. -type OrderRecord struct { - ID int64 - TradeID int64 - Leg string // long / short - Type string // entry / exit / scale - Exchange string - Side string // buy / sell - Price *float64 - Size *float64 - Fee *float64 - OrderID *string - Status *string - CreatedAt time.Time -} - -// SystemOrderRecord represents one system-level arbitrage action (entry/scale/exit). -type SystemOrderRecord struct { - ID int64 - TradeID int64 - Type string // entry / scale / exit - Status string // filled / failed - Spread *float64 - LongPrice *float64 - ShortPrice *float64 - LongOrderID *int64 - ShortOrderID *int64 - CreatedAt time.Time -} - -// SaveTrade inserts a new trade and returns its ID. -func (d *DB) SaveTrade(t *TradeRecord) (int64, error) { - res, err := d.Exec(`INSERT INTO trades ( - coin, direction, status, entry_spread, exit_spread, - long_exchange, short_exchange, long_entry, long_exit, short_entry, short_exit, - long_pnl, short_pnl, fee_entry, fee_exit, net_pnl, - amount_usd, scale_count, exit_reason, convergence, opened_at, closed_at, - pnl_long_usd, pnl_short_usd, fee_long_usd, fee_short_usd - ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, - t.Coin, t.Direction, t.Status, t.EntrySpread, t.ExitSpread, - t.LongExchange, t.ShortExchange, t.LongEntry, t.LongExit, t.ShortEntry, t.ShortExit, - t.LongPnl, t.ShortPnl, t.FeeEntry, t.FeeExit, t.NetPnl, - t.AmountUSD, t.ScaleCount, t.ExitReason, t.Convergence, t.OpenedAt, t.ClosedAt, - t.PnlLongUSD, t.PnlShortUSD, t.FeeLongUSD, t.FeeShortUSD, - ) - if err != nil { - return 0, err - } - return res.LastInsertId() -} - -// UpdateTradeStatus updates an existing trade's close data. -func (d *DB) UpdateTradeStatus(id int64, t *TradeRecord) error { - _, err := d.Exec(`UPDATE trades SET - status=?, exit_spread=?, long_exit=?, short_exit=?, - long_pnl=?, short_pnl=?, fee_entry=?, fee_exit=?, net_pnl=?, - amount_usd=?, scale_count=?, exit_reason=?, convergence=?, closed_at=?, - pnl_long_usd=?, pnl_short_usd=?, fee_long_usd=?, fee_short_usd=? - WHERE id=?`, - t.Status, t.ExitSpread, - t.LongExit, t.ShortExit, - t.LongPnl, t.ShortPnl, t.FeeEntry, t.FeeExit, t.NetPnl, - t.AmountUSD, t.ScaleCount, t.ExitReason, t.Convergence, t.ClosedAt, - t.PnlLongUSD, t.PnlShortUSD, t.FeeLongUSD, t.FeeShortUSD, - id, - ) - return err -} - -// SetTradeStatus updates only the status field of a trade. -func (d *DB) SetTradeStatus(id int64, status string) error { - _, err := d.Exec("UPDATE trades SET status=? WHERE id=?", status, id) - return err -} - -// UpdateTradeEntry updates entry-related fields on an existing trade (prices, exchanges, spread). -func (d *DB) UpdateTradeEntry(id int64, t *TradeRecord) error { - _, err := d.Exec(`UPDATE trades SET long_entry=?, short_entry=?, long_exchange=?, short_exchange=?, entry_spread=? WHERE id=?`, - t.LongEntry, t.ShortEntry, t.LongExchange, t.ShortExchange, t.EntrySpread, id) - return err -} - -// UpdateTradeScale updates scale-in fields on an existing trade (amount_usd, scale_count). -func (d *DB) UpdateTradeScale(id int64, amountUSD float64, scaleCount int) error { - _, err := d.Exec("UPDATE trades SET amount_usd=?, scale_count=? WHERE id=?", amountUSD, scaleCount, id) - return err -} - -// GetOpenTrades returns all non-closed trades (status='open' or status='entering'). -func (d *DB) GetOpenTrades() ([]TradeRecord, error) { - rows, err := d.Query(`SELECT id, coin, direction, status, entry_spread, exit_spread, - long_exchange, short_exchange, long_entry, long_exit, short_entry, short_exit, - long_pnl, short_pnl, fee_entry, fee_exit, net_pnl, - amount_usd, scale_count, exit_reason, convergence, opened_at, closed_at, - pnl_long_usd, pnl_short_usd, fee_long_usd, fee_short_usd - FROM trades WHERE status IN ('open','entering')`) - if err != nil { - return nil, err - } - defer rows.Close() - return scanTrades(rows) -} - -// GetTrades returns paginated closed trades. -func (d *DB) GetTrades(page, limit int, coin string) ([]TradeRecord, int, error) { - // Count total - var total int - countSQL := "SELECT COUNT(*) FROM trades WHERE status='closed'" - args := []interface{}{} - if coin != "" { - countSQL += " AND coin=?" - args = append(args, coin) - } - if err := d.QueryRow(countSQL, args...).Scan(&total); err != nil { - return nil, 0, err - } - - // Fetch page - offset := (page - 1) * limit - query := `SELECT id, coin, direction, status, entry_spread, exit_spread, - long_exchange, short_exchange, long_entry, long_exit, short_entry, short_exit, - long_pnl, short_pnl, fee_entry, fee_exit, net_pnl, - amount_usd, scale_count, exit_reason, convergence, opened_at, closed_at, - pnl_long_usd, pnl_short_usd, fee_long_usd, fee_short_usd - FROM trades WHERE status='closed'` - if coin != "" { - query += " AND coin=?" - } - query += " ORDER BY closed_at DESC LIMIT ? OFFSET ?" - - allArgs := args - allArgs = append(allArgs, limit, offset) - - rows, err := d.Query(query, allArgs...) - if err != nil { - return nil, 0, err - } - defer rows.Close() - - trades, err := scanTrades(rows) - return trades, total, err -} - -// SaveOrder inserts an order record. -func (d *DB) SaveOrder(o *OrderRecord) (int64, error) { - res, err := d.Exec(`INSERT INTO orders - (trade_id, leg, type, exchange, side, price, size, fee, order_id, status, created_at) - VALUES (?,?,?,?,?, ?,?,?,?,?, ?)`, - o.TradeID, o.Leg, o.Type, o.Exchange, o.Side, - o.Price, o.Size, o.Fee, o.OrderID, o.Status, o.CreatedAt, - ) - if err != nil { - return 0, err - } - return res.LastInsertId() -} - -// SaveSystemOrder inserts a system-level order record. -func (d *DB) SaveSystemOrder(o *SystemOrderRecord) (int64, error) { - res, err := d.Exec(`INSERT INTO system_orders - (trade_id, type, status, spread, long_price, short_price, long_order_id, short_order_id, created_at) - VALUES (?,?,?,?,?, ?,?,?,?)`, - o.TradeID, o.Type, o.Status, o.Spread, - o.LongPrice, o.ShortPrice, o.LongOrderID, o.ShortOrderID, o.CreatedAt, - ) - if err != nil { - return 0, err - } - return res.LastInsertId() -} - -// GetTradeByID returns a single trade with its orders. -func (d *DB) GetTradeByID(id int64) (*TradeRecord, []OrderRecord, error) { - row := d.QueryRow(`SELECT id, coin, direction, status, entry_spread, exit_spread, - long_exchange, short_exchange, long_entry, long_exit, short_entry, short_exit, - long_pnl, short_pnl, fee_entry, fee_exit, net_pnl, - amount_usd, scale_count, exit_reason, convergence, opened_at, closed_at, - pnl_long_usd, pnl_short_usd, fee_long_usd, fee_short_usd - FROM trades WHERE id=?`, id) - - var t TradeRecord - err := row.Scan( - &t.ID, &t.Coin, &t.Direction, &t.Status, &t.EntrySpread, &t.ExitSpread, - &t.LongExchange, &t.ShortExchange, &t.LongEntry, &t.LongExit, &t.ShortEntry, &t.ShortExit, - &t.LongPnl, &t.ShortPnl, &t.FeeEntry, &t.FeeExit, &t.NetPnl, - &t.AmountUSD, &t.ScaleCount, &t.ExitReason, &t.Convergence, &t.OpenedAt, &t.ClosedAt, - &t.PnlLongUSD, &t.PnlShortUSD, &t.FeeLongUSD, &t.FeeShortUSD, - ) - if err != nil { - return nil, nil, err - } - - // Fetch orders - oRows, err := d.Query(`SELECT id, trade_id, leg, type, exchange, side, - price, size, fee, order_id, status, created_at - FROM orders WHERE trade_id=? ORDER BY id`, id) - if err != nil { - return nil, nil, err - } - defer oRows.Close() - - var orders []OrderRecord - for oRows.Next() { - var o OrderRecord - if err := oRows.Scan(&o.ID, &o.TradeID, &o.Leg, &o.Type, &o.Exchange, &o.Side, - &o.Price, &o.Size, &o.Fee, &o.OrderID, &o.Status, &o.CreatedAt); err != nil { - return nil, nil, err - } - orders = append(orders, o) - } - return &t, orders, nil -} - -func scanTrades(rows *sql.Rows) ([]TradeRecord, error) { - var trades []TradeRecord - for rows.Next() { - var t TradeRecord - if err := rows.Scan( - &t.ID, &t.Coin, &t.Direction, &t.Status, &t.EntrySpread, &t.ExitSpread, - &t.LongExchange, &t.ShortExchange, &t.LongEntry, &t.LongExit, &t.ShortEntry, &t.ShortExit, - &t.LongPnl, &t.ShortPnl, &t.FeeEntry, &t.FeeExit, &t.NetPnl, - &t.AmountUSD, &t.ScaleCount, &t.ExitReason, &t.Convergence, &t.OpenedAt, &t.ClosedAt, - &t.PnlLongUSD, &t.PnlShortUSD, &t.FeeLongUSD, &t.FeeShortUSD, - ); err != nil { - return nil, err - } - trades = append(trades, t) - } - return trades, rows.Err() -} - -// GetScalePrices returns scale-in order prices for a trade, grouped by leg. -func (d *DB) GetScalePrices(tradeID int64) (longPrices, shortPrices []float64, err error) { - rows, err := d.Query(`SELECT leg, price FROM orders - WHERE trade_id=? AND type='scale' AND price IS NOT NULL - ORDER BY id`, tradeID) - if err != nil { - return nil, nil, err - } - defer rows.Close() - for rows.Next() { - var leg string - var price float64 - if err := rows.Scan(&leg, &price); err != nil { - return nil, nil, err - } - switch leg { - case "long": - longPrices = append(longPrices, price) - case "short": - shortPrices = append(shortPrices, price) - } - } - return longPrices, shortPrices, rows.Err() -} - -// GetAllClosedTrades returns all closed trades for PnL history restoration. -func (d *DB) GetAllClosedTrades() ([]TradeRecord, error) { - rows, err := d.Query(`SELECT id, coin, direction, status, entry_spread, exit_spread, - long_exchange, short_exchange, long_entry, long_exit, short_entry, short_exit, - long_pnl, short_pnl, fee_entry, fee_exit, net_pnl, - amount_usd, scale_count, exit_reason, convergence, opened_at, closed_at, - pnl_long_usd, pnl_short_usd, fee_long_usd, fee_short_usd - FROM trades WHERE status='closed' ORDER BY id`) - if err != nil { - return nil, err - } - defer rows.Close() - return scanTrades(rows) -} - -// GetClosedStats returns convergence counts from the database. -func (d *DB) GetClosedStats() (converged, diverged, flat, total int, err error) { - if err = d.QueryRow("SELECT COUNT(*) FROM trades WHERE status='closed'").Scan(&total); err != nil { - return - } - if err = d.QueryRow("SELECT COUNT(*) FROM trades WHERE status='closed' AND convergence='价差收敛'").Scan(&converged); err != nil { - return - } - if err = d.QueryRow("SELECT COUNT(*) FROM trades WHERE status='closed' AND convergence='价差发散'").Scan(&diverged); err != nil { - return - } - if err = d.QueryRow("SELECT COUNT(*) FROM trades WHERE status='closed' AND (convergence IS NULL OR convergence NOT IN ('价差收敛','价差发散'))").Scan(&flat); err != nil { - return - } - return -} \ No newline at end of file diff --git a/exchange/bitget_trade.go b/exchange/bitget_trade.go deleted file mode 100644 index 70a40c2..0000000 --- a/exchange/bitget_trade.go +++ /dev/null @@ -1,279 +0,0 @@ -package exchange - -import ( - "crypto/hmac" - "crypto/sha256" - "encoding/base64" - "encoding/json" - "fmt" - "io" - "math" - "net/http" - "strconv" - "strings" - "time" -) - -type BitgetTrade struct { - APIKey string - APISecret string - Passphrase string - client *http.Client -} - -func NewBitgetTrade(apiKey, apiSecret, passphrase string) *BitgetTrade { - return &BitgetTrade{ - APIKey: apiKey, - APISecret: apiSecret, - Passphrase: passphrase, - client: &http.Client{Timeout: 10 * time.Second}, - } -} - -func (b *BitgetTrade) PlaceMarketOrder(side, symbol, size, tradeSide, holdSide string) (string, error) { - ts := fmt.Sprintf("%d", time.Now().UnixMilli()) - method := "POST" - - requestPath := "/api/v2/mix/order/place-order" - host := "https://api.bitget.com" - - body := map[string]interface{}{ - "marginCoin": "USDT", - "symbol": symbol, - "productType": "USDT-FUTURES", - "side": side, - "orderType": "market", - "timeInForce": "IOC", - "marginMode": "crossed", - "tradeSide": tradeSide, - "size": size, - } - // Close orders require holdSide to identify which position to close - if tradeSide == "close" && holdSide != "" { - body["holdSide"] = holdSide - } - bodyJSON, _ := json.Marshal(body) - - sign := b.sign(method, requestPath, ts, string(bodyJSON)) - url := host + requestPath - req, _ := http.NewRequest(method, url, strings.NewReader(string(bodyJSON))) - req.Header.Set("Content-Type", "application/json") - req.Header.Set("ACCESS-KEY", b.APIKey) - req.Header.Set("ACCESS-SIGN", sign) - req.Header.Set("ACCESS-TIMESTAMP", ts) - req.Header.Set("ACCESS-PASSPHRASE", b.Passphrase) - - resp, err := b.client.Do(req) - if err != nil { - return "", fmt.Errorf("http request: %w", err) - } - defer resp.Body.Close() - respBody, _ := io.ReadAll(resp.Body) - - var result struct { - Code string `json:"code"` - Msg string `json:"msg"` - Data struct { - OrderID string `json:"orderId"` - } `json:"data"` - } - if err := json.Unmarshal(respBody, &result); err != nil { - return "", fmt.Errorf("parse: %s", string(respBody)) - } - if result.Code != "00000" { - return "", fmt.Errorf("bitget error: %s - %s", result.Code, result.Msg) - } - return result.Data.OrderID, nil -} -// GetTradeFee queries the fills endpoint for actual fee charged and average fill price. -// Retries up to 5 times with 500ms intervals because Bitget's fills API may lag. -// Returns (average fill price, fee in USD, error). avgPrice=0 on any fills issue. -func (b *BitgetTrade) GetTradeFee(symbol, orderID string) (avgPrice, feeUSD float64, err error) { - for i := 0; i < 5; i++ { - if i > 0 { - time.Sleep(500 * time.Millisecond) - } - ts := fmt.Sprintf("%d", time.Now().UnixMilli()) - method := "GET" - requestPath := "/api/v2/mix/order/fills?symbol=" + symbol + "&orderId=" + orderID + "&productType=USDT-FUTURES" - host := "https://api.bitget.com" - - sign := b.sign(method, requestPath, ts, "") - url := host + requestPath - req, _ := http.NewRequest(method, url, nil) - req.Header.Set("Content-Type", "application/json") - req.Header.Set("ACCESS-KEY", b.APIKey) - req.Header.Set("ACCESS-SIGN", sign) - req.Header.Set("ACCESS-TIMESTAMP", ts) - req.Header.Set("ACCESS-PASSPHRASE", b.Passphrase) - - resp, err := b.client.Do(req) - if err != nil { - return 0, 0, fmt.Errorf("http: %w", err) - } - respBody, _ := io.ReadAll(resp.Body) - resp.Body.Close() - - var raw struct { - Code string `json:"code"` - Msg string `json:"msg"` - Data struct { - FillList []json.RawMessage `json:"fillList"` - } `json:"data"` - } - if err := json.Unmarshal(respBody, &raw); err != nil { - return 0, 0, fmt.Errorf("parse: %s", string(respBody)) - } - if raw.Code != "00000" { - return 0, 0, fmt.Errorf("bitget error: %s - %s", raw.Code, raw.Msg) - } - - var totalFee, totalQty, totalCost float64 - for _, item := range raw.Data.FillList { - var fill struct { - FillPrice string `json:"fillPrice"` - FillSize string `json:"fillBaseSize"` - FillFee string `json:"fillFee"` - } - if err := json.Unmarshal(item, &fill); err != nil { - continue - } - f, _ := strconv.ParseFloat(fill.FillFee, 64) - p, _ := strconv.ParseFloat(fill.FillPrice, 64) - q, _ := strconv.ParseFloat(fill.FillSize, 64) - totalFee += math.Abs(f) - totalCost += p * q - totalQty += q - } - if totalQty > 0 { - return totalCost / totalQty, totalFee, nil - } - } - return 0, 0, fmt.Errorf("no fill data after 5 attempts") -} - -// CheckPosition returns the available position size for a coin, or 0 if no position. -// Returns (total as float64, raw total string from API) — the raw string can be used -// for close orders to ensure correct precision. -func (b *BitgetTrade) CheckPosition(symbol string) (float64, string) { - ts := fmt.Sprintf("%d", time.Now().UnixMilli()) - method := "GET" - requestPath := "/api/v2/mix/position/single-position?symbol=" + symbol + "&productType=USDT-FUTURES&marginCoin=USDT" - host := "https://api.bitget.com" - sign := b.sign(method, requestPath, ts, "") - url := host + requestPath - req, _ := http.NewRequest(method, url, nil) - req.Header.Set("ACCESS-KEY", b.APIKey) - req.Header.Set("ACCESS-SIGN", sign) - req.Header.Set("ACCESS-TIMESTAMP", ts) - req.Header.Set("ACCESS-PASSPHRASE", b.Passphrase) - - resp, err := b.client.Do(req) - if err != nil { - return 0, "" - } - defer resp.Body.Close() - respBody, _ := io.ReadAll(resp.Body) - - var raw struct { - Code string `json:"code"` - Data []struct { - Total string `json:"total"` - } `json:"data"` - } - json.Unmarshal(respBody, &raw) - if raw.Code != "00000" || len(raw.Data) == 0 { - return 0, "" - } - total, _ := strconv.ParseFloat(raw.Data[0].Total, 64) - return total, raw.Data[0].Total -} - -func (b *BitgetTrade) sign(method, requestPath, timestamp, body string) string { - raw := timestamp + method + requestPath + body - mac := hmac.New(sha256.New, []byte(b.APISecret)) - mac.Write([]byte(raw)) - return base64.StdEncoding.EncodeToString(mac.Sum(nil)) -} - -func (b *BitgetTrade) GetBalance() (float64, error) { - ts := fmt.Sprintf("%d", time.Now().UnixMilli()) - method := "GET" - host := "https://api.bitget.com" - requestPath := "/api/v2/mix/account/accounts?productType=USDT-FUTURES" - - sign := b.sign(method, requestPath, ts, "") - url := host + requestPath - req, _ := http.NewRequest(method, url, nil) - req.Header.Set("ACCESS-KEY", b.APIKey) - req.Header.Set("ACCESS-SIGN", sign) - req.Header.Set("ACCESS-TIMESTAMP", ts) - req.Header.Set("ACCESS-PASSPHRASE", b.Passphrase) - - resp, err := b.client.Do(req) - if err != nil { - return 0, fmt.Errorf("http: %w", err) - } - defer resp.Body.Close() - respBody, _ := io.ReadAll(resp.Body) - - var raw map[string]interface{} - if err := json.Unmarshal(respBody, &raw); err != nil { - return 0, fmt.Errorf("parse: %s", string(respBody)) - } - code, _ := raw["code"].(string) - if code != "00000" && code != "" { - msg, _ := raw["msg"].(string) - return 0, fmt.Errorf("bitget error: %s - %s", code, msg) - } - - dataRaw, ok := raw["data"] - if !ok || dataRaw == nil { - return 0, fmt.Errorf("no data in response") - } - dataArr, ok := dataRaw.([]interface{}) - if !ok { - return 0, fmt.Errorf("unexpected data format") - } - for _, item := range dataArr { - acct, ok := item.(map[string]interface{}) - if !ok { - continue - } - if acct["marginCoin"] == "USDT" { - bal, _ := strconv.ParseFloat(fmt.Sprint(acct["available"]), 64) - return bal, nil - } - } - return 0, fmt.Errorf("no USDT account found") -} - -func GetBitgetSize(symbol string, amountUSD, price float64) string { - if amountUSD < 5 { - amountUSD = 5 - } - sz := amountUSD / price - switch symbol { - case "DOGEUSDT": - if sz < 1 { sz = 1 } - return fmt.Sprintf("%.0f", math.Floor(sz)) - case "ONDOUSDT": - sz = math.Floor(sz*10)/10 - if sz < 0.1 { sz = 0.1 } - return fmt.Sprintf("%.1f", sz) - case "OPUSDT": - sz = math.Floor(sz*10)/10 - if sz < 0.1 { sz = 0.1 } - return fmt.Sprintf("%.1f", sz) - case "WIFUSDT": - sz = math.Floor(sz*10)/10 - if sz < 0.1 { sz = 0.1 } - return fmt.Sprintf("%.1f", sz) - case "ARBUSDT": - sz = math.Floor(sz*100)/100 - if sz < 0.01 { sz = 0.01 } - return fmt.Sprintf("%.2f", sz) - default: - return fmt.Sprintf("%.4f", sz) - } -} diff --git a/exchange/hyperliquid.go b/exchange/hyperliquid.go deleted file mode 100644 index e3b030a..0000000 --- a/exchange/hyperliquid.go +++ /dev/null @@ -1,66 +0,0 @@ -package exchange - -import ( - "encoding/json" - "log" - "strconv" - "time" -) - -type HyperLiquidWS struct { - Tracked []string -} - -type hlAllMidsMsg struct { - Channel string `json:"channel"` - Data json.RawMessage `json:"data"` -} - -type hlMidsData struct { - Mids map[string]string `json:"mids"` -} - -func NewHyperLiquidWS(tracked []string) *HyperLiquidWS { - return &HyperLiquidWS{Tracked: tracked} -} -// Run connects to HyperLiquid WS and streams mid prices. -func (h *HyperLiquidWS) Run(updateFn func(coin string, price, bid, ask float64)) error { - conn := NewPriceConnector("wss://api.hyperliquid.xyz/ws", "HyperLiquid", 120*time.Second, 30*time.Second) - conn.PingInterval = 45 * time.Second - - conn.OnConnect = func() { - log.Printf("[HL WS] Connected") - sub := map[string]interface{}{ - "method": "subscribe", - "subscription": map[string]string{ - "type": "allMids", - }, - } - if err := conn.SendJSON(sub); err != nil { - log.Printf("[HL WS] Subscribe error: %v", err) - } - } - - conn.OnMessage = func(msg []byte) { - var raw hlAllMidsMsg - if err := json.Unmarshal(msg, &raw); err != nil { - return - } - if raw.Channel != "allMids" { - return - } - var data hlMidsData - if err := json.Unmarshal(raw.Data, &data); err != nil { - return - } - for coin, priceStr := range data.Mids { - price, err := strconv.ParseFloat(priceStr, 64) - if err != nil || price <= 0 { - continue - } - updateFn(coin, price, 0, 0) - } - } - - return conn.Run() -} diff --git a/exchange/hyperliquid_trade.go b/exchange/hyperliquid_trade.go deleted file mode 100644 index 9475cf8..0000000 --- a/exchange/hyperliquid_trade.go +++ /dev/null @@ -1,266 +0,0 @@ -package exchange - -import ( - "context" - "crypto/ecdsa" - "encoding/hex" - "encoding/json" - "fmt" - "log" - "math" - "strconv" - "strings" - "sync" - "time" - - "github.com/ethereum/go-ethereum/crypto" - hl "github.com/sonirico/go-hyperliquid" -) - -type HyperLiquidTrade struct { - exchange *hl.Exchange - info *hl.Info - privateKey *ecdsa.PrivateKey - mainAddress string - nonceMu sync.Mutex - lastNonce int64 - configured bool - - // szDecimals maps coin name -> decimal places for size formatting - // Populated from HL Meta on initExchange() - szDecimals map[string]int -} - -func NewHyperLiquidTrade(privateKeyHex, mainAddress, apiAddress string) (*HyperLiquidTrade, error) { - if privateKeyHex == "" { - return &HyperLiquidTrade{}, nil - } - - keyHex := strings.TrimPrefix(privateKeyHex, "0x") - keyBytes, err := hex.DecodeString(keyHex) - if err != nil { - return nil, fmt.Errorf("decode private key: %w", err) - } - - privKey, err := crypto.ToECDSA(keyBytes) - if err != nil { - return nil, fmt.Errorf("to ECDSA: %w", err) - } - - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - - info := hl.NewInfo(ctx, hl.MainnetAPIURL, true, nil, nil, nil) - - return &HyperLiquidTrade{ - privateKey: privKey, - mainAddress: mainAddress, - info: info, - configured: true, - }, nil -} - -// InitExchange ensures the HL exchange is initialized (fetches metadata, szDecimals, etc.). -// Safe to call multiple times — no-op after first initialization. -// Must be called before GetSize or PlaceMarketOrder for accurate size formatting. -func (h *HyperLiquidTrade) InitExchange() error { - return h.initExchange() -} - -func (h *HyperLiquidTrade) initExchange() error { - if h.exchange != nil { - return nil - } - if !h.configured { - return fmt.Errorf("HL not configured") - } - - ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) - defer cancel() - - meta, err := h.info.Meta(ctx) - if err != nil { - return fmt.Errorf("meta: %w", err) - } - spotMeta, err := h.info.SpotMeta(ctx) - if err != nil { - return fmt.Errorf("spot meta: %w", err) - } - - h.exchange = hl.NewExchange(ctx, h.privateKey, hl.MainnetAPIURL, meta, "", h.mainAddress, spotMeta, nil) - - // Build szDecimals map from HL Meta for correct size formatting - h.szDecimals = make(map[string]int, len(meta.Universe)) - for _, asset := range meta.Universe { - h.szDecimals[asset.Name] = asset.SzDecimals - } - return nil -} - -// GetSize returns a formatted size string for HL orders using the correct szDecimals. -func (h *HyperLiquidTrade) GetSize(coin string, amountUSD, price float64) string { - sz := amountUSD / price - decimals, ok := h.szDecimals[coin] - if !ok { - // Fallback: 4 decimal places - return fmt.Sprintf("%.4f", math.Floor(sz*10000)/10000) - } - switch decimals { - case 0: - sz = math.Floor(sz) - if sz < 1 { - sz = 1 - } - return fmt.Sprintf("%.0f", sz) - case 1: - sz = math.Floor(sz*10) / 10 - if sz < 0.1 { - sz = 0.1 - } - return fmt.Sprintf("%.1f", sz) - case 2: - sz = math.Floor(sz*100) / 100 - if sz < 0.01 { - sz = 0.01 - } - return fmt.Sprintf("%.2f", sz) - default: - mult := math.Pow10(decimals) - sz = math.Floor(sz*mult) / mult - if sz < 1/mult { - sz = 1 / mult - } - return fmt.Sprintf("%."+strconv.Itoa(decimals)+"f", sz) - } -} - -func (h *HyperLiquidTrade) IsConfigured() bool { - return h.configured -} - -// PlaceMarketOrder places a market order and returns the raw JSON response. -func (h *HyperLiquidTrade) PlaceMarketOrder(coin, side, sz string) (string, error) { - if !h.configured { - return "", fmt.Errorf("HL not configured") - } - if err := h.initExchange(); err != nil { - return "", fmt.Errorf("init: %w", err) - } - - isBuy := side == "buy" - size, _ := strconv.ParseFloat(sz, 64) - - // Find szDecimals for this coin - decimals := 4 - if d, ok := h.szDecimals[coin]; ok { - decimals = d - } - - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - - log.Printf("[Order] HL MarketOpen | coin=%s isBuy=%v size=%.*f szDecimals=%d slippage=0.05 px=nil", coin, isBuy, decimals, size, decimals) - result, err := h.exchange.MarketOpen(ctx, coin, isBuy, size, nil, 0.05, nil, nil) - if err != nil { - return "", fmt.Errorf("market open: %w", err) - } - respJSON, _ := json.Marshal(result) - return string(respJSON), nil -} - -// PlaceMarketCloseOrder closes a position on HL with reduceOnly protection. -// Uses the SDK's MarketClose which sets ReduceOnly=true to prevent accidental reversals. -// sz is the size string (same format as PlaceMarketOrder). Pass "0" or "" to close full position. -func (h *HyperLiquidTrade) PlaceMarketCloseOrder(coin, sz string) (string, error) { - if !h.configured { - return "", fmt.Errorf("HL not configured") - } - if err := h.initExchange(); err != nil { - return "", fmt.Errorf("init: %w", err) - } - - var size *float64 - if f, err := strconv.ParseFloat(sz, 64); err == nil && f > 0 { - size = &f - } - - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - - log.Printf("[Order] HL MarketClose | coin=%s size=%v reduceOnly=true slippage=0.05", coin, size) - result, err := h.exchange.MarketClose(ctx, coin, size, nil, 0.05, nil, nil) - if err != nil { - return "", fmt.Errorf("market close: %w", err) - } - respJSON, _ := json.Marshal(result) - return string(respJSON), nil -} - -// EstimateFeeFromResponse calculates the fee using the response's filled size × price -// × configured taker rate. This is NOT an actual fee from HL — HL does not return -// fee amounts in the order response. The result is equivalent to estimating from -// TradeAmountUSD, but more accurate for partial fills since it uses actual filled sz/px. -func (h *HyperLiquidTrade) EstimateFeeFromResponse(orderResponseJSON string, takerFeePct float64) (feeUSD float64, err error) { - var resp struct { - Filled *struct { - TotalSz string `json:"totalSz"` - AvgPx string `json:"avgPx"` - } `json:"filled,omitempty"` - } - if err := json.Unmarshal([]byte(orderResponseJSON), &resp); err != nil || resp.Filled == nil { - return 0, fmt.Errorf("no filled data in response") - } - sz, _ := strconv.ParseFloat(resp.Filled.TotalSz, 64) - px, _ := strconv.ParseFloat(resp.Filled.AvgPx, 64) - if sz > 0 && px > 0 { - return sz * px * takerFeePct / 100, nil - } - return 0, fmt.Errorf("no filled status in response") -} - -// ParseFillFromResponse extracts the average fill price and total filled size -// from an HL MarketOpen/MarketClose response. Returns (avgFillPrice, filledSize, error). -func (h *HyperLiquidTrade) ParseFillFromResponse(orderResponseJSON string) (avgPrice, filledSize float64, err error) { - var resp struct { - Filled *struct { - TotalSz string `json:"totalSz"` - AvgPx string `json:"avgPx"` - } `json:"filled,omitempty"` - Error *string `json:"error,omitempty"` - } - if err := json.Unmarshal([]byte(orderResponseJSON), &resp); err != nil { - return 0, 0, fmt.Errorf("parse: %w", err) - } - if resp.Filled != nil { - sz, _ := strconv.ParseFloat(resp.Filled.TotalSz, 64) - px, _ := strconv.ParseFloat(resp.Filled.AvgPx, 64) - if sz > 0 && px > 0 { - return px, sz, nil - } - } - return 0, 0, fmt.Errorf("no filled data in response") -} - -func (h *HyperLiquidTrade) GetBalance() (float64, error) { - if !h.configured { - return 0, fmt.Errorf("HL not configured") - } - - ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) - defer cancel() - - // HL testnet USDC is on spot, not perp. Use SpotUserState. - state, err := h.info.SpotUserState(ctx, h.mainAddress) - if err != nil { - return 0, fmt.Errorf("spot user state: %w", err) - } - - for _, b := range state.Balances { - if b.Coin == "USDC" { - total, _ := strconv.ParseFloat(b.Total, 64) - hold, _ := strconv.ParseFloat(b.Hold, 64) - return total - hold, nil - } - } - return 0, fmt.Errorf("USDC balance not found in spot state") -} diff --git a/frontend/dist/assets/index-B9OruCsy.js b/frontend/dist/assets/index-B9OruCsy.js deleted file mode 100644 index 7135033..0000000 --- a/frontend/dist/assets/index-B9OruCsy.js +++ /dev/null @@ -1,40 +0,0 @@ -(function(){const t=document.createElement("link").relList;if(t&&t.supports&&t.supports("modulepreload"))return;for(const l of document.querySelectorAll('link[rel="modulepreload"]'))r(l);new MutationObserver(l=>{for(const i of l)if(i.type==="childList")for(const o of i.addedNodes)o.tagName==="LINK"&&o.rel==="modulepreload"&&r(o)}).observe(document,{childList:!0,subtree:!0});function n(l){const i={};return l.integrity&&(i.integrity=l.integrity),l.referrerPolicy&&(i.referrerPolicy=l.referrerPolicy),l.crossOrigin==="use-credentials"?i.credentials="include":l.crossOrigin==="anonymous"?i.credentials="omit":i.credentials="same-origin",i}function r(l){if(l.ep)return;l.ep=!0;const i=n(l);fetch(l.href,i)}})();function dc(e){return e&&e.__esModule&&Object.prototype.hasOwnProperty.call(e,"default")?e.default:e}var Jo={exports:{}},ul={},qo={exports:{}},D={};/** - * @license React - * react.production.min.js - * - * Copyright (c) Facebook, Inc. and its affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */var rr=Symbol.for("react.element"),fc=Symbol.for("react.portal"),hc=Symbol.for("react.fragment"),pc=Symbol.for("react.strict_mode"),mc=Symbol.for("react.profiler"),gc=Symbol.for("react.provider"),xc=Symbol.for("react.context"),vc=Symbol.for("react.forward_ref"),yc=Symbol.for("react.suspense"),jc=Symbol.for("react.memo"),wc=Symbol.for("react.lazy"),Bs=Symbol.iterator;function Sc(e){return e===null||typeof e!="object"?null:(e=Bs&&e[Bs]||e["@@iterator"],typeof e=="function"?e:null)}var bo={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},eu=Object.assign,tu={};function mn(e,t,n){this.props=e,this.context=t,this.refs=tu,this.updater=n||bo}mn.prototype.isReactComponent={};mn.prototype.setState=function(e,t){if(typeof e!="object"&&typeof e!="function"&&e!=null)throw Error("setState(...): takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,e,t,"setState")};mn.prototype.forceUpdate=function(e){this.updater.enqueueForceUpdate(this,e,"forceUpdate")};function nu(){}nu.prototype=mn.prototype;function Ki(e,t,n){this.props=e,this.context=t,this.refs=tu,this.updater=n||bo}var Xi=Ki.prototype=new nu;Xi.constructor=Ki;eu(Xi,mn.prototype);Xi.isPureReactComponent=!0;var Hs=Array.isArray,ru=Object.prototype.hasOwnProperty,Gi={current:null},lu={key:!0,ref:!0,__self:!0,__source:!0};function iu(e,t,n){var r,l={},i=null,o=null;if(t!=null)for(r in t.ref!==void 0&&(o=t.ref),t.key!==void 0&&(i=""+t.key),t)ru.call(t,r)&&!lu.hasOwnProperty(r)&&(l[r]=t[r]);var u=arguments.length-2;if(u===1)l.children=n;else if(1>>1,J=k[A];if(0>>1;Al(vn,T))Yel(L,vn)?(k[A]=L,k[Ye]=T,A=Ye):(k[A]=vn,k[Ge]=T,A=Ge);else if(Yel(L,T))k[A]=L,k[Ye]=T,A=Ye;else break e}}return P}function l(k,P){var T=k.sortIndex-P.sortIndex;return T!==0?T:k.id-P.id}if(typeof performance=="object"&&typeof performance.now=="function"){var i=performance;e.unstable_now=function(){return i.now()}}else{var o=Date,u=o.now();e.unstable_now=function(){return o.now()-u}}var c=[],a=[],f=1,d=null,g=3,v=!1,x=!1,w=!1,R=typeof setTimeout=="function"?setTimeout:null,p=typeof clearTimeout=="function"?clearTimeout:null,h=typeof setImmediate<"u"?setImmediate:null;typeof navigator<"u"&&navigator.scheduling!==void 0&&navigator.scheduling.isInputPending!==void 0&&navigator.scheduling.isInputPending.bind(navigator.scheduling);function m(k){for(var P=n(a);P!==null;){if(P.callback===null)r(a);else if(P.startTime<=k)r(a),P.sortIndex=P.expirationTime,t(c,P);else break;P=n(a)}}function y(k){if(w=!1,m(k),!x)if(n(c)!==null)x=!0,de(S);else{var P=n(a);P!==null&&fe(y,P.startTime-k)}}function S(k,P){x=!1,w&&(w=!1,p(E),E=-1),v=!0;var T=g;try{for(m(P),d=n(c);d!==null&&(!(d.expirationTime>P)||k&&!re());){var A=d.callback;if(typeof A=="function"){d.callback=null,g=d.priorityLevel;var J=A(d.expirationTime<=P);P=e.unstable_now(),typeof J=="function"?d.callback=J:d===n(c)&&r(c),m(P)}else r(c);d=n(c)}if(d!==null)var Bt=!0;else{var Ge=n(a);Ge!==null&&fe(y,Ge.startTime-P),Bt=!1}return Bt}finally{d=null,g=T,v=!1}}var _=!1,C=null,E=-1,$=5,F=-1;function re(){return!(e.unstable_now()-F<$)}function Pe(){if(C!==null){var k=e.unstable_now();F=k;var P=!0;try{P=C(!0,k)}finally{P?He():(_=!1,C=null)}}else _=!1}var He;if(typeof h=="function")He=function(){h(Pe)};else if(typeof MessageChannel<"u"){var Ct=new MessageChannel,M=Ct.port2;Ct.port1.onmessage=Pe,He=function(){M.postMessage(null)}}else He=function(){R(Pe,0)};function de(k){C=k,_||(_=!0,He())}function fe(k,P){E=R(function(){k(e.unstable_now())},P)}e.unstable_IdlePriority=5,e.unstable_ImmediatePriority=1,e.unstable_LowPriority=4,e.unstable_NormalPriority=3,e.unstable_Profiling=null,e.unstable_UserBlockingPriority=2,e.unstable_cancelCallback=function(k){k.callback=null},e.unstable_continueExecution=function(){x||v||(x=!0,de(S))},e.unstable_forceFrameRate=function(k){0>k||125A?(k.sortIndex=T,t(a,k),n(c)===null&&k===n(a)&&(w?(p(E),E=-1):w=!0,fe(y,T-A))):(k.sortIndex=J,t(c,k),x||v||(x=!0,de(S))),k},e.unstable_shouldYield=re,e.unstable_wrapCallback=function(k){var P=g;return function(){var T=g;g=P;try{return k.apply(this,arguments)}finally{g=T}}}})(cu);au.exports=cu;var Oc=au.exports;/** - * @license React - * react-dom.production.min.js - * - * Copyright (c) Facebook, Inc. and its affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */var Rc=z,Ne=Oc;function j(e){for(var t="https://reactjs.org/docs/error-decoder.html?invariant="+e,n=1;n"u"||typeof window.document>"u"||typeof window.document.createElement>"u"),ql=Object.prototype.hasOwnProperty,Mc=/^[:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD][:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD\-.0-9\u00B7\u0300-\u036F\u203F-\u2040]*$/,Vs={},Qs={};function Ic(e){return ql.call(Qs,e)?!0:ql.call(Vs,e)?!1:Mc.test(e)?Qs[e]=!0:(Vs[e]=!0,!1)}function $c(e,t,n,r){if(n!==null&&n.type===0)return!1;switch(typeof t){case"function":case"symbol":return!0;case"boolean":return r?!1:n!==null?!n.acceptsBooleans:(e=e.toLowerCase().slice(0,5),e!=="data-"&&e!=="aria-");default:return!1}}function Uc(e,t,n,r){if(t===null||typeof t>"u"||$c(e,t,n,r))return!0;if(r)return!1;if(n!==null)switch(n.type){case 3:return!t;case 4:return t===!1;case 5:return isNaN(t);case 6:return isNaN(t)||1>t}return!1}function ge(e,t,n,r,l,i,o){this.acceptsBooleans=t===2||t===3||t===4,this.attributeName=r,this.attributeNamespace=l,this.mustUseProperty=n,this.propertyName=e,this.type=t,this.sanitizeURL=i,this.removeEmptyString=o}var se={};"children dangerouslySetInnerHTML defaultValue defaultChecked innerHTML suppressContentEditableWarning suppressHydrationWarning style".split(" ").forEach(function(e){se[e]=new ge(e,0,!1,e,null,!1,!1)});[["acceptCharset","accept-charset"],["className","class"],["htmlFor","for"],["httpEquiv","http-equiv"]].forEach(function(e){var t=e[0];se[t]=new ge(t,1,!1,e[1],null,!1,!1)});["contentEditable","draggable","spellCheck","value"].forEach(function(e){se[e]=new ge(e,2,!1,e.toLowerCase(),null,!1,!1)});["autoReverse","externalResourcesRequired","focusable","preserveAlpha"].forEach(function(e){se[e]=new ge(e,2,!1,e,null,!1,!1)});"allowFullScreen async autoFocus autoPlay controls default defer disabled disablePictureInPicture disableRemotePlayback formNoValidate hidden loop noModule noValidate open playsInline readOnly required reversed scoped seamless itemScope".split(" ").forEach(function(e){se[e]=new ge(e,3,!1,e.toLowerCase(),null,!1,!1)});["checked","multiple","muted","selected"].forEach(function(e){se[e]=new ge(e,3,!0,e,null,!1,!1)});["capture","download"].forEach(function(e){se[e]=new ge(e,4,!1,e,null,!1,!1)});["cols","rows","size","span"].forEach(function(e){se[e]=new ge(e,6,!1,e,null,!1,!1)});["rowSpan","start"].forEach(function(e){se[e]=new ge(e,5,!1,e.toLowerCase(),null,!1,!1)});var Zi=/[\-:]([a-z])/g;function Ji(e){return e[1].toUpperCase()}"accent-height alignment-baseline arabic-form baseline-shift cap-height clip-path clip-rule color-interpolation color-interpolation-filters color-profile color-rendering dominant-baseline enable-background fill-opacity fill-rule flood-color flood-opacity font-family font-size font-size-adjust font-stretch font-style font-variant font-weight glyph-name glyph-orientation-horizontal glyph-orientation-vertical horiz-adv-x horiz-origin-x image-rendering letter-spacing lighting-color marker-end marker-mid marker-start overline-position overline-thickness paint-order panose-1 pointer-events rendering-intent shape-rendering stop-color stop-opacity strikethrough-position strikethrough-thickness stroke-dasharray stroke-dashoffset stroke-linecap stroke-linejoin stroke-miterlimit stroke-opacity stroke-width text-anchor text-decoration text-rendering underline-position underline-thickness unicode-bidi unicode-range units-per-em v-alphabetic v-hanging v-ideographic v-mathematical vector-effect vert-adv-y vert-origin-x vert-origin-y word-spacing writing-mode xmlns:xlink x-height".split(" ").forEach(function(e){var t=e.replace(Zi,Ji);se[t]=new ge(t,1,!1,e,null,!1,!1)});"xlink:actuate xlink:arcrole xlink:role xlink:show xlink:title xlink:type".split(" ").forEach(function(e){var t=e.replace(Zi,Ji);se[t]=new ge(t,1,!1,e,"http://www.w3.org/1999/xlink",!1,!1)});["xml:base","xml:lang","xml:space"].forEach(function(e){var t=e.replace(Zi,Ji);se[t]=new ge(t,1,!1,e,"http://www.w3.org/XML/1998/namespace",!1,!1)});["tabIndex","crossOrigin"].forEach(function(e){se[e]=new ge(e,1,!1,e.toLowerCase(),null,!1,!1)});se.xlinkHref=new ge("xlinkHref",1,!1,"xlink:href","http://www.w3.org/1999/xlink",!0,!1);["src","href","action","formAction"].forEach(function(e){se[e]=new ge(e,1,!1,e.toLowerCase(),null,!0,!0)});function qi(e,t,n,r){var l=se.hasOwnProperty(t)?se[t]:null;(l!==null?l.type!==0:r||!(2u||l[o]!==i[u]){var c=` -`+l[o].replace(" at new "," at ");return e.displayName&&c.includes("")&&(c=c.replace("",e.displayName)),c}while(1<=o&&0<=u);break}}}finally{El=!1,Error.prepareStackTrace=n}return(e=e?e.displayName||e.name:"")?En(e):""}function Ac(e){switch(e.tag){case 5:return En(e.type);case 16:return En("Lazy");case 13:return En("Suspense");case 19:return En("SuspenseList");case 0:case 2:case 15:return e=Pl(e.type,!1),e;case 11:return e=Pl(e.type.render,!1),e;case 1:return e=Pl(e.type,!0),e;default:return""}}function ni(e){if(e==null)return null;if(typeof e=="function")return e.displayName||e.name||null;if(typeof e=="string")return e;switch(e){case Vt:return"Fragment";case Wt:return"Portal";case bl:return"Profiler";case bi:return"StrictMode";case ei:return"Suspense";case ti:return"SuspenseList"}if(typeof e=="object")switch(e.$$typeof){case hu:return(e.displayName||"Context")+".Consumer";case fu:return(e._context.displayName||"Context")+".Provider";case es:var t=e.render;return e=e.displayName,e||(e=t.displayName||t.name||"",e=e!==""?"ForwardRef("+e+")":"ForwardRef"),e;case ts:return t=e.displayName||null,t!==null?t:ni(e.type)||"Memo";case ot:t=e._payload,e=e._init;try{return ni(e(t))}catch{}}return null}function Bc(e){var t=e.type;switch(e.tag){case 24:return"Cache";case 9:return(t.displayName||"Context")+".Consumer";case 10:return(t._context.displayName||"Context")+".Provider";case 18:return"DehydratedFragment";case 11:return e=t.render,e=e.displayName||e.name||"",t.displayName||(e!==""?"ForwardRef("+e+")":"ForwardRef");case 7:return"Fragment";case 5:return t;case 4:return"Portal";case 3:return"Root";case 6:return"Text";case 16:return ni(t);case 8:return t===bi?"StrictMode":"Mode";case 22:return"Offscreen";case 12:return"Profiler";case 21:return"Scope";case 13:return"Suspense";case 19:return"SuspenseList";case 25:return"TracingMarker";case 1:case 0:case 17:case 2:case 14:case 15:if(typeof t=="function")return t.displayName||t.name||null;if(typeof t=="string")return t}return null}function wt(e){switch(typeof e){case"boolean":case"number":case"string":case"undefined":return e;case"object":return e;default:return""}}function mu(e){var t=e.type;return(e=e.nodeName)&&e.toLowerCase()==="input"&&(t==="checkbox"||t==="radio")}function Hc(e){var t=mu(e)?"checked":"value",n=Object.getOwnPropertyDescriptor(e.constructor.prototype,t),r=""+e[t];if(!e.hasOwnProperty(t)&&typeof n<"u"&&typeof n.get=="function"&&typeof n.set=="function"){var l=n.get,i=n.set;return Object.defineProperty(e,t,{configurable:!0,get:function(){return l.call(this)},set:function(o){r=""+o,i.call(this,o)}}),Object.defineProperty(e,t,{enumerable:n.enumerable}),{getValue:function(){return r},setValue:function(o){r=""+o},stopTracking:function(){e._valueTracker=null,delete e[t]}}}}function dr(e){e._valueTracker||(e._valueTracker=Hc(e))}function gu(e){if(!e)return!1;var t=e._valueTracker;if(!t)return!0;var n=t.getValue(),r="";return e&&(r=mu(e)?e.checked?"true":"false":e.value),e=r,e!==n?(t.setValue(e),!0):!1}function $r(e){if(e=e||(typeof document<"u"?document:void 0),typeof e>"u")return null;try{return e.activeElement||e.body}catch{return e.body}}function ri(e,t){var n=t.checked;return G({},t,{defaultChecked:void 0,defaultValue:void 0,value:void 0,checked:n??e._wrapperState.initialChecked})}function Xs(e,t){var n=t.defaultValue==null?"":t.defaultValue,r=t.checked!=null?t.checked:t.defaultChecked;n=wt(t.value!=null?t.value:n),e._wrapperState={initialChecked:r,initialValue:n,controlled:t.type==="checkbox"||t.type==="radio"?t.checked!=null:t.value!=null}}function xu(e,t){t=t.checked,t!=null&&qi(e,"checked",t,!1)}function li(e,t){xu(e,t);var n=wt(t.value),r=t.type;if(n!=null)r==="number"?(n===0&&e.value===""||e.value!=n)&&(e.value=""+n):e.value!==""+n&&(e.value=""+n);else if(r==="submit"||r==="reset"){e.removeAttribute("value");return}t.hasOwnProperty("value")?ii(e,t.type,n):t.hasOwnProperty("defaultValue")&&ii(e,t.type,wt(t.defaultValue)),t.checked==null&&t.defaultChecked!=null&&(e.defaultChecked=!!t.defaultChecked)}function Gs(e,t,n){if(t.hasOwnProperty("value")||t.hasOwnProperty("defaultValue")){var r=t.type;if(!(r!=="submit"&&r!=="reset"||t.value!==void 0&&t.value!==null))return;t=""+e._wrapperState.initialValue,n||t===e.value||(e.value=t),e.defaultValue=t}n=e.name,n!==""&&(e.name=""),e.defaultChecked=!!e._wrapperState.initialChecked,n!==""&&(e.name=n)}function ii(e,t,n){(t!=="number"||$r(e.ownerDocument)!==e)&&(n==null?e.defaultValue=""+e._wrapperState.initialValue:e.defaultValue!==""+n&&(e.defaultValue=""+n))}var Pn=Array.isArray;function tn(e,t,n,r){if(e=e.options,t){t={};for(var l=0;l"+t.valueOf().toString()+"",t=fr.firstChild;e.firstChild;)e.removeChild(e.firstChild);for(;t.firstChild;)e.appendChild(t.firstChild)}});function Bn(e,t){if(t){var n=e.firstChild;if(n&&n===e.lastChild&&n.nodeType===3){n.nodeValue=t;return}}e.textContent=t}var Fn={animationIterationCount:!0,aspectRatio:!0,borderImageOutset:!0,borderImageSlice:!0,borderImageWidth:!0,boxFlex:!0,boxFlexGroup:!0,boxOrdinalGroup:!0,columnCount:!0,columns:!0,flex:!0,flexGrow:!0,flexPositive:!0,flexShrink:!0,flexNegative:!0,flexOrder:!0,gridArea:!0,gridRow:!0,gridRowEnd:!0,gridRowSpan:!0,gridRowStart:!0,gridColumn:!0,gridColumnEnd:!0,gridColumnSpan:!0,gridColumnStart:!0,fontWeight:!0,lineClamp:!0,lineHeight:!0,opacity:!0,order:!0,orphans:!0,tabSize:!0,widows:!0,zIndex:!0,zoom:!0,fillOpacity:!0,floodOpacity:!0,stopOpacity:!0,strokeDasharray:!0,strokeDashoffset:!0,strokeMiterlimit:!0,strokeOpacity:!0,strokeWidth:!0},Wc=["Webkit","ms","Moz","O"];Object.keys(Fn).forEach(function(e){Wc.forEach(function(t){t=t+e.charAt(0).toUpperCase()+e.substring(1),Fn[t]=Fn[e]})});function wu(e,t,n){return t==null||typeof t=="boolean"||t===""?"":n||typeof t!="number"||t===0||Fn.hasOwnProperty(e)&&Fn[e]?(""+t).trim():t+"px"}function Su(e,t){e=e.style;for(var n in t)if(t.hasOwnProperty(n)){var r=n.indexOf("--")===0,l=wu(n,t[n],r);n==="float"&&(n="cssFloat"),r?e.setProperty(n,l):e[n]=l}}var Vc=G({menuitem:!0},{area:!0,base:!0,br:!0,col:!0,embed:!0,hr:!0,img:!0,input:!0,keygen:!0,link:!0,meta:!0,param:!0,source:!0,track:!0,wbr:!0});function ui(e,t){if(t){if(Vc[e]&&(t.children!=null||t.dangerouslySetInnerHTML!=null))throw Error(j(137,e));if(t.dangerouslySetInnerHTML!=null){if(t.children!=null)throw Error(j(60));if(typeof t.dangerouslySetInnerHTML!="object"||!("__html"in t.dangerouslySetInnerHTML))throw Error(j(61))}if(t.style!=null&&typeof t.style!="object")throw Error(j(62))}}function ai(e,t){if(e.indexOf("-")===-1)return typeof t.is=="string";switch(e){case"annotation-xml":case"color-profile":case"font-face":case"font-face-src":case"font-face-uri":case"font-face-format":case"font-face-name":case"missing-glyph":return!1;default:return!0}}var ci=null;function ns(e){return e=e.target||e.srcElement||window,e.correspondingUseElement&&(e=e.correspondingUseElement),e.nodeType===3?e.parentNode:e}var di=null,nn=null,rn=null;function Js(e){if(e=sr(e)){if(typeof di!="function")throw Error(j(280));var t=e.stateNode;t&&(t=hl(t),di(e.stateNode,e.type,t))}}function ku(e){nn?rn?rn.push(e):rn=[e]:nn=e}function _u(){if(nn){var e=nn,t=rn;if(rn=nn=null,Js(e),t)for(e=0;e>>=0,e===0?32:31-(td(e)/nd|0)|0}var hr=64,pr=4194304;function zn(e){switch(e&-e){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return e&4194240;case 4194304:case 8388608:case 16777216:case 33554432:case 67108864:return e&130023424;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 1073741824;default:return e}}function Hr(e,t){var n=e.pendingLanes;if(n===0)return 0;var r=0,l=e.suspendedLanes,i=e.pingedLanes,o=n&268435455;if(o!==0){var u=o&~l;u!==0?r=zn(u):(i&=o,i!==0&&(r=zn(i)))}else o=n&~l,o!==0?r=zn(o):i!==0&&(r=zn(i));if(r===0)return 0;if(t!==0&&t!==r&&!(t&l)&&(l=r&-r,i=t&-t,l>=i||l===16&&(i&4194240)!==0))return t;if(r&4&&(r|=n&16),t=e.entangledLanes,t!==0)for(e=e.entanglements,t&=r;0n;n++)t.push(e);return t}function lr(e,t,n){e.pendingLanes|=t,t!==536870912&&(e.suspendedLanes=0,e.pingedLanes=0),e=e.eventTimes,t=31-Ue(t),e[t]=n}function sd(e,t){var n=e.pendingLanes&~t;e.pendingLanes=t,e.suspendedLanes=0,e.pingedLanes=0,e.expiredLanes&=t,e.mutableReadLanes&=t,e.entangledLanes&=t,t=e.entanglements;var r=e.eventTimes;for(e=e.expirationTimes;0=Dn),so=" ",oo=!1;function Vu(e,t){switch(e){case"keyup":return Od.indexOf(t.keyCode)!==-1;case"keydown":return t.keyCode!==229;case"keypress":case"mousedown":case"focusout":return!0;default:return!1}}function Qu(e){return e=e.detail,typeof e=="object"&&"data"in e?e.data:null}var Qt=!1;function Md(e,t){switch(e){case"compositionend":return Qu(t);case"keypress":return t.which!==32?null:(oo=!0,so);case"textInput":return e=t.data,e===so&&oo?null:e;default:return null}}function Id(e,t){if(Qt)return e==="compositionend"||!cs&&Vu(e,t)?(e=Hu(),zr=os=dt=null,Qt=!1,e):null;switch(e){case"paste":return null;case"keypress":if(!(t.ctrlKey||t.altKey||t.metaKey)||t.ctrlKey&&t.altKey){if(t.char&&1=t)return{node:n,offset:t-e};e=r}e:{for(;n;){if(n.nextSibling){n=n.nextSibling;break e}n=n.parentNode}n=void 0}n=fo(n)}}function Yu(e,t){return e&&t?e===t?!0:e&&e.nodeType===3?!1:t&&t.nodeType===3?Yu(e,t.parentNode):"contains"in e?e.contains(t):e.compareDocumentPosition?!!(e.compareDocumentPosition(t)&16):!1:!1}function Zu(){for(var e=window,t=$r();t instanceof e.HTMLIFrameElement;){try{var n=typeof t.contentWindow.location.href=="string"}catch{n=!1}if(n)e=t.contentWindow;else break;t=$r(e.document)}return t}function ds(e){var t=e&&e.nodeName&&e.nodeName.toLowerCase();return t&&(t==="input"&&(e.type==="text"||e.type==="search"||e.type==="tel"||e.type==="url"||e.type==="password")||t==="textarea"||e.contentEditable==="true")}function Kd(e){var t=Zu(),n=e.focusedElem,r=e.selectionRange;if(t!==n&&n&&n.ownerDocument&&Yu(n.ownerDocument.documentElement,n)){if(r!==null&&ds(n)){if(t=r.start,e=r.end,e===void 0&&(e=t),"selectionStart"in n)n.selectionStart=t,n.selectionEnd=Math.min(e,n.value.length);else if(e=(t=n.ownerDocument||document)&&t.defaultView||window,e.getSelection){e=e.getSelection();var l=n.textContent.length,i=Math.min(r.start,l);r=r.end===void 0?i:Math.min(r.end,l),!e.extend&&i>r&&(l=r,r=i,i=l),l=ho(n,i);var o=ho(n,r);l&&o&&(e.rangeCount!==1||e.anchorNode!==l.node||e.anchorOffset!==l.offset||e.focusNode!==o.node||e.focusOffset!==o.offset)&&(t=t.createRange(),t.setStart(l.node,l.offset),e.removeAllRanges(),i>r?(e.addRange(t),e.extend(o.node,o.offset)):(t.setEnd(o.node,o.offset),e.addRange(t)))}}for(t=[],e=n;e=e.parentNode;)e.nodeType===1&&t.push({element:e,left:e.scrollLeft,top:e.scrollTop});for(typeof n.focus=="function"&&n.focus(),n=0;n=document.documentMode,Kt=null,xi=null,Rn=null,vi=!1;function po(e,t,n){var r=n.window===n?n.document:n.nodeType===9?n:n.ownerDocument;vi||Kt==null||Kt!==$r(r)||(r=Kt,"selectionStart"in r&&ds(r)?r={start:r.selectionStart,end:r.selectionEnd}:(r=(r.ownerDocument&&r.ownerDocument.defaultView||window).getSelection(),r={anchorNode:r.anchorNode,anchorOffset:r.anchorOffset,focusNode:r.focusNode,focusOffset:r.focusOffset}),Rn&&Xn(Rn,r)||(Rn=r,r=Qr(xi,"onSelect"),0Yt||(e.current=_i[Yt],_i[Yt]=null,Yt--)}function U(e,t){Yt++,_i[Yt]=e.current,e.current=t}var St={},ce=_t(St),ye=_t(!1),Ot=St;function an(e,t){var n=e.type.contextTypes;if(!n)return St;var r=e.stateNode;if(r&&r.__reactInternalMemoizedUnmaskedChildContext===t)return r.__reactInternalMemoizedMaskedChildContext;var l={},i;for(i in n)l[i]=t[i];return r&&(e=e.stateNode,e.__reactInternalMemoizedUnmaskedChildContext=t,e.__reactInternalMemoizedMaskedChildContext=l),l}function je(e){return e=e.childContextTypes,e!=null}function Xr(){W(ye),W(ce)}function wo(e,t,n){if(ce.current!==St)throw Error(j(168));U(ce,t),U(ye,n)}function ia(e,t,n){var r=e.stateNode;if(t=t.childContextTypes,typeof r.getChildContext!="function")return n;r=r.getChildContext();for(var l in r)if(!(l in t))throw Error(j(108,Bc(e)||"Unknown",l));return G({},n,r)}function Gr(e){return e=(e=e.stateNode)&&e.__reactInternalMemoizedMergedChildContext||St,Ot=ce.current,U(ce,e),U(ye,ye.current),!0}function So(e,t,n){var r=e.stateNode;if(!r)throw Error(j(169));n?(e=ia(e,t,Ot),r.__reactInternalMemoizedMergedChildContext=e,W(ye),W(ce),U(ce,e)):W(ye),U(ye,n)}var Je=null,pl=!1,Hl=!1;function sa(e){Je===null?Je=[e]:Je.push(e)}function lf(e){pl=!0,sa(e)}function Nt(){if(!Hl&&Je!==null){Hl=!0;var e=0,t=I;try{var n=Je;for(I=1;e>=o,l-=o,qe=1<<32-Ue(t)+l|n<E?($=C,C=null):$=C.sibling;var F=g(p,C,m[E],y);if(F===null){C===null&&(C=$);break}e&&C&&F.alternate===null&&t(p,C),h=i(F,h,E),_===null?S=F:_.sibling=F,_=F,C=$}if(E===m.length)return n(p,C),Q&&Et(p,E),S;if(C===null){for(;EE?($=C,C=null):$=C.sibling;var re=g(p,C,F.value,y);if(re===null){C===null&&(C=$);break}e&&C&&re.alternate===null&&t(p,C),h=i(re,h,E),_===null?S=re:_.sibling=re,_=re,C=$}if(F.done)return n(p,C),Q&&Et(p,E),S;if(C===null){for(;!F.done;E++,F=m.next())F=d(p,F.value,y),F!==null&&(h=i(F,h,E),_===null?S=F:_.sibling=F,_=F);return Q&&Et(p,E),S}for(C=r(p,C);!F.done;E++,F=m.next())F=v(C,p,E,F.value,y),F!==null&&(e&&F.alternate!==null&&C.delete(F.key===null?E:F.key),h=i(F,h,E),_===null?S=F:_.sibling=F,_=F);return e&&C.forEach(function(Pe){return t(p,Pe)}),Q&&Et(p,E),S}function R(p,h,m,y){if(typeof m=="object"&&m!==null&&m.type===Vt&&m.key===null&&(m=m.props.children),typeof m=="object"&&m!==null){switch(m.$$typeof){case cr:e:{for(var S=m.key,_=h;_!==null;){if(_.key===S){if(S=m.type,S===Vt){if(_.tag===7){n(p,_.sibling),h=l(_,m.props.children),h.return=p,p=h;break e}}else if(_.elementType===S||typeof S=="object"&&S!==null&&S.$$typeof===ot&&No(S)===_.type){n(p,_.sibling),h=l(_,m.props),h.ref=_n(p,_,m),h.return=p,p=h;break e}n(p,_);break}else t(p,_);_=_.sibling}m.type===Vt?(h=Dt(m.props.children,p.mode,y,m.key),h.return=p,p=h):(y=Ir(m.type,m.key,m.props,null,p.mode,y),y.ref=_n(p,h,m),y.return=p,p=y)}return o(p);case Wt:e:{for(_=m.key;h!==null;){if(h.key===_)if(h.tag===4&&h.stateNode.containerInfo===m.containerInfo&&h.stateNode.implementation===m.implementation){n(p,h.sibling),h=l(h,m.children||[]),h.return=p,p=h;break e}else{n(p,h);break}else t(p,h);h=h.sibling}h=Zl(m,p.mode,y),h.return=p,p=h}return o(p);case ot:return _=m._init,R(p,h,_(m._payload),y)}if(Pn(m))return x(p,h,m,y);if(yn(m))return w(p,h,m,y);wr(p,m)}return typeof m=="string"&&m!==""||typeof m=="number"?(m=""+m,h!==null&&h.tag===6?(n(p,h.sibling),h=l(h,m),h.return=p,p=h):(n(p,h),h=Yl(m,p.mode,y),h.return=p,p=h),o(p)):n(p,h)}return R}var dn=ca(!0),da=ca(!1),Jr=_t(null),qr=null,qt=null,ms=null;function gs(){ms=qt=qr=null}function xs(e){var t=Jr.current;W(Jr),e._currentValue=t}function Ei(e,t,n){for(;e!==null;){var r=e.alternate;if((e.childLanes&t)!==t?(e.childLanes|=t,r!==null&&(r.childLanes|=t)):r!==null&&(r.childLanes&t)!==t&&(r.childLanes|=t),e===n)break;e=e.return}}function sn(e,t){qr=e,ms=qt=null,e=e.dependencies,e!==null&&e.firstContext!==null&&(e.lanes&t&&(ve=!0),e.firstContext=null)}function De(e){var t=e._currentValue;if(ms!==e)if(e={context:e,memoizedValue:t,next:null},qt===null){if(qr===null)throw Error(j(308));qt=e,qr.dependencies={lanes:0,firstContext:e}}else qt=qt.next=e;return t}var Tt=null;function vs(e){Tt===null?Tt=[e]:Tt.push(e)}function fa(e,t,n,r){var l=t.interleaved;return l===null?(n.next=n,vs(t)):(n.next=l.next,l.next=n),t.interleaved=n,rt(e,r)}function rt(e,t){e.lanes|=t;var n=e.alternate;for(n!==null&&(n.lanes|=t),n=e,e=e.return;e!==null;)e.childLanes|=t,n=e.alternate,n!==null&&(n.childLanes|=t),n=e,e=e.return;return n.tag===3?n.stateNode:null}var ut=!1;function ys(e){e.updateQueue={baseState:e.memoizedState,firstBaseUpdate:null,lastBaseUpdate:null,shared:{pending:null,interleaved:null,lanes:0},effects:null}}function ha(e,t){e=e.updateQueue,t.updateQueue===e&&(t.updateQueue={baseState:e.baseState,firstBaseUpdate:e.firstBaseUpdate,lastBaseUpdate:e.lastBaseUpdate,shared:e.shared,effects:e.effects})}function et(e,t){return{eventTime:e,lane:t,tag:0,payload:null,callback:null,next:null}}function xt(e,t,n){var r=e.updateQueue;if(r===null)return null;if(r=r.shared,O&2){var l=r.pending;return l===null?t.next=t:(t.next=l.next,l.next=t),r.pending=t,rt(e,n)}return l=r.interleaved,l===null?(t.next=t,vs(r)):(t.next=l.next,l.next=t),r.interleaved=t,rt(e,n)}function Fr(e,t,n){if(t=t.updateQueue,t!==null&&(t=t.shared,(n&4194240)!==0)){var r=t.lanes;r&=e.pendingLanes,n|=r,t.lanes=n,ls(e,n)}}function Co(e,t){var n=e.updateQueue,r=e.alternate;if(r!==null&&(r=r.updateQueue,n===r)){var l=null,i=null;if(n=n.firstBaseUpdate,n!==null){do{var o={eventTime:n.eventTime,lane:n.lane,tag:n.tag,payload:n.payload,callback:n.callback,next:null};i===null?l=i=o:i=i.next=o,n=n.next}while(n!==null);i===null?l=i=t:i=i.next=t}else l=i=t;n={baseState:r.baseState,firstBaseUpdate:l,lastBaseUpdate:i,shared:r.shared,effects:r.effects},e.updateQueue=n;return}e=n.lastBaseUpdate,e===null?n.firstBaseUpdate=t:e.next=t,n.lastBaseUpdate=t}function br(e,t,n,r){var l=e.updateQueue;ut=!1;var i=l.firstBaseUpdate,o=l.lastBaseUpdate,u=l.shared.pending;if(u!==null){l.shared.pending=null;var c=u,a=c.next;c.next=null,o===null?i=a:o.next=a,o=c;var f=e.alternate;f!==null&&(f=f.updateQueue,u=f.lastBaseUpdate,u!==o&&(u===null?f.firstBaseUpdate=a:u.next=a,f.lastBaseUpdate=c))}if(i!==null){var d=l.baseState;o=0,f=a=c=null,u=i;do{var g=u.lane,v=u.eventTime;if((r&g)===g){f!==null&&(f=f.next={eventTime:v,lane:0,tag:u.tag,payload:u.payload,callback:u.callback,next:null});e:{var x=e,w=u;switch(g=t,v=n,w.tag){case 1:if(x=w.payload,typeof x=="function"){d=x.call(v,d,g);break e}d=x;break e;case 3:x.flags=x.flags&-65537|128;case 0:if(x=w.payload,g=typeof x=="function"?x.call(v,d,g):x,g==null)break e;d=G({},d,g);break e;case 2:ut=!0}}u.callback!==null&&u.lane!==0&&(e.flags|=64,g=l.effects,g===null?l.effects=[u]:g.push(u))}else v={eventTime:v,lane:g,tag:u.tag,payload:u.payload,callback:u.callback,next:null},f===null?(a=f=v,c=d):f=f.next=v,o|=g;if(u=u.next,u===null){if(u=l.shared.pending,u===null)break;g=u,u=g.next,g.next=null,l.lastBaseUpdate=g,l.shared.pending=null}}while(!0);if(f===null&&(c=d),l.baseState=c,l.firstBaseUpdate=a,l.lastBaseUpdate=f,t=l.shared.interleaved,t!==null){l=t;do o|=l.lane,l=l.next;while(l!==t)}else i===null&&(l.shared.lanes=0);It|=o,e.lanes=o,e.memoizedState=d}}function Eo(e,t,n){if(e=t.effects,t.effects=null,e!==null)for(t=0;tn?n:4,e(!0);var r=Vl.transition;Vl.transition={};try{e(!1),t()}finally{I=n,Vl.transition=r}}function Ta(){return Oe().memoizedState}function af(e,t,n){var r=yt(e);if(n={lane:r,action:n,hasEagerState:!1,eagerState:null,next:null},Fa(e))La(t,n);else if(n=fa(e,t,n,r),n!==null){var l=pe();Ae(n,e,r,l),Da(n,t,r)}}function cf(e,t,n){var r=yt(e),l={lane:r,action:n,hasEagerState:!1,eagerState:null,next:null};if(Fa(e))La(t,l);else{var i=e.alternate;if(e.lanes===0&&(i===null||i.lanes===0)&&(i=t.lastRenderedReducer,i!==null))try{var o=t.lastRenderedState,u=i(o,n);if(l.hasEagerState=!0,l.eagerState=u,Be(u,o)){var c=t.interleaved;c===null?(l.next=l,vs(t)):(l.next=c.next,c.next=l),t.interleaved=l;return}}catch{}finally{}n=fa(e,t,l,r),n!==null&&(l=pe(),Ae(n,e,r,l),Da(n,t,r))}}function Fa(e){var t=e.alternate;return e===X||t!==null&&t===X}function La(e,t){Mn=tl=!0;var n=e.pending;n===null?t.next=t:(t.next=n.next,n.next=t),e.pending=t}function Da(e,t,n){if(n&4194240){var r=t.lanes;r&=e.pendingLanes,n|=r,t.lanes=n,ls(e,n)}}var nl={readContext:De,useCallback:oe,useContext:oe,useEffect:oe,useImperativeHandle:oe,useInsertionEffect:oe,useLayoutEffect:oe,useMemo:oe,useReducer:oe,useRef:oe,useState:oe,useDebugValue:oe,useDeferredValue:oe,useTransition:oe,useMutableSource:oe,useSyncExternalStore:oe,useId:oe,unstable_isNewReconciler:!1},df={readContext:De,useCallback:function(e,t){return Ve().memoizedState=[e,t===void 0?null:t],e},useContext:De,useEffect:zo,useImperativeHandle:function(e,t,n){return n=n!=null?n.concat([e]):null,Dr(4194308,4,Na.bind(null,t,e),n)},useLayoutEffect:function(e,t){return Dr(4194308,4,e,t)},useInsertionEffect:function(e,t){return Dr(4,2,e,t)},useMemo:function(e,t){var n=Ve();return t=t===void 0?null:t,e=e(),n.memoizedState=[e,t],e},useReducer:function(e,t,n){var r=Ve();return t=n!==void 0?n(t):t,r.memoizedState=r.baseState=t,e={pending:null,interleaved:null,lanes:0,dispatch:null,lastRenderedReducer:e,lastRenderedState:t},r.queue=e,e=e.dispatch=af.bind(null,X,e),[r.memoizedState,e]},useRef:function(e){var t=Ve();return e={current:e},t.memoizedState=e},useState:Po,useDebugValue:Es,useDeferredValue:function(e){return Ve().memoizedState=e},useTransition:function(){var e=Po(!1),t=e[0];return e=uf.bind(null,e[1]),Ve().memoizedState=e,[t,e]},useMutableSource:function(){},useSyncExternalStore:function(e,t,n){var r=X,l=Ve();if(Q){if(n===void 0)throw Error(j(407));n=n()}else{if(n=t(),ne===null)throw Error(j(349));Mt&30||xa(r,t,n)}l.memoizedState=n;var i={value:n,getSnapshot:t};return l.queue=i,zo(ya.bind(null,r,i,e),[e]),r.flags|=2048,tr(9,va.bind(null,r,i,n,t),void 0,null),n},useId:function(){var e=Ve(),t=ne.identifierPrefix;if(Q){var n=be,r=qe;n=(r&~(1<<32-Ue(r)-1)).toString(32)+n,t=":"+t+"R"+n,n=bn++,0<\/script>",e=e.removeChild(e.firstChild)):typeof r.is=="string"?e=o.createElement(n,{is:r.is}):(e=o.createElement(n),n==="select"&&(o=e,r.multiple?o.multiple=!0:r.size&&(o.size=r.size))):e=o.createElementNS(e,n),e[Qe]=t,e[Zn]=r,Wa(e,t,!1,!1),t.stateNode=e;e:{switch(o=ai(n,r),n){case"dialog":H("cancel",e),H("close",e),l=r;break;case"iframe":case"object":case"embed":H("load",e),l=r;break;case"video":case"audio":for(l=0;lpn&&(t.flags|=128,r=!0,Nn(i,!1),t.lanes=4194304)}else{if(!r)if(e=el(o),e!==null){if(t.flags|=128,r=!0,n=e.updateQueue,n!==null&&(t.updateQueue=n,t.flags|=4),Nn(i,!0),i.tail===null&&i.tailMode==="hidden"&&!o.alternate&&!Q)return ue(t),null}else 2*Z()-i.renderingStartTime>pn&&n!==1073741824&&(t.flags|=128,r=!0,Nn(i,!1),t.lanes=4194304);i.isBackwards?(o.sibling=t.child,t.child=o):(n=i.last,n!==null?n.sibling=o:t.child=o,i.last=o)}return i.tail!==null?(t=i.tail,i.rendering=t,i.tail=t.sibling,i.renderingStartTime=Z(),t.sibling=null,n=K.current,U(K,r?n&1|2:n&1),t):(ue(t),null);case 22:case 23:return Ds(),r=t.memoizedState!==null,e!==null&&e.memoizedState!==null!==r&&(t.flags|=8192),r&&t.mode&1?Se&1073741824&&(ue(t),t.subtreeFlags&6&&(t.flags|=8192)):ue(t),null;case 24:return null;case 25:return null}throw Error(j(156,t.tag))}function yf(e,t){switch(hs(t),t.tag){case 1:return je(t.type)&&Xr(),e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 3:return fn(),W(ye),W(ce),Ss(),e=t.flags,e&65536&&!(e&128)?(t.flags=e&-65537|128,t):null;case 5:return ws(t),null;case 13:if(W(K),e=t.memoizedState,e!==null&&e.dehydrated!==null){if(t.alternate===null)throw Error(j(340));cn()}return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 19:return W(K),null;case 4:return fn(),null;case 10:return xs(t.type._context),null;case 22:case 23:return Ds(),null;case 24:return null;default:return null}}var kr=!1,ae=!1,jf=typeof WeakSet=="function"?WeakSet:Set,N=null;function bt(e,t){var n=e.ref;if(n!==null)if(typeof n=="function")try{n(null)}catch(r){Y(e,t,r)}else n.current=null}function Mi(e,t,n){try{n()}catch(r){Y(e,t,r)}}var Ao=!1;function wf(e,t){if(yi=Wr,e=Zu(),ds(e)){if("selectionStart"in e)var n={start:e.selectionStart,end:e.selectionEnd};else e:{n=(n=e.ownerDocument)&&n.defaultView||window;var r=n.getSelection&&n.getSelection();if(r&&r.rangeCount!==0){n=r.anchorNode;var l=r.anchorOffset,i=r.focusNode;r=r.focusOffset;try{n.nodeType,i.nodeType}catch{n=null;break e}var o=0,u=-1,c=-1,a=0,f=0,d=e,g=null;t:for(;;){for(var v;d!==n||l!==0&&d.nodeType!==3||(u=o+l),d!==i||r!==0&&d.nodeType!==3||(c=o+r),d.nodeType===3&&(o+=d.nodeValue.length),(v=d.firstChild)!==null;)g=d,d=v;for(;;){if(d===e)break t;if(g===n&&++a===l&&(u=o),g===i&&++f===r&&(c=o),(v=d.nextSibling)!==null)break;d=g,g=d.parentNode}d=v}n=u===-1||c===-1?null:{start:u,end:c}}else n=null}n=n||{start:0,end:0}}else n=null;for(ji={focusedElem:e,selectionRange:n},Wr=!1,N=t;N!==null;)if(t=N,e=t.child,(t.subtreeFlags&1028)!==0&&e!==null)e.return=t,N=e;else for(;N!==null;){t=N;try{var x=t.alternate;if(t.flags&1024)switch(t.tag){case 0:case 11:case 15:break;case 1:if(x!==null){var w=x.memoizedProps,R=x.memoizedState,p=t.stateNode,h=p.getSnapshotBeforeUpdate(t.elementType===t.type?w:Me(t.type,w),R);p.__reactInternalSnapshotBeforeUpdate=h}break;case 3:var m=t.stateNode.containerInfo;m.nodeType===1?m.textContent="":m.nodeType===9&&m.documentElement&&m.removeChild(m.documentElement);break;case 5:case 6:case 4:case 17:break;default:throw Error(j(163))}}catch(y){Y(t,t.return,y)}if(e=t.sibling,e!==null){e.return=t.return,N=e;break}N=t.return}return x=Ao,Ao=!1,x}function In(e,t,n){var r=t.updateQueue;if(r=r!==null?r.lastEffect:null,r!==null){var l=r=r.next;do{if((l.tag&e)===e){var i=l.destroy;l.destroy=void 0,i!==void 0&&Mi(t,n,i)}l=l.next}while(l!==r)}}function xl(e,t){if(t=t.updateQueue,t=t!==null?t.lastEffect:null,t!==null){var n=t=t.next;do{if((n.tag&e)===e){var r=n.create;n.destroy=r()}n=n.next}while(n!==t)}}function Ii(e){var t=e.ref;if(t!==null){var n=e.stateNode;switch(e.tag){case 5:e=n;break;default:e=n}typeof t=="function"?t(e):t.current=e}}function Ka(e){var t=e.alternate;t!==null&&(e.alternate=null,Ka(t)),e.child=null,e.deletions=null,e.sibling=null,e.tag===5&&(t=e.stateNode,t!==null&&(delete t[Qe],delete t[Zn],delete t[ki],delete t[nf],delete t[rf])),e.stateNode=null,e.return=null,e.dependencies=null,e.memoizedProps=null,e.memoizedState=null,e.pendingProps=null,e.stateNode=null,e.updateQueue=null}function Xa(e){return e.tag===5||e.tag===3||e.tag===4}function Bo(e){e:for(;;){for(;e.sibling===null;){if(e.return===null||Xa(e.return))return null;e=e.return}for(e.sibling.return=e.return,e=e.sibling;e.tag!==5&&e.tag!==6&&e.tag!==18;){if(e.flags&2||e.child===null||e.tag===4)continue e;e.child.return=e,e=e.child}if(!(e.flags&2))return e.stateNode}}function $i(e,t,n){var r=e.tag;if(r===5||r===6)e=e.stateNode,t?n.nodeType===8?n.parentNode.insertBefore(e,t):n.insertBefore(e,t):(n.nodeType===8?(t=n.parentNode,t.insertBefore(e,n)):(t=n,t.appendChild(e)),n=n._reactRootContainer,n!=null||t.onclick!==null||(t.onclick=Kr));else if(r!==4&&(e=e.child,e!==null))for($i(e,t,n),e=e.sibling;e!==null;)$i(e,t,n),e=e.sibling}function Ui(e,t,n){var r=e.tag;if(r===5||r===6)e=e.stateNode,t?n.insertBefore(e,t):n.appendChild(e);else if(r!==4&&(e=e.child,e!==null))for(Ui(e,t,n),e=e.sibling;e!==null;)Ui(e,t,n),e=e.sibling}var le=null,Ie=!1;function st(e,t,n){for(n=n.child;n!==null;)Ga(e,t,n),n=n.sibling}function Ga(e,t,n){if(Ke&&typeof Ke.onCommitFiberUnmount=="function")try{Ke.onCommitFiberUnmount(al,n)}catch{}switch(n.tag){case 5:ae||bt(n,t);case 6:var r=le,l=Ie;le=null,st(e,t,n),le=r,Ie=l,le!==null&&(Ie?(e=le,n=n.stateNode,e.nodeType===8?e.parentNode.removeChild(n):e.removeChild(n)):le.removeChild(n.stateNode));break;case 18:le!==null&&(Ie?(e=le,n=n.stateNode,e.nodeType===8?Bl(e.parentNode,n):e.nodeType===1&&Bl(e,n),Qn(e)):Bl(le,n.stateNode));break;case 4:r=le,l=Ie,le=n.stateNode.containerInfo,Ie=!0,st(e,t,n),le=r,Ie=l;break;case 0:case 11:case 14:case 15:if(!ae&&(r=n.updateQueue,r!==null&&(r=r.lastEffect,r!==null))){l=r=r.next;do{var i=l,o=i.destroy;i=i.tag,o!==void 0&&(i&2||i&4)&&Mi(n,t,o),l=l.next}while(l!==r)}st(e,t,n);break;case 1:if(!ae&&(bt(n,t),r=n.stateNode,typeof r.componentWillUnmount=="function"))try{r.props=n.memoizedProps,r.state=n.memoizedState,r.componentWillUnmount()}catch(u){Y(n,t,u)}st(e,t,n);break;case 21:st(e,t,n);break;case 22:n.mode&1?(ae=(r=ae)||n.memoizedState!==null,st(e,t,n),ae=r):st(e,t,n);break;default:st(e,t,n)}}function Ho(e){var t=e.updateQueue;if(t!==null){e.updateQueue=null;var n=e.stateNode;n===null&&(n=e.stateNode=new jf),t.forEach(function(r){var l=Tf.bind(null,e,r);n.has(r)||(n.add(r),r.then(l,l))})}}function Re(e,t){var n=t.deletions;if(n!==null)for(var r=0;rl&&(l=o),r&=~i}if(r=l,r=Z()-r,r=(120>r?120:480>r?480:1080>r?1080:1920>r?1920:3e3>r?3e3:4320>r?4320:1960*kf(r/1960))-r,10e?16:e,ft===null)var r=!1;else{if(e=ft,ft=null,il=0,O&6)throw Error(j(331));var l=O;for(O|=4,N=e.current;N!==null;){var i=N,o=i.child;if(N.flags&16){var u=i.deletions;if(u!==null){for(var c=0;cZ()-Fs?Lt(e,0):Ts|=n),we(e,t)}function nc(e,t){t===0&&(e.mode&1?(t=pr,pr<<=1,!(pr&130023424)&&(pr=4194304)):t=1);var n=pe();e=rt(e,t),e!==null&&(lr(e,t,n),we(e,n))}function zf(e){var t=e.memoizedState,n=0;t!==null&&(n=t.retryLane),nc(e,n)}function Tf(e,t){var n=0;switch(e.tag){case 13:var r=e.stateNode,l=e.memoizedState;l!==null&&(n=l.retryLane);break;case 19:r=e.stateNode;break;default:throw Error(j(314))}r!==null&&r.delete(t),nc(e,n)}var rc;rc=function(e,t,n){if(e!==null)if(e.memoizedProps!==t.pendingProps||ye.current)ve=!0;else{if(!(e.lanes&n)&&!(t.flags&128))return ve=!1,xf(e,t,n);ve=!!(e.flags&131072)}else ve=!1,Q&&t.flags&1048576&&oa(t,Zr,t.index);switch(t.lanes=0,t.tag){case 2:var r=t.type;Or(e,t),e=t.pendingProps;var l=an(t,ce.current);sn(t,n),l=_s(null,t,r,e,l,n);var i=Ns();return t.flags|=1,typeof l=="object"&&l!==null&&typeof l.render=="function"&&l.$$typeof===void 0?(t.tag=1,t.memoizedState=null,t.updateQueue=null,je(r)?(i=!0,Gr(t)):i=!1,t.memoizedState=l.state!==null&&l.state!==void 0?l.state:null,ys(t),l.updater=gl,t.stateNode=l,l._reactInternals=t,zi(t,r,e,n),t=Li(null,t,r,!0,i,n)):(t.tag=0,Q&&i&&fs(t),he(null,t,l,n),t=t.child),t;case 16:r=t.elementType;e:{switch(Or(e,t),e=t.pendingProps,l=r._init,r=l(r._payload),t.type=r,l=t.tag=Lf(r),e=Me(r,e),l){case 0:t=Fi(null,t,r,e,n);break e;case 1:t=Io(null,t,r,e,n);break e;case 11:t=Ro(null,t,r,e,n);break e;case 14:t=Mo(null,t,r,Me(r.type,e),n);break e}throw Error(j(306,r,""))}return t;case 0:return r=t.type,l=t.pendingProps,l=t.elementType===r?l:Me(r,l),Fi(e,t,r,l,n);case 1:return r=t.type,l=t.pendingProps,l=t.elementType===r?l:Me(r,l),Io(e,t,r,l,n);case 3:e:{if(Aa(t),e===null)throw Error(j(387));r=t.pendingProps,i=t.memoizedState,l=i.element,ha(e,t),br(t,r,null,n);var o=t.memoizedState;if(r=o.element,i.isDehydrated)if(i={element:r,isDehydrated:!1,cache:o.cache,pendingSuspenseBoundaries:o.pendingSuspenseBoundaries,transitions:o.transitions},t.updateQueue.baseState=i,t.memoizedState=i,t.flags&256){l=hn(Error(j(423)),t),t=$o(e,t,r,n,l);break e}else if(r!==l){l=hn(Error(j(424)),t),t=$o(e,t,r,n,l);break e}else for(ke=gt(t.stateNode.containerInfo.firstChild),_e=t,Q=!0,$e=null,n=da(t,null,r,n),t.child=n;n;)n.flags=n.flags&-3|4096,n=n.sibling;else{if(cn(),r===l){t=lt(e,t,n);break e}he(e,t,r,n)}t=t.child}return t;case 5:return pa(t),e===null&&Ci(t),r=t.type,l=t.pendingProps,i=e!==null?e.memoizedProps:null,o=l.children,wi(r,l)?o=null:i!==null&&wi(r,i)&&(t.flags|=32),Ua(e,t),he(e,t,o,n),t.child;case 6:return e===null&&Ci(t),null;case 13:return Ba(e,t,n);case 4:return js(t,t.stateNode.containerInfo),r=t.pendingProps,e===null?t.child=dn(t,null,r,n):he(e,t,r,n),t.child;case 11:return r=t.type,l=t.pendingProps,l=t.elementType===r?l:Me(r,l),Ro(e,t,r,l,n);case 7:return he(e,t,t.pendingProps,n),t.child;case 8:return he(e,t,t.pendingProps.children,n),t.child;case 12:return he(e,t,t.pendingProps.children,n),t.child;case 10:e:{if(r=t.type._context,l=t.pendingProps,i=t.memoizedProps,o=l.value,U(Jr,r._currentValue),r._currentValue=o,i!==null)if(Be(i.value,o)){if(i.children===l.children&&!ye.current){t=lt(e,t,n);break e}}else for(i=t.child,i!==null&&(i.return=t);i!==null;){var u=i.dependencies;if(u!==null){o=i.child;for(var c=u.firstContext;c!==null;){if(c.context===r){if(i.tag===1){c=et(-1,n&-n),c.tag=2;var a=i.updateQueue;if(a!==null){a=a.shared;var f=a.pending;f===null?c.next=c:(c.next=f.next,f.next=c),a.pending=c}}i.lanes|=n,c=i.alternate,c!==null&&(c.lanes|=n),Ei(i.return,n,t),u.lanes|=n;break}c=c.next}}else if(i.tag===10)o=i.type===t.type?null:i.child;else if(i.tag===18){if(o=i.return,o===null)throw Error(j(341));o.lanes|=n,u=o.alternate,u!==null&&(u.lanes|=n),Ei(o,n,t),o=i.sibling}else o=i.child;if(o!==null)o.return=i;else for(o=i;o!==null;){if(o===t){o=null;break}if(i=o.sibling,i!==null){i.return=o.return,o=i;break}o=o.return}i=o}he(e,t,l.children,n),t=t.child}return t;case 9:return l=t.type,r=t.pendingProps.children,sn(t,n),l=De(l),r=r(l),t.flags|=1,he(e,t,r,n),t.child;case 14:return r=t.type,l=Me(r,t.pendingProps),l=Me(r.type,l),Mo(e,t,r,l,n);case 15:return Ia(e,t,t.type,t.pendingProps,n);case 17:return r=t.type,l=t.pendingProps,l=t.elementType===r?l:Me(r,l),Or(e,t),t.tag=1,je(r)?(e=!0,Gr(t)):e=!1,sn(t,n),Oa(t,r,l),zi(t,r,l,n),Li(null,t,r,!0,e,n);case 19:return Ha(e,t,n);case 22:return $a(e,t,n)}throw Error(j(156,t.tag))};function lc(e,t){return Fu(e,t)}function Ff(e,t,n,r){this.tag=e,this.key=n,this.sibling=this.child=this.return=this.stateNode=this.type=this.elementType=null,this.index=0,this.ref=null,this.pendingProps=t,this.dependencies=this.memoizedState=this.updateQueue=this.memoizedProps=null,this.mode=r,this.subtreeFlags=this.flags=0,this.deletions=null,this.childLanes=this.lanes=0,this.alternate=null}function Fe(e,t,n,r){return new Ff(e,t,n,r)}function Rs(e){return e=e.prototype,!(!e||!e.isReactComponent)}function Lf(e){if(typeof e=="function")return Rs(e)?1:0;if(e!=null){if(e=e.$$typeof,e===es)return 11;if(e===ts)return 14}return 2}function jt(e,t){var n=e.alternate;return n===null?(n=Fe(e.tag,t,e.key,e.mode),n.elementType=e.elementType,n.type=e.type,n.stateNode=e.stateNode,n.alternate=e,e.alternate=n):(n.pendingProps=t,n.type=e.type,n.flags=0,n.subtreeFlags=0,n.deletions=null),n.flags=e.flags&14680064,n.childLanes=e.childLanes,n.lanes=e.lanes,n.child=e.child,n.memoizedProps=e.memoizedProps,n.memoizedState=e.memoizedState,n.updateQueue=e.updateQueue,t=e.dependencies,n.dependencies=t===null?null:{lanes:t.lanes,firstContext:t.firstContext},n.sibling=e.sibling,n.index=e.index,n.ref=e.ref,n}function Ir(e,t,n,r,l,i){var o=2;if(r=e,typeof e=="function")Rs(e)&&(o=1);else if(typeof e=="string")o=5;else e:switch(e){case Vt:return Dt(n.children,l,i,t);case bi:o=8,l|=8;break;case bl:return e=Fe(12,n,t,l|2),e.elementType=bl,e.lanes=i,e;case ei:return e=Fe(13,n,t,l),e.elementType=ei,e.lanes=i,e;case ti:return e=Fe(19,n,t,l),e.elementType=ti,e.lanes=i,e;case pu:return yl(n,l,i,t);default:if(typeof e=="object"&&e!==null)switch(e.$$typeof){case fu:o=10;break e;case hu:o=9;break e;case es:o=11;break e;case ts:o=14;break e;case ot:o=16,r=null;break e}throw Error(j(130,e==null?e:typeof e,""))}return t=Fe(o,n,t,l),t.elementType=e,t.type=r,t.lanes=i,t}function Dt(e,t,n,r){return e=Fe(7,e,r,t),e.lanes=n,e}function yl(e,t,n,r){return e=Fe(22,e,r,t),e.elementType=pu,e.lanes=n,e.stateNode={isHidden:!1},e}function Yl(e,t,n){return e=Fe(6,e,null,t),e.lanes=n,e}function Zl(e,t,n){return t=Fe(4,e.children!==null?e.children:[],e.key,t),t.lanes=n,t.stateNode={containerInfo:e.containerInfo,pendingChildren:null,implementation:e.implementation},t}function Df(e,t,n,r,l){this.tag=t,this.containerInfo=e,this.finishedWork=this.pingCache=this.current=this.pendingChildren=null,this.timeoutHandle=-1,this.callbackNode=this.pendingContext=this.context=null,this.callbackPriority=0,this.eventTimes=Tl(0),this.expirationTimes=Tl(-1),this.entangledLanes=this.finishedLanes=this.mutableReadLanes=this.expiredLanes=this.pingedLanes=this.suspendedLanes=this.pendingLanes=0,this.entanglements=Tl(0),this.identifierPrefix=r,this.onRecoverableError=l,this.mutableSourceEagerHydrationData=null}function Ms(e,t,n,r,l,i,o,u,c){return e=new Df(e,t,n,u,c),t===1?(t=1,i===!0&&(t|=8)):t=0,i=Fe(3,null,null,t),e.current=i,i.stateNode=e,i.memoizedState={element:r,isDehydrated:n,cache:null,transitions:null,pendingSuspenseBoundaries:null},ys(i),e}function Of(e,t,n){var r=3"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(uc)}catch(e){console.error(e)}}uc(),uu.exports=Ce;var Uf=uu.exports,Zo=Uf;Jl.createRoot=Zo.createRoot,Jl.hydrateRoot=Zo.hydrateRoot;const ac=["HyperLiquid","Bitget","Binance","OKX"];function Vi(e){return e==null||e<=0?"-":e>=100?e.toFixed(2):e>=1?e.toFixed(4):e.toFixed(6)}function Qi(e){return e==null?"":e>0?"text-green":e<0?"text-red":""}function Af(){const[e,t]=z.useState("--:--:--"),[n,r]=z.useState("● 未连接"),[l,i]=z.useState(!1),[o,u]=z.useState(""),[c,a]=z.useState([]),[f,d]=z.useState(""),[g,v]=z.useState([]),[x,w]=z.useState([]),[R,p]=z.useState([]),[h,m]=z.useState({}),[y,S]=z.useState([]),[_,C]=z.useState([]),[E,$]=z.useState([]),[F,re]=z.useState([]),[Pe,He]=z.useState([]),[Ct,M]=z.useState([]),[de,fe]=z.useState([]),k=z.useRef({});z.useEffect(()=>{const L=()=>t(new Date().toLocaleTimeString("zh-CN",{hour12:!1}));L();const V=setInterval(L,1e3);return()=>clearInterval(V)},[]),z.useEffect(()=>{let L=new EventSource("/events");return L.addEventListener("connected",()=>{r("● 已连接"),i(!0)}),L.onerror=()=>{r("● 已断开 (重连中...)"),i(!1),setTimeout(()=>{L=new EventSource("/events")},3e3)},L.onmessage=V=>{try{const B=JSON.parse(V.data);switch(B.event){case"prices":J(B.data);break;case"arb":v(B.data||[]);break;case"positions":w(B.data||[]);break;case"blacklist":p(B.data||[]);break;case"momentum":C(B.data||[]);break;case"trend":$(B.data||[]);break;case"cumulative":re(B.data||[]);break;case"trend_filter":M(B.data||[]);break;case"trend_signal":fe(ur=>[B.data,...ur].slice(0,100));break;case"stats":m(B.data||{}),B.data&&B.data.blacklist&&p(B.data.blacklist);break;case"trade_close":P();break}}catch{}},()=>L.close()},[]);const P=z.useCallback(async()=>{try{const V=await(await fetch("/api/trades")).json();S(V.trades||[])}catch{}},[]);z.useEffect(()=>{P();const L=setInterval(P,1e4);return()=>clearInterval(L)},[P]);const T=z.useCallback(async()=>{try{const V=await(await fetch("/api/cm-history")).json();He(V.events||[])}catch{}},[]);z.useEffect(()=>{T();const L=setInterval(T,5e3);return()=>clearInterval(L)},[T]);const A=z.useCallback(async()=>{try{const V=await(await fetch("/api/trend-signals")).json();V.signals&&fe(V.signals)}catch{}},[]);z.useEffect(()=>{A();const L=setInterval(A,5e3);return()=>clearInterval(L)},[A]);function J(L){if(!L||L.length===0)return;a(L),d(new Date().toLocaleTimeString("zh-CN",{hour12:!1}));const V=k.current;for(const B of L)for(const ur of ac){const _l=B.coin+"."+ur,As=B[ur]||0;V[_l]?V[_l].last=As:V[_l]={last:As}}}function Bt(){const L=new Set,V=[];if(!c)return V;for(const B of c)L.has(B.coin)||(L.add(B.coin),V.push(B.coin));return V}function Ge(L,V){var B;return(B=k.current[L+"."+V])==null?void 0:B.last}function vn(L,V){return L==null||V==null?"":V>L?"text-green":V`${i}:${o}`).join(" "));let l="";return e.exchange_funds&&(l=Object.entries(e.exchange_funds).map(([i,o])=>`${i}: $${o.balance.toFixed(2)}`).join(" | ")),s.jsxs("section",{className:"card",id:"stats-card",children:[s.jsx("h2",{children:"📊 统计数据"}),s.jsxs("div",{className:"stats-row",children:[s.jsxs("div",{className:"stat",children:[s.jsx("label",{children:"总交易"}),s.jsx("span",{id:"stat-total",children:e.total_trades||0})]}),s.jsxs("div",{className:"stat",children:[s.jsx("label",{children:"收敛"}),s.jsx("span",{className:"pct-green",children:e.converged||0})]}),s.jsxs("div",{className:"stat",children:[s.jsx("label",{children:"发散"}),s.jsx("span",{className:"pct-red",children:e.diverged||0})]}),s.jsxs("div",{className:"stat",children:[s.jsx("label",{children:"持平"}),s.jsx("span",{className:"pct-gray",children:e.flat||0})]}),s.jsxs("div",{className:"stat",children:[s.jsx("label",{children:"持仓"}),s.jsxs("span",{className:"pct-yellow",children:[e.open_positions||0," / ",s.jsx("span",{children:"5"})]})]}),s.jsxs("div",{className:"stat",children:[s.jsx("label",{children:"币种"}),s.jsx("span",{className:"pct-blue",children:e.coins||0})]}),s.jsxs("div",{className:"stat",id:"conn-stats",children:[s.jsx("label",{children:"连接"}),s.jsx("span",{id:"conn-detail",style:{fontSize:11},children:r})]})]}),l&&s.jsx("div",{className:"stats-row",style:{marginTop:2,fontSize:11,opacity:.85},children:s.jsxs("div",{className:"stat",style:{gridColumn:"1 / -1"},children:[s.jsx("label",{children:"资金"}),s.jsx("span",{style:{fontWeight:600},children:l})]})}),t&&s.jsxs("div",{className:"stats-row detail-stats",style:{marginTop:4,fontSize:12,opacity:.85},children:[s.jsxs("div",{className:"stat",children:[s.jsx("label",{children:"总PnL"}),s.jsx("span",{children:(t.total_pnl_usd!=null?"$"+t.total_pnl_usd.toFixed(2):"—")+(t.capital_pnl!=null?" ("+t.capital_pnl.toFixed(4)+"%)":"")})]}),s.jsxs("div",{className:"stat",children:[s.jsx("label",{children:"本金"}),s.jsx("span",{children:n!=null?"$"+n.toFixed(0):"—"})]}),s.jsxs("div",{className:"stat",children:[s.jsx("label",{children:"胜率"}),s.jsx("span",{children:t.win_rate!=null?t.win_rate.toFixed(1)+"%":"—"})]}),s.jsxs("div",{className:"stat",children:[s.jsx("label",{children:"最多盈利"}),s.jsx("span",{className:"text-green",children:t.max_profit!=null?t.max_profit.toFixed(4)+"%":"—"})]}),s.jsxs("div",{className:"stat",children:[s.jsx("label",{children:"最多亏损"}),s.jsx("span",{className:"text-red",children:t.max_loss!=null?t.max_loss.toFixed(4)+"%":"—"})]}),s.jsxs("div",{className:"stat",children:[s.jsx("label",{children:"平均持仓"}),s.jsx("span",{children:t.avg_dur||"—"})]})]})]})}function Hf({positions:e}){const[t,n]=z.useState(!1),[r,l]=z.useState(null),[i,o]=z.useState([]);function u(a){a&&(n(!0),l(null),o([]),fetch("/api/trade/"+a).then(f=>f.json()).then(f=>{l(f.trade),o(f.orders||[])}).catch(()=>{l({ID:a})}))}function c(){n(!1)}return z.useEffect(()=>{if(!t)return;function a(f){f.key==="Escape"&&c()}return document.addEventListener("keydown",a),()=>document.removeEventListener("keydown",a)},[t]),s.jsxs(s.Fragment,{children:[s.jsxs("section",{className:"card",id:"positions-card",children:[s.jsx("h2",{children:"🔒 当前持仓"}),s.jsx("div",{className:"table-wrap",children:s.jsxs("table",{id:"positions-table",children:[s.jsx("thead",{children:s.jsxs("tr",{children:[s.jsx("th",{children:"币种"}),s.jsx("th",{children:"方向"}),s.jsx("th",{children:"规模"}),s.jsx("th",{children:"入价差"}),s.jsx("th",{children:"现价差"}),s.jsx("th",{children:"估盈亏"}),s.jsx("th",{children:"加仓"}),s.jsx("th",{children:"时长"})]})}),s.jsx("tbody",{id:"positions-body",children:e.length===0?s.jsx("tr",{children:s.jsx("td",{colSpan:"8",className:"loading",children:"无持仓"})}):[...e].sort((a,f)=>a.coin.localeCompare(f.coin)).map(a=>s.jsxs("tr",{className:"trade-row",onClick:()=>u(a.db_trade_id),children:[s.jsx("td",{children:s.jsx("strong",{children:a.coin})}),s.jsx("td",{children:a.direction}),s.jsxs("td",{className:"text-right",children:["$",(a.amount_usd||0).toFixed(0)]}),s.jsxs("td",{className:"text-right",children:[(a.entry_spread||0).toFixed(4),"%"]}),s.jsx("td",{className:"text-right",children:a.current_spread!=null?a.current_spread.toFixed(4)+"%":"-"}),s.jsx("td",{className:"text-right "+Qi(a.pnl_est),children:s.jsx("strong",{children:a.pnl_est!=null?"$"+a.pnl_est.toFixed(4):"-"})}),s.jsx("td",{className:"text-right",children:a.scales||0}),s.jsx("td",{children:a.duration||"-"})]},a.coin))})]})})]}),t&&s.jsx(cc,{trade:r,orders:i,onClose:c})]})}function Wf({blacklist:e}){return s.jsxs("section",{className:"card",id:"bl-card",children:[s.jsx("h2",{children:"⛔ 黑名单"}),s.jsx("div",{className:"stats-row",id:"bl-body",children:!e||e.length===0?s.jsx("span",{className:"text-dim",children:"暂无"}):e.map((t,n)=>{const r=t.remaining_sec||0,l=r>0?`${Math.floor(r/60)}m${r%60}s`:"";return s.jsxs("span",{className:"bl-item",title:`${t.coin}: ${l}`,children:["⛔ ",t.coin,l?` (${l})`:""]},n)})})]})}function Vf({coins:e,prices:t,getPrevPrice:n,priceClass:r,pricesAge:l}){return s.jsxs("section",{className:"card",id:"prices-card",children:[s.jsxs("h2",{children:["💰 实时价格 ",s.jsx("span",{className:"text-dim",style:{fontSize:11},children:l})]}),s.jsx("div",{className:"table-wrap",children:s.jsxs("table",{id:"price-table",children:[s.jsx("thead",{children:s.jsxs("tr",{children:[s.jsx("th",{children:"币种"}),s.jsx("th",{children:"HyperLiquid"}),s.jsx("th",{children:"Bitget"}),s.jsx("th",{children:"Binance"}),s.jsx("th",{children:"OKX"}),s.jsx("th",{children:"毛价差"}),s.jsx("th",{children:"BG→HL净利"}),s.jsx("th",{children:"HL→BG净利"})]})}),s.jsx("tbody",{id:"price-body",children:e.length===0?s.jsx("tr",{children:s.jsx("td",{colSpan:"8",className:"loading",children:"等待数据..."})}):e.map(i=>{const o=t.find(x=>x.coin===i);if(!o)return s.jsxs("tr",{children:[s.jsx("td",{children:i}),s.jsx("td",{className:"text-dim",children:"-"}),s.jsx("td",{className:"text-dim",children:"-"}),s.jsx("td",{className:"text-dim",children:"-"}),s.jsx("td",{className:"text-dim",children:"-"}),s.jsx("td",{className:"text-dim",children:"-"})]},i);const u=ac.map(x=>{const w=o[x],R=n(i,x),p=R?r(R,w||0):"";return s.jsx("td",{className:p,children:Vi(w)},x)}),c=o.bg_hl_spread,a=c>.2?"text-green":c<-.2?"text-red":"",f=o.net_bg_to_hl,d=o.net_hl_to_bg,g=f!=null?Qi(f):"",v=d!=null?Qi(d):"";return s.jsxs("tr",{children:[s.jsx("td",{children:s.jsx("strong",{children:i})}),u,s.jsx("td",{className:a,children:c!=null?c.toFixed(4)+"%":"-"}),s.jsx("td",{className:g,children:f!=null?f.toFixed(2)+"%":"-"}),s.jsx("td",{className:v,children:d!=null?d.toFixed(2)+"%":"-"})]},i)})})]})})]})}function Qf({opps:e}){return s.jsxs("section",{className:"card",id:"arb-card",children:[s.jsx("h2",{children:"🎯 套利机会 (BG↔HL)"}),s.jsx("div",{className:"table-wrap",children:s.jsxs("table",{id:"arb-table",children:[s.jsx("thead",{children:s.jsxs("tr",{children:[s.jsx("th",{children:"币种"}),s.jsx("th",{children:"方向"}),s.jsx("th",{children:"买价"}),s.jsx("th",{children:"卖价"}),s.jsx("th",{children:"净利%"})]})}),s.jsx("tbody",{id:"arb-body",children:!e||e.length===0?s.jsx("tr",{children:s.jsx("td",{colSpan:"5",className:"text-dim",children:"暂无套利机会"})}):e.map((t,n)=>{const r=t.net_profit>.1?"text-green":t.net_profit>.05?"text-yellow":"";return s.jsxs("tr",{children:[s.jsx("td",{children:t.coin}),s.jsx("td",{children:t.direction}),s.jsx("td",{className:"text-right",children:Vi(t.buy_price)}),s.jsx("td",{className:"text-right",children:Vi(t.sell_price)}),s.jsx("td",{className:"text-right "+r,children:s.jsx("strong",{children:(t.net_profit||0).toFixed(4)})})]},n)})})]})})]})}function Kf({trades:e,onRefresh:t}){const[n,r]=z.useState(null),[l,i]=z.useState([]),[o,u]=z.useState(!1);function c(f){u(!0),r(null),i([]),fetch("/api/trade/"+f).then(d=>d.json()).then(d=>{r(d.trade),i(d.orders||[])}).catch(()=>{r({ID:f})})}function a(){u(!1)}return z.useEffect(()=>{if(!o)return;function f(d){d.key==="Escape"&&a()}return document.addEventListener("keydown",f),()=>document.removeEventListener("keydown",f)},[o]),s.jsxs(s.Fragment,{children:[s.jsxs("section",{className:"card card-wide",id:"trades-card",children:[s.jsx("h2",{children:"📋 历史交易"}),s.jsx("div",{className:"table-wrap",children:s.jsxs("table",{id:"trades-table",children:[s.jsx("thead",{children:s.jsxs("tr",{children:[s.jsx("th",{children:"时间"}),s.jsx("th",{children:"币种"}),s.jsx("th",{children:"方向"}),s.jsx("th",{children:"入价差"}),s.jsx("th",{children:"出价差"}),s.jsx("th",{children:"净利%"}),s.jsx("th",{children:"结果"}),s.jsx("th",{children:"原因"})]})}),s.jsx("tbody",{id:"trades-body",children:e.length===0?s.jsx("tr",{children:s.jsx("td",{colSpan:"8",className:"text-dim",children:"暂无交易记录"})}):e.slice(0,20).map(f=>{const d=f.NetPnl>0?"text-green":f.NetPnl<0?"text-red":"",g=f.Convergence==="价差收敛"?"text-green":f.Convergence==="价差发散"?"text-red":"text-yellow";return s.jsxs("tr",{className:"trade-row",onClick:()=>c(f.ID),children:[s.jsx("td",{className:"text-dim",children:f.ClosedAt?new Date(f.ClosedAt).toLocaleTimeString("zh-CN",{hour12:!1}):"-"}),s.jsx("td",{children:s.jsx("strong",{children:f.Coin})}),s.jsx("td",{children:f.Direction}),s.jsx("td",{className:"text-right",children:f.EntrySpread!=null?f.EntrySpread.toFixed(4):"-"}),s.jsx("td",{className:"text-right",children:f.ExitSpread!=null?f.ExitSpread.toFixed(4):"-"}),s.jsx("td",{className:"text-right "+d,children:s.jsx("strong",{children:f.NetPnl!=null?f.NetPnl.toFixed(4)+"%":"-"})}),s.jsx("td",{className:g,children:f.Convergence||"-"}),s.jsx("td",{children:f.ExitReason||"-"})]},f.ID)})})]})})]}),o&&s.jsx(cc,{trade:n,orders:l,onClose:a})]})}function cc({trade:e,orders:t,onClose:n}){function r(g){g.target===g.currentTarget&&n()}if(!e)return s.jsx("div",{className:"modal-overlay",onClick:r,children:s.jsxs("div",{className:"modal-content",children:[s.jsxs("div",{className:"modal-header",children:[s.jsx("h2",{children:"📋 交易详情"}),s.jsx("button",{className:"modal-close",onClick:n,children:"✕"})]}),s.jsx("div",{id:"trade-detail-body",children:s.jsx("div",{className:"loading",children:"加载中..."})})]})});const l=new Date(e.OpenedAt),i=e.ClosedAt?new Date(e.ClosedAt):null,o=i?Math.round((i-l)/1e3)+"s":"-",u=e.NetPnl>0?"text-green":e.NetPnl<0?"text-red":"",c=[...t||[]].sort((g,v)=>{const x=(g.Exchange||"").localeCompare(v.Exchange||"");return x!==0?x:new Date(g.CreatedAt)-new Date(v.CreatedAt)}),a=e.AmountUSD&&e.LongPnl!=null?e.AmountUSD*e.LongPnl/100:null,f=e.AmountUSD&&e.ShortPnl!=null?e.AmountUSD*e.ShortPnl/100:null,d=e.AmountUSD&&e.NetPnl!=null?2*e.AmountUSD*e.NetPnl/100:null;return s.jsx("div",{className:"modal-overlay",onClick:r,children:s.jsxs("div",{className:"modal-content",children:[s.jsxs("div",{className:"modal-header",children:[s.jsx("h2",{children:"📋 交易详情"}),s.jsx("button",{className:"modal-close",onClick:n,children:"✕"})]}),s.jsxs("div",{id:"trade-detail-body",children:[s.jsxs("div",{className:"detail-grid",children:[s.jsxs("div",{className:"detail-section",children:[s.jsx("h3",{children:"概览"}),s.jsxs("div",{className:"detail-row",children:[s.jsx("span",{className:"label",children:"币种"}),s.jsxs("span",{className:"value",children:[s.jsx("strong",{children:e.Coin}),"/USDT"]})]}),s.jsxs("div",{className:"detail-row",children:[s.jsx("span",{className:"label",children:"方向"}),s.jsx("span",{className:"value",children:e.Direction||"-"})]}),s.jsxs("div",{className:"detail-row",children:[s.jsx("span",{className:"label",children:"状态"}),s.jsx("span",{className:"value",children:e.Status==="closed"?"已平仓":e.Status})]}),s.jsxs("div",{className:"detail-row",children:[s.jsx("span",{className:"label",children:"加仓次数"}),s.jsxs("span",{className:"value",children:[e.ScaleCount||0," 次"]})]}),s.jsxs("div",{className:"detail-row",children:[s.jsx("span",{className:"label",children:"总规模"}),s.jsxs("span",{className:"value",children:["$",(e.AmountUSD||0).toFixed(0)]})]})]}),s.jsxs("div",{className:"detail-section",children:[s.jsx("h3",{children:"时间"}),s.jsxs("div",{className:"detail-row",children:[s.jsx("span",{className:"label",children:"开仓"}),s.jsx("span",{className:"value",children:l.toLocaleString("zh-CN",{hour12:!1})})]}),s.jsxs("div",{className:"detail-row",children:[s.jsx("span",{className:"label",children:"平仓"}),s.jsx("span",{className:"value",children:i?i.toLocaleString("zh-CN",{hour12:!1}):"-"})]}),s.jsxs("div",{className:"detail-row",children:[s.jsx("span",{className:"label",children:"持仓时长"}),s.jsx("span",{className:"value",children:o})]})]}),s.jsxs("div",{className:"detail-section",children:[s.jsx("h3",{children:"价差"}),s.jsxs("div",{className:"detail-row",children:[s.jsx("span",{className:"label",children:"入场价差"}),s.jsx("span",{className:"value",children:e.EntrySpread!=null?e.EntrySpread.toFixed(4)+"%":"-"})]}),s.jsxs("div",{className:"detail-row",children:[s.jsx("span",{className:"label",children:"出场价差"}),s.jsx("span",{className:"value",children:e.ExitSpread!=null?e.ExitSpread.toFixed(4)+"%":"-"})]}),s.jsxs("div",{className:"detail-row",children:[s.jsx("span",{className:"label",children:"收敛情况"}),s.jsx("span",{className:"value "+(e.Convergence==="价差收敛"?"text-green":e.Convergence==="价差发散"?"text-red":""),children:e.Convergence||"-"})]}),s.jsxs("div",{className:"detail-row",children:[s.jsx("span",{className:"label",children:"平仓原因"}),s.jsx("span",{className:"value",children:e.ExitReason||"-"})]})]}),s.jsxs("div",{className:"detail-section",children:[s.jsx("h3",{children:"手续费"}),s.jsxs("div",{className:"detail-row",children:[s.jsx("span",{className:"label",children:"开仓费"}),s.jsxs("span",{className:"value",children:["$",(e.FeeEntry||0).toFixed(4)]})]}),s.jsxs("div",{className:"detail-row",children:[s.jsx("span",{className:"label",children:"平仓费"}),s.jsxs("span",{className:"value",children:["$",(e.FeeExit||0).toFixed(4)]})]}),s.jsxs("div",{className:"detail-row",children:[s.jsx("span",{className:"label",children:"总手续费"}),s.jsxs("span",{className:"value",children:["$",((e.FeeEntry||0)+(e.FeeExit||0)).toFixed(4)]})]})]}),s.jsxs("div",{className:"detail-section",children:[s.jsxs("h3",{children:["多仓 ",e.LongExchange||"-"]}),s.jsxs("div",{className:"detail-row",children:[s.jsx("span",{className:"label",children:"入场价"}),s.jsxs("span",{className:"value",children:["$",(e.LongEntry||0).toFixed(6)]})]}),s.jsxs("div",{className:"detail-row",children:[s.jsx("span",{className:"label",children:"出场价"}),s.jsxs("span",{className:"value",children:["$",(e.LongExit||0).toFixed(6)]})]}),s.jsxs("div",{className:"detail-row",children:[s.jsx("span",{className:"label",children:"盈亏"}),s.jsxs("span",{className:"value "+(e.LongPnl>0?"text-green":e.LongPnl<0?"text-red":""),children:[e.LongPnl!=null?e.LongPnl.toFixed(4)+"%":"-"," ",a!=null?s.jsxs("span",{style:{fontSize:11,opacity:.8},children:["($",a.toFixed(4),")"]}):null]})]})]}),s.jsxs("div",{className:"detail-section",children:[s.jsxs("h3",{children:["空仓 ",e.ShortExchange||"-"]}),s.jsxs("div",{className:"detail-row",children:[s.jsx("span",{className:"label",children:"入场价"}),s.jsxs("span",{className:"value",children:["$",(e.ShortEntry||0).toFixed(6)]})]}),s.jsxs("div",{className:"detail-row",children:[s.jsx("span",{className:"label",children:"出场价"}),s.jsxs("span",{className:"value",children:["$",(e.ShortExit||0).toFixed(6)]})]}),s.jsxs("div",{className:"detail-row",children:[s.jsx("span",{className:"label",children:"盈亏"}),s.jsxs("span",{className:"value "+(e.ShortPnl>0?"text-green":e.ShortPnl<0?"text-red":""),children:[e.ShortPnl!=null?e.ShortPnl.toFixed(4)+"%":"-"," ",f!=null?s.jsxs("span",{style:{fontSize:11,opacity:.8},children:["($",f.toFixed(4),")"]}):null]})]})]}),s.jsxs("div",{className:"detail-section detail-section-full",children:[s.jsx("h3",{children:"净收益"}),s.jsxs("div",{className:"detail-row",style:{fontSize:16},children:[s.jsx("span",{className:"label",children:"总计"}),s.jsxs("span",{className:"value "+u,style:{fontWeight:700},children:[e.NetPnl!=null?e.NetPnl.toFixed(4)+"%":"-"," ",d!=null?s.jsxs("span",{style:{fontSize:12,opacity:.8},children:["($",d.toFixed(4),")"]}):null]})]})]})]}),t.length>0&&s.jsxs("div",{className:"detail-section detail-section-full",style:{borderTop:"1px solid var(--border)"},children:[s.jsxs("h3",{children:["订单明细 (",t.length,")"]}),s.jsxs("table",{className:"detail-orders",children:[s.jsx("thead",{children:s.jsxs("tr",{children:[s.jsx("th",{children:"交易所"}),s.jsx("th",{children:"类型"}),s.jsx("th",{children:"方向"}),s.jsx("th",{children:"价格"}),s.jsx("th",{children:"仓位"}),s.jsx("th",{children:"手续费"}),s.jsx("th",{children:"订单ID"})]})}),s.jsx("tbody",{children:c.map((g,v)=>s.jsxs("tr",{children:[s.jsx("td",{children:g.Exchange}),s.jsx("td",{children:g.Type==="entry"?"开仓":g.Type==="exit"?"平仓":g.Type==="scale"?"加仓":g.Type}),s.jsx("td",{children:g.Side==="buy"?"买":"卖"}),s.jsxs("td",{children:["$",(g.Price||0).toFixed(6)]}),s.jsx("td",{className:"text-right",children:g.Size!=null?Number(g.Size).toFixed(4):"-"}),s.jsx("td",{children:g.Fee!=null?"$"+g.Fee.toFixed(4):"-"}),s.jsx("td",{children:g.OrderID?g.OrderID.substring(0,12)+"...":"-"})]},v))})]})]})]})]})})}function Xf(){const e=z.useRef(null),[t,n]=z.useState([]),[r,l]=z.useState(0);return z.useEffect(()=>{async function i(){try{const a=((await(await fetch("/api/trades?limit=1000")).json()).trades||[]).filter(d=>d.ClosedAt&&d.NetPnl!=null).sort((d,g)=>new Date(d.ClosedAt)-new Date(g.ClosedAt));n(a);const f=a.reduce((d,g)=>d+2*(g.AmountUSD||0)*(g.NetPnl||0)/100,0);l(f)}catch{}}i();const o=setInterval(i,1e4);return()=>clearInterval(o)},[]),z.useEffect(()=>{const i=e.current;if(!i||t.length<2)return;const o=i.parentElement.getBoundingClientRect(),u=window.devicePixelRatio||1,c=o.width,a=o.height;i.width=c*u,i.height=a*u,i.style.width=c+"px",i.style.height=a+"px";const f=i.getContext("2d");f.scale(u,u);const d={top:20,right:20,bottom:35,left:55},g=c-d.left-d.right,v=a-d.top-d.bottom,x=[];let w=0;if(t.length>0){const M=new Date(t[0].ClosedAt).getTime()-1e3;x.push({x:M,y:0})}for(const M of t)w+=2*(M.AmountUSD||0)*(M.NetPnl||0)/100,x.push({x:new Date(M.ClosedAt).getTime(),y:w});const R=x[0].x,p=x[x.length-1].x,h=x.map(M=>M.y),m=Math.min(0,...h),y=Math.max(0,...h),S=Math.max(y-m,.01),_=S*.15,C=M=>d.left+(M-R)/Math.max(p-R,1)*g,E=M=>d.top+v-(M-(m-_))/(S+2*_)*v;f.clearRect(0,0,c,a),f.strokeStyle="rgba(48,54,61,0.5)",f.lineWidth=1,f.font="11px sans-serif",f.fillStyle="#8b949e";const $=5;for(let M=0;M<=$;M++){const de=m-_+(S+2*_)*M/$,fe=E(de);f.beginPath(),f.moveTo(d.left,fe),f.lineTo(c-d.right,fe),f.stroke(),f.fillText("$"+de.toFixed(2),2,fe+4)}if(m<0&&y>0){const M=E(0);f.strokeStyle="rgba(248,81,73,0.3)",f.lineWidth=1,f.setLineDash([4,4]),f.beginPath(),f.moveTo(d.left,M),f.lineTo(c-d.right,M),f.stroke(),f.setLineDash([])}const F=Math.min(6,x.length);for(let M=0;M=0?"#3fb950":"#f85149",f.fill(),f.strokeStyle="#0d1117",f.lineWidth=2,f.stroke(),f.fillStyle="#c9d1d9",f.font="bold 13px sans-serif",f.textAlign="center",f.fillText("$"+Pe.y.toFixed(2),He,Ct-12)},[t]),s.jsxs("section",{className:"card card-wide",id:"pnl-chart-card",children:[s.jsxs("h2",{children:["📈 总PnL成长曲线 ",s.jsx("span",{className:"text-dim",style:{fontSize:11},children:t.length>0?`$${r.toFixed(2)}`:""})]}),s.jsx("div",{className:"chart-container",style:{height:260},children:t.length<1?s.jsx("div",{className:"loading",style:{paddingTop:100},children:"暂无数据..."}):s.jsx("canvas",{ref:e})})]})}function Gf({momentum:e}){const[t,n]=z.useState("score"),[r,l]=z.useState("desc");function i(d){t===d?l(r==="asc"?"desc":"asc"):(n(d),l("desc"))}function o(d){return t!==d?"":r==="asc"?" ▲":" ▼"}const u=[...e].sort((d,g)=>{let v,x;switch(t){case"coin":v=d.coin,x=g.coin;break;case"bg_1s":v=d.bg_1s||0,x=g.bg_1s||0;break;case"bg_5s":v=d.bg_5s||0,x=g.bg_5s||0;break;case"bg_15s":v=d.bg_15s||0,x=g.bg_15s||0;break;case"hl_1s":v=d.hl_1s||0,x=g.hl_1s||0;break;case"hl_5s":v=d.hl_5s||0,x=g.hl_5s||0;break;case"hl_15s":v=d.hl_15s||0,x=g.hl_15s||0;break;case"bn_1s":v=d.bn_1s||0,x=g.bn_1s||0;break;case"bn_5s":v=d.bn_5s||0,x=g.bn_5s||0;break;case"bn_15s":v=d.bn_15s||0,x=g.bn_15s||0;break;case"okx_1s":v=d.okx_1s||0,x=g.okx_1s||0;break;case"okx_5s":v=d.okx_5s||0,x=g.okx_5s||0;break;case"okx_15s":v=d.okx_15s||0,x=g.okx_15s||0;break;default:v=d.score||0,x=g.score||0}return typeof v=="string"?r==="asc"?v.localeCompare(x):x.localeCompare(v):r==="asc"?v-x:x-v});function c(d){switch(d){case"up":return"↑";case"down":return"↓";case"flat":return"→";case"mixed":return"↕";default:return"-"}}function a(d){switch(d){case"up":return"text-green";case"down":return"text-red";case"mixed":return"text-yellow";default:return""}}function f(d){return d==null||d===0?"":d>0?"text-green":"text-red"}return s.jsxs("section",{className:"card card-wide",id:"momentum-card",children:[s.jsx("h2",{children:"⚡ 动量扫描 (价格变动%)"}),s.jsx("div",{className:"table-wrap",style:{maxHeight:400},children:s.jsxs("table",{id:"momentum-table",children:[s.jsx("thead",{children:s.jsxs("tr",{children:[s.jsxs("th",{onClick:()=>i("coin"),style:{cursor:"pointer"},children:["币种",o("coin")]}),s.jsxs("th",{onClick:()=>i("score"),style:{cursor:"pointer"},children:["分数",o("score")]}),s.jsx("th",{children:"方向"}),s.jsxs("th",{onClick:()=>i("bg_1s"),style:{cursor:"pointer"},children:["BG 1s",o("bg_1s")]}),s.jsxs("th",{onClick:()=>i("bg_5s"),style:{cursor:"pointer"},children:["BG 5s",o("bg_5s")]}),s.jsxs("th",{onClick:()=>i("bg_15s"),style:{cursor:"pointer"},children:["BG 15s",o("bg_15s")]}),s.jsxs("th",{onClick:()=>i("hl_1s"),style:{cursor:"pointer"},children:["HL 1s",o("hl_1s")]}),s.jsxs("th",{onClick:()=>i("hl_5s"),style:{cursor:"pointer"},children:["HL 5s",o("hl_5s")]}),s.jsxs("th",{onClick:()=>i("hl_15s"),style:{cursor:"pointer"},children:["HL 15s",o("hl_15s")]}),s.jsxs("th",{onClick:()=>i("bn_1s"),style:{cursor:"pointer"},children:["BN 1s",o("bn_1s")]}),s.jsxs("th",{onClick:()=>i("bn_5s"),style:{cursor:"pointer"},children:["BN 5s",o("bn_5s")]}),s.jsxs("th",{onClick:()=>i("bn_15s"),style:{cursor:"pointer"},children:["BN 15s",o("bn_15s")]}),s.jsxs("th",{onClick:()=>i("okx_1s"),style:{cursor:"pointer"},children:["OKX 1s",o("okx_1s")]}),s.jsxs("th",{onClick:()=>i("okx_5s"),style:{cursor:"pointer"},children:["OKX 5s",o("okx_5s")]}),s.jsxs("th",{onClick:()=>i("okx_15s"),style:{cursor:"pointer"},children:["OKX 15s",o("okx_15s")]})]})}),s.jsx("tbody",{children:u.length===0?s.jsx("tr",{children:s.jsx("td",{colSpan:"15",className:"text-dim",style:{textAlign:"center",padding:20},children:"正在收集动量数据... (需要至少 15 秒数据)"})}):u.slice(0,50).map(d=>s.jsxs("tr",{children:[s.jsx("td",{children:s.jsx("strong",{children:d.coin})}),s.jsxs("td",{className:"text-right",style:{fontWeight:700},children:[d.score.toFixed(4),"%"]}),s.jsx("td",{className:a(d.direction),style:{textAlign:"center",fontSize:18},children:c(d.direction)}),s.jsx("td",{className:"text-right "+f(d.bg_1s),children:d.bg_1s!=null?d.bg_1s.toFixed(3)+"%":"-"}),s.jsx("td",{className:"text-right "+f(d.bg_5s),children:d.bg_5s!=null?d.bg_5s.toFixed(3)+"%":"-"}),s.jsx("td",{className:"text-right "+f(d.bg_15s),children:d.bg_15s!=null?d.bg_15s.toFixed(3)+"%":"-"}),s.jsx("td",{className:"text-right "+f(d.hl_1s),children:d.hl_1s!=null?d.hl_1s.toFixed(3)+"%":"-"}),s.jsx("td",{className:"text-right "+f(d.hl_5s),children:d.hl_5s!=null?d.hl_5s.toFixed(3)+"%":"-"}),s.jsx("td",{className:"text-right "+f(d.hl_15s),children:d.hl_15s!=null?d.hl_15s.toFixed(3)+"%":"-"}),s.jsx("td",{className:"text-right "+f(d.bn_1s),children:d.bn_1s!=null?d.bn_1s.toFixed(3)+"%":"-"}),s.jsx("td",{className:"text-right "+f(d.bn_5s),children:d.bn_5s!=null?d.bn_5s.toFixed(3)+"%":"-"}),s.jsx("td",{className:"text-right "+f(d.bn_15s),children:d.bn_15s!=null?d.bn_15s.toFixed(3)+"%":"-"}),s.jsx("td",{className:"text-right "+f(d.okx_1s),children:d.okx_1s!=null?d.okx_1s.toFixed(3)+"%":"-"}),s.jsx("td",{className:"text-right "+f(d.okx_5s),children:d.okx_5s!=null?d.okx_5s.toFixed(3)+"%":"-"}),s.jsx("td",{className:"text-right "+f(d.okx_15s),children:d.okx_15s!=null?d.okx_15s.toFixed(3)+"%":"-"})]},d.coin))})]})})]})}function Yf({data:e}){function t(i){switch(i){case"rising":return"↑ 上涨";case"falling":return"↓ 下跌";default:return"− 中性"}}function n(i){switch(i){case"rising":return"text-green";case"falling":return"text-red";default:return"text-dim"}}function r(i){return i==="up"?"text-green":"text-red"}function l(i){return i==null||i===0?"":i>0?"text-green":"text-red"}return s.jsxs("section",{className:"card card-wide",id:"cm-card",children:[s.jsx("h2",{children:"📊 累积变动 (1min 共识)"}),s.jsx("div",{className:"table-wrap",style:{maxHeight:300},children:s.jsxs("table",{id:"cm-table",children:[s.jsx("thead",{children:s.jsxs("tr",{children:[s.jsx("th",{children:"币种"}),s.jsx("th",{children:"状态"}),s.jsx("th",{children:"方向"}),s.jsx("th",{children:"分数"}),s.jsx("th",{children:"均值%"}),s.jsx("th",{children:"一致"}),s.jsx("th",{children:"BG 1m"}),s.jsx("th",{children:"HL 1m"}),s.jsx("th",{children:"BN 1m"}),s.jsx("th",{children:"OKX 1m"}),s.jsx("th",{children:"BG 5m"}),s.jsx("th",{children:"HL 5m"}),s.jsx("th",{children:"BN 5m"}),s.jsx("th",{children:"OKX 5m"}),s.jsx("th",{colSpan:4,style:{borderLeft:"2px solid var(--border)"},children:"1h 趋势"}),s.jsx("th",{children:"BG 1h"}),s.jsx("th",{children:"HL 1h"}),s.jsx("th",{children:"BN 1h"}),s.jsx("th",{children:"OKX 1h"})]})}),s.jsx("tbody",{children:!e||e.length===0?s.jsx("tr",{children:s.jsx("td",{colSpan:"19",className:"text-dim",style:{textAlign:"center",padding:20},children:"等待累积数据... (需要至少 1 分钟数据)"})}):e.slice(0,30).map(i=>s.jsxs("tr",{className:n(i.state),children:[s.jsx("td",{children:s.jsx("strong",{children:i.coin})}),s.jsx("td",{children:t(i.state)}),s.jsx("td",{className:r(i.direction),style:{textAlign:"center",fontSize:16},children:i.direction==="up"?"↑":"↓"}),s.jsx("td",{className:"text-right",style:{fontWeight:700},children:(i.score||0).toFixed(2)}),s.jsxs("td",{className:"text-right",children:[(i.avg_change||0).toFixed(3),"%"]}),s.jsxs("td",{className:"text-right",children:[i.ex_agree||0,"/",i.ex_total||0]}),s.jsx("td",{className:"text-right "+l(i.bg_1m),children:i.bg_1m!=null?i.bg_1m.toFixed(3)+"%":"-"}),s.jsx("td",{className:"text-right "+l(i.hl_1m),children:i.hl_1m!=null?i.hl_1m.toFixed(3)+"%":"-"}),s.jsx("td",{className:"text-right "+l(i.bn_1m),children:i.bn_1m!=null?i.bn_1m.toFixed(3)+"%":"-"}),s.jsx("td",{className:"text-right "+l(i.okx_1m),children:i.okx_1m!=null?i.okx_1m.toFixed(3)+"%":"-"}),s.jsx("td",{className:"text-right "+l(i.bg_5m),children:i.bg_5m!=null?i.bg_5m.toFixed(3)+"%":"-"}),s.jsx("td",{className:"text-right "+l(i.hl_5m),children:i.hl_5m!=null?i.hl_5m.toFixed(3)+"%":"-"}),s.jsx("td",{className:"text-right "+l(i.bn_5m),children:i.bn_5m!=null?i.bn_5m.toFixed(3)+"%":"-"}),s.jsx("td",{className:"text-right "+l(i.okx_5m),children:i.okx_5m!=null?i.okx_5m.toFixed(3)+"%":"-"}),s.jsxs("td",{className:"text-right "+r(i.direction),style:{fontWeight:600,borderLeft:"2px solid var(--border)"},children:[(i.avg_1h||0).toFixed(2),"%"]}),s.jsx("td",{className:"text-right "+l(i.bg_1h),children:i.bg_1h!=null?i.bg_1h.toFixed(2)+"%":"-"}),s.jsx("td",{className:"text-right "+l(i.hl_1h),children:i.hl_1h!=null?i.hl_1h.toFixed(2)+"%":"-"}),s.jsx("td",{className:"text-right "+l(i.bn_1h),children:i.bn_1h!=null?i.bn_1h.toFixed(2)+"%":"-"}),s.jsx("td",{className:"text-right "+l(i.okx_1h),children:i.okx_1h!=null?i.okx_1h.toFixed(2)+"%":"-"})]},i.coin))})]})})]})}function Zf({history:e}){function t(i){switch(i){case"rising":return"↑ 上涨";case"falling":return"↓ 下跌";default:return"− 中性"}}function n(i){switch(i){case"rising":return"text-green";case"falling":return"text-red";default:return"text-dim"}}function r(i){return i==="up"?"text-green":"text-red"}function l(i){return i==null||i===0?"":i>0?"text-green":"text-red"}return s.jsxs("section",{className:"card card-wide",id:"cm-history-card",children:[s.jsx("h2",{children:"📋 累积变动事件记录"}),s.jsx("div",{className:"table-wrap",style:{maxHeight:400},children:s.jsxs("table",{id:"cm-history-table",children:[s.jsx("thead",{children:s.jsxs("tr",{children:[s.jsx("th",{children:"时间"}),s.jsx("th",{children:"币种"}),s.jsx("th",{children:"转换"}),s.jsx("th",{children:"方向"}),s.jsx("th",{children:"分数"}),s.jsx("th",{children:"均值%"}),s.jsx("th",{children:"一致"}),s.jsx("th",{children:"BG 1m"}),s.jsx("th",{children:"HL 1m"}),s.jsx("th",{children:"BN 1m"}),s.jsx("th",{children:"OKX 1m"}),s.jsx("th",{children:"BG 5m"}),s.jsx("th",{children:"HL 5m"}),s.jsx("th",{children:"BN 5m"}),s.jsx("th",{children:"OKX 5m"})]})}),s.jsx("tbody",{children:e.length===0?s.jsx("tr",{children:s.jsx("td",{colSpan:"15",className:"text-dim",style:{textAlign:"center",padding:20},children:"暂无累积变动事件记录"})}):e.slice(0,100).map((i,o)=>s.jsxs("tr",{children:[s.jsx("td",{className:"text-dim",children:i.created_at?new Date(i.created_at).toLocaleTimeString("zh-CN",{hour12:!1}):"-"}),s.jsx("td",{children:s.jsx("strong",{children:i.coin})}),s.jsxs("td",{className:n(i.new_state),children:[i.prev_state," → ",t(i.new_state)]}),s.jsx("td",{className:r(i.direction),style:{textAlign:"center",fontSize:16},children:i.direction==="up"?"↑":"↓"}),s.jsx("td",{className:"text-right",children:(i.score||0).toFixed(2)}),s.jsxs("td",{className:"text-right",children:[(i.avg_change||0).toFixed(3),"%"]}),s.jsxs("td",{className:"text-right",children:[i.ex_agree||0,"/",i.ex_total||0]}),s.jsx("td",{className:"text-right "+l(i.bg_1m),children:i.bg_1m!=null?i.bg_1m.toFixed(3)+"%":"-"}),s.jsx("td",{className:"text-right "+l(i.hl_1m),children:i.hl_1m!=null?i.hl_1m.toFixed(3)+"%":"-"}),s.jsx("td",{className:"text-right "+l(i.bn_1m),children:i.bn_1m!=null?i.bn_1m.toFixed(3)+"%":"-"}),s.jsx("td",{className:"text-right "+l(i.okx_1m),children:i.okx_1m!=null?i.okx_1m.toFixed(3)+"%":"-"}),s.jsx("td",{className:"text-right "+l(i.bg_5m),children:i.bg_5m!=null?i.bg_5m.toFixed(3)+"%":"-"}),s.jsx("td",{className:"text-right "+l(i.hl_5m),children:i.hl_5m!=null?i.hl_5m.toFixed(3)+"%":"-"}),s.jsx("td",{className:"text-right "+l(i.bn_5m),children:i.bn_5m!=null?i.bn_5m.toFixed(3)+"%":"-"}),s.jsx("td",{className:"text-right "+l(i.okx_5m),children:i.okx_5m!=null?i.okx_5m.toFixed(3)+"%":"-"})]},(i.id||o)+"-cm"))})]})})]})}function Jf({filterData:e}){const t=e.filter(a=>a.passes_filter).length,n=e.filter(a=>a.signal_score>=80).length,r=e.filter(a=>a.signal_score>=50&&a.signal_score<80).length,l=e.filter(a=>a.fresh_anomaly).length;let i=`高分${n} 中分${r}`;l>0?(i+=` | ${l}币异动中`,t>0&&(i+=` → ${t}通过!`)):i+=" | 等待异动信号";function o(a){return a==null?"":a>=80?"text-green":a>=50?"text-yellow":"text-dim"}function u(a){return a==null||a<=1.5?"":a>3?"text-red":"text-orange"}function c(a){return a==null||a===0?"":a>0?"text-green":"text-red"}return s.jsxs("section",{className:"card card-wide",id:"trend-filter-card",children:[s.jsxs("h2",{children:["趋势过滤 (",t,"通过 / ",e.length,") ",s.jsx("span",{className:"text-dim",style:{fontSize:12,fontWeight:400},children:i})]}),s.jsx("div",{className:"table-wrap",style:{maxHeight:400},children:s.jsxs("table",{id:"trend-filter-table",children:[s.jsx("thead",{children:s.jsxs("tr",{children:[s.jsx("th",{children:"币种"}),s.jsx("th",{children:"分数"}),s.jsx("th",{children:"24h范围"}),s.jsx("th",{children:"基线"}),s.jsx("th",{children:"1h范围"}),s.jsx("th",{children:"成交量比"}),s.jsx("th",{children:"1h变化"}),s.jsx("th",{children:"EMA52"}),s.jsx("th",{children:"EMA斜率"}),s.jsx("th",{children:"现价"}),s.jsx("th",{children:"> EMA"}),s.jsx("th",{children:"安静24h"}),s.jsx("th",{children:"安静1h"}),s.jsx("th",{children:"异动"}),s.jsx("th",{children:"更新于"})]})}),s.jsx("tbody",{children:e.length===0?s.jsx("tr",{children:s.jsx("td",{colSpan:"15",className:"text-dim",style:{textAlign:"center",padding:20},children:"等待K线数据..."})}):e.map(a=>s.jsxs("tr",{className:a.passes_filter?"filter-pass":"",children:[s.jsx("td",{children:s.jsx("strong",{children:a.coin})}),s.jsx("td",{className:"text-right "+o(a.signal_score),style:{fontWeight:700},children:a.signal_score!=null?a.signal_score.toFixed(0):"-"}),s.jsx("td",{className:"text-right "+(a.quiet_24h?"text-green":""),children:a.range_24h!=null?a.range_24h.toFixed(2)+"%":"-"}),s.jsx("td",{className:"text-right text-dim",children:a.vol_baseline!=null?a.vol_baseline.toFixed(2)+"%":"-"}),s.jsx("td",{className:"text-right "+(a.quiet_1h?"text-green":""),children:a.range_1h!=null?a.range_1h.toFixed(2)+"%":"-"}),s.jsx("td",{className:"text-right "+u(a.volume_ratio),children:a.volume_ratio!=null?a.volume_ratio.toFixed(2)+"x":"-"}),s.jsx("td",{className:"text-right "+(a.change_1h>0?"text-green":a.change_1h<0?"text-red":""),children:a.change_1h!=null?(a.change_1h>0?"+":"")+a.change_1h.toFixed(2)+"%":"-"}),s.jsx("td",{className:"text-right",children:a.ema_52?a.ema_52.toFixed(4):"-"}),s.jsx("td",{className:"text-right "+c(a.ema_slope),children:a.ema_slope!=null?(a.ema_slope>0?"+":"")+a.ema_slope.toFixed(3)+"%":"-"}),s.jsx("td",{className:"text-right",children:a.current_price?a.current_price.toFixed(4):"-"}),s.jsx("td",{className:a.price_above_ema?"text-green":"text-red",children:a.price_above_ema!=null?a.price_above_ema?"↑":"↓":"-"}),s.jsx("td",{className:a.quiet_24h?"text-green":"text-dim",children:a.quiet_24h!=null?a.quiet_24h?"✓":"✗":"-"}),s.jsx("td",{className:a.quiet_1h?"text-green":"text-dim",children:a.quiet_1h!=null?a.quiet_1h?"✓":"✗":"-"}),s.jsx("td",{className:a.fresh_anomaly?"text-orange":"text-dim",children:a.fresh_anomaly!=null&&a.fresh_anomaly?"⚠":"-"}),s.jsx("td",{className:"text-dim",children:a.last_updated?new Date(a.last_updated).toLocaleTimeString("zh-CN",{hour12:!1}):"-"})]},a.coin))})]})})]})}function qf({signals:e}){const t=e.filter(r=>r.category==="full"),n=t.filter(r=>r.type==="enter").length;return s.jsxs("section",{className:"card card-wide",id:"trend-signal-card",children:[s.jsxs("h2",{children:["完整信号 (异动+分数≥70) ",n>0&&s.jsxs("span",{className:"text-green",style:{fontSize:12,fontWeight:400,marginLeft:8},children:["共",n,"条"]})]}),s.jsx("div",{className:"table-wrap",style:{maxHeight:350},children:s.jsxs("table",{id:"trend-signal-table",children:[s.jsx("thead",{children:s.jsxs("tr",{children:[s.jsx("th",{children:"时间"}),s.jsx("th",{children:"币种"}),s.jsx("th",{children:"类型"}),s.jsx("th",{children:"分数"}),s.jsx("th",{children:"价格"}),s.jsx("th",{children:"成交量比"}),s.jsx("th",{children:"EMA斜率"}),s.jsx("th",{children:"趋势状态"})]})}),s.jsx("tbody",{children:t.length===0?s.jsx("tr",{children:s.jsx("td",{colSpan:"8",className:"text-dim",style:{textAlign:"center",padding:20},children:"等待完整信号... (FreshAnomaly + 分数≥70)"})}):t.slice(0,50).map((r,l)=>{const i=r.type==="enter"?"signal-enter":"signal-exit",o=r.timestamp?new Date(r.timestamp).toLocaleTimeString("zh-CN",{hour12:!1}):"-";return s.jsxs("tr",{className:i,children:[s.jsx("td",{className:"text-dim",children:o}),s.jsx("td",{children:s.jsx("strong",{children:r.coin})}),s.jsx("td",{className:r.type==="enter"?"text-green":"text-dim",style:{fontWeight:600},children:r.type==="enter"?"开":"关"}),s.jsx("td",{className:"text-right",style:{fontWeight:700},children:r.signal_score!=null?r.signal_score.toFixed(0):"-"}),s.jsx("td",{className:"text-right",children:r.price?r.price.toFixed(4):"-"}),s.jsx("td",{className:"text-right",children:r.volume_ratio!=null?r.volume_ratio.toFixed(2)+"x":"-"}),s.jsx("td",{className:"text-right",children:r.ema_slope!=null?(r.ema_slope>0?"+":"")+r.ema_slope.toFixed(3)+"%":"-"}),s.jsx("td",{className:"text-dim",children:r.state||"-"})]},"full-"+r.timestamp+"-"+r.coin+"-"+l)})})]})})]})}function bf({signals:e}){const t=e.filter(r=>r.category==="high"),n=t.filter(r=>r.type==="enter").length;return s.jsxs("section",{className:"card card-wide",id:"high-score-card",children:[s.jsxs("h2",{children:["高分信号 (分数≥90) ",n>0&&s.jsxs("span",{className:"text-green",style:{fontSize:12,fontWeight:400,marginLeft:8},children:["共",n,"条"]})]}),s.jsx("div",{className:"table-wrap",style:{maxHeight:350},children:s.jsxs("table",{id:"high-score-table",children:[s.jsx("thead",{children:s.jsxs("tr",{children:[s.jsx("th",{children:"时间"}),s.jsx("th",{children:"币种"}),s.jsx("th",{children:"类型"}),s.jsx("th",{children:"分数"}),s.jsx("th",{children:"价格"}),s.jsx("th",{children:"成交量比"}),s.jsx("th",{children:"EMA斜率"}),s.jsx("th",{children:"趋势状态"})]})}),s.jsx("tbody",{children:t.length===0?s.jsx("tr",{children:s.jsx("td",{colSpan:"8",className:"text-dim",style:{textAlign:"center",padding:20},children:"等待高分信号... (分数≥90)"})}):t.slice(0,50).map((r,l)=>{const i=r.type==="enter"?"signal-enter":"signal-exit",o=r.timestamp?new Date(r.timestamp).toLocaleTimeString("zh-CN",{hour12:!1}):"-";return s.jsxs("tr",{className:i,children:[s.jsx("td",{className:"text-dim",children:o}),s.jsx("td",{children:s.jsx("strong",{children:r.coin})}),s.jsx("td",{className:r.type==="enter"?"text-green":"text-dim",style:{fontWeight:600},children:r.type==="enter"?"开":"关"}),s.jsx("td",{className:"text-right",style:{fontWeight:700},children:r.signal_score!=null?r.signal_score.toFixed(0):"-"}),s.jsx("td",{className:"text-right",children:r.price?r.price.toFixed(4):"-"}),s.jsx("td",{className:"text-right",children:r.volume_ratio!=null?r.volume_ratio.toFixed(2)+"x":"-"}),s.jsx("td",{className:"text-right",children:r.ema_slope!=null?(r.ema_slope>0?"+":"")+r.ema_slope.toFixed(3)+"%":"-"}),s.jsx("td",{className:"text-dim",children:r.state||"-"})]},"high-"+r.timestamp+"-"+r.coin+"-"+l)})})]})})]})}Jl.createRoot(document.getElementById("root")).render(s.jsx(Ec.StrictMode,{children:s.jsx(Af,{})})); diff --git a/frontend/dist/assets/index-CDE5zNyv.css b/frontend/dist/assets/index-CDE5zNyv.css deleted file mode 100644 index 621ec7c..0000000 --- a/frontend/dist/assets/index-CDE5zNyv.css +++ /dev/null @@ -1 +0,0 @@ -:root{--bg: #0d1117;--card: #161b22;--border: #30363d;--text: #c9d1d9;--text-dim: #8b949e;--accent: #58a6ff;--green: #3fb950;--red: #f85149;--yellow: #d29922;--blue: #58a6ff}*{margin:0;padding:0;box-sizing:border-box}body{font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,Arial,sans-serif;background:var(--bg);color:var(--text);font-size:14px;line-height:1.5;min-height:100vh}#app{max-width:1440px;margin:0 auto;padding:16px}header{display:flex;justify-content:space-between;align-items:center;padding:12px 16px;background:var(--card);border:1px solid var(--border);border-radius:8px;margin-bottom:16px}header h1{font-size:18px;font-weight:600}.header-meta{display:flex;align-items:center;gap:8px;font-size:13px;color:var(--text-dim)}.sep{color:var(--border)}.status-offline{color:var(--red)}.status-online{color:var(--green)}.grid{display:grid;grid-template-columns:1fr 1fr;gap:12px}.card-wide{grid-column:1 / -1}.card{background:var(--card);border:1px solid var(--border);border-radius:8px;padding:12px}.card h2{font-size:14px;font-weight:600;color:var(--text-dim);margin-bottom:10px;padding-bottom:8px;border-bottom:1px solid var(--border)}.stats-row{display:flex;gap:16px;flex-wrap:wrap}.stat{display:flex;flex-direction:column;align-items:center;min-width:60px}.stat label{font-size:11px;color:var(--text-dim);margin-bottom:2px}.stat span{font-size:20px;font-weight:700}.pct-green{color:var(--green)}.pct-red{color:var(--red)}.pct-gray{color:var(--text-dim)}.pct-yellow{color:var(--yellow)}.pct-blue{color:var(--blue)}#conn-detail{font-size:11px;white-space:nowrap}.table-wrap{overflow-x:auto;max-height:320px;overflow-y:auto}table{width:100%;border-collapse:collapse;font-size:13px}th{text-align:left;padding:6px 8px;color:var(--text-dim);font-weight:500;font-size:11px;text-transform:uppercase;letter-spacing:.5px;position:sticky;top:0;background:var(--card);border-bottom:1px solid var(--border)}td{padding:5px 8px;border-bottom:1px solid rgba(48,54,61,.5);white-space:nowrap}tr:hover td{background:#58a6ff0d}.trade-row{cursor:pointer}.loading{text-align:center;color:var(--text-dim);padding:20px!important}.text-green{color:var(--green)}.text-red{color:var(--red)}.text-yellow{color:var(--yellow)}.text-dim{color:var(--text-dim)}.text-right{text-align:right}::-webkit-scrollbar{width:6px;height:6px}::-webkit-scrollbar-track{background:transparent}::-webkit-scrollbar-thumb{background:var(--border);border-radius:3px}::-webkit-scrollbar-thumb:hover{background:#484f58}@media (max-width: 768px){.grid{grid-template-columns:1fr}header{flex-direction:column;gap:8px}.stats-row{justify-content:center}}#bl-body{display:flex;gap:8px;flex-wrap:wrap}.bl-item{background:#f851491a;border:1px solid rgba(248,81,73,.3);border-radius:4px;padding:4px 10px;font-size:12px;color:var(--red);cursor:default}.modal-overlay{position:fixed;top:0;left:0;right:0;bottom:0;background:#000000b3;z-index:1000;display:flex;align-items:flex-start;justify-content:center;padding:40px 16px;overflow-y:auto}.modal-content{background:var(--card);border:1px solid var(--border);border-radius:12px;max-width:700px;width:100%;box-shadow:0 8px 32px #00000080}.modal-header{display:flex;justify-content:space-between;align-items:center;padding:16px 20px;border-bottom:1px solid var(--border)}.modal-header h2{font-size:16px;margin:0;padding:0;border:none;color:var(--text)}.modal-close{background:none;border:none;color:var(--text-dim);font-size:20px;cursor:pointer;padding:4px 8px;border-radius:4px;line-height:1}.modal-close:hover{background:#ffffff1a;color:var(--text)}#trade-detail-body{padding:0}.detail-grid{display:grid;grid-template-columns:1fr 1fr;gap:0}.detail-section{padding:14px 20px;border-bottom:1px solid rgba(48,54,61,.4)}.detail-section:last-child{border-bottom:none}.detail-section-full{grid-column:1 / -1}.detail-section h3{font-size:12px;color:var(--text-dim);font-weight:600;text-transform:uppercase;letter-spacing:.5px;margin-bottom:8px}.detail-row{display:flex;justify-content:space-between;padding:3px 0;font-size:13px}.detail-row .label{color:var(--text-dim)}.detail-row .value{font-weight:500}.detail-orders{width:100%;font-size:12px}.detail-orders th{background:var(--bg);font-size:10px}.detail-orders td{padding:4px 6px}#momentum-card{grid-column:1 / -1}#momentum-table th{cursor:pointer;-webkit-user-select:none;user-select:none}#momentum-table th:hover{color:var(--accent)}#momentum-table td{font-variant-numeric:tabular-nums}#trend-card{grid-column:1 / -1}#trend-table th{-webkit-user-select:none;user-select:none}#trend-table td{font-variant-numeric:tabular-nums}.trend-state{font-weight:600;font-size:12px}.trend-alert{background:#d299220d}.trend-alert:hover td{background:#d299221a!important}.trend-confirmed{background:#3fb95014}.trend-confirmed:hover td{background:#3fb95026!important}.trend-exhausting{background:#8b949e0d}.trend-exhausting:hover td{background:#8b949e1a!important}.text-orange{color:var(--yellow)}#trend-filter-card{grid-column:1 / -1}#trend-filter-table td{font-variant-numeric:tabular-nums}.filter-pass td{background:#3fb9500f}.filter-pass:hover td{background:#3fb9501f!important}.text-blue{color:#58a6ff}#trend-signal-card{grid-column:1 / -1}#trend-signal-table td{font-variant-numeric:tabular-nums}#high-score-card{grid-column:1 / -1}#high-score-table td{font-variant-numeric:tabular-nums}.signal-enter td{background:#3fb95014}.signal-enter:hover td{background:#3fb95026!important}.signal-exit td{background:#8b949e0d}.signal-exit:hover td{background:#8b949e1a!important} diff --git a/frontend/dist/assets/index-Czt_9K6K.js b/frontend/dist/assets/index-Czt_9K6K.js new file mode 100644 index 0000000..b7eddee --- /dev/null +++ b/frontend/dist/assets/index-Czt_9K6K.js @@ -0,0 +1,40 @@ +(function(){const t=document.createElement("link").relList;if(t&&t.supports&&t.supports("modulepreload"))return;for(const l of document.querySelectorAll('link[rel="modulepreload"]'))r(l);new MutationObserver(l=>{for(const i of l)if(i.type==="childList")for(const o of i.addedNodes)o.tagName==="LINK"&&o.rel==="modulepreload"&&r(o)}).observe(document,{childList:!0,subtree:!0});function n(l){const i={};return l.integrity&&(i.integrity=l.integrity),l.referrerPolicy&&(i.referrerPolicy=l.referrerPolicy),l.crossOrigin==="use-credentials"?i.credentials="include":l.crossOrigin==="anonymous"?i.credentials="omit":i.credentials="same-origin",i}function r(l){if(l.ep)return;l.ep=!0;const i=n(l);fetch(l.href,i)}})();function oc(e){return e&&e.__esModule&&Object.prototype.hasOwnProperty.call(e,"default")?e.default:e}var Xu={exports:{}},ol={},Gu={exports:{}},F={};/** + * @license React + * react.production.min.js + * + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var nr=Symbol.for("react.element"),uc=Symbol.for("react.portal"),sc=Symbol.for("react.fragment"),ac=Symbol.for("react.strict_mode"),cc=Symbol.for("react.profiler"),dc=Symbol.for("react.provider"),fc=Symbol.for("react.context"),pc=Symbol.for("react.forward_ref"),hc=Symbol.for("react.suspense"),mc=Symbol.for("react.memo"),gc=Symbol.for("react.lazy"),Uo=Symbol.iterator;function vc(e){return e===null||typeof e!="object"?null:(e=Uo&&e[Uo]||e["@@iterator"],typeof e=="function"?e:null)}var Yu={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},Zu=Object.assign,Ju={};function pn(e,t,n){this.props=e,this.context=t,this.refs=Ju,this.updater=n||Yu}pn.prototype.isReactComponent={};pn.prototype.setState=function(e,t){if(typeof e!="object"&&typeof e!="function"&&e!=null)throw Error("setState(...): takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,e,t,"setState")};pn.prototype.forceUpdate=function(e){this.updater.enqueueForceUpdate(this,e,"forceUpdate")};function qu(){}qu.prototype=pn.prototype;function Hi(e,t,n){this.props=e,this.context=t,this.refs=Ju,this.updater=n||Yu}var Wi=Hi.prototype=new qu;Wi.constructor=Hi;Zu(Wi,pn.prototype);Wi.isPureReactComponent=!0;var $o=Array.isArray,bu=Object.prototype.hasOwnProperty,Vi={current:null},es={key:!0,ref:!0,__self:!0,__source:!0};function ts(e,t,n){var r,l={},i=null,o=null;if(t!=null)for(r in t.ref!==void 0&&(o=t.ref),t.key!==void 0&&(i=""+t.key),t)bu.call(t,r)&&!es.hasOwnProperty(r)&&(l[r]=t[r]);var s=arguments.length-2;if(s===1)l.children=n;else if(1>>1,Z=_[V];if(0>>1;Vl(M,L))Rl(Te,M)?(_[V]=Te,_[R]=L,V=R):(_[V]=M,_[P]=L,V=P);else if(Rl(Te,L))_[V]=Te,_[R]=L,V=R;else break e}}return z}function l(_,z){var L=_.sortIndex-z.sortIndex;return L!==0?L:_.id-z.id}if(typeof performance=="object"&&typeof performance.now=="function"){var i=performance;e.unstable_now=function(){return i.now()}}else{var o=Date,s=o.now();e.unstable_now=function(){return o.now()-s}}var a=[],c=[],g=1,f=null,m=3,v=!1,w=!1,k=!1,$=typeof setTimeout=="function"?setTimeout:null,p=typeof clearTimeout=="function"?clearTimeout:null,d=typeof setImmediate<"u"?setImmediate:null;typeof navigator<"u"&&navigator.scheduling!==void 0&&navigator.scheduling.isInputPending!==void 0&&navigator.scheduling.isInputPending.bind(navigator.scheduling);function h(_){for(var z=n(c);z!==null;){if(z.callback===null)r(c);else if(z.startTime<=_)r(c),z.sortIndex=z.expirationTime,t(a,z);else break;z=n(c)}}function y(_){if(k=!1,h(_),!w)if(n(a)!==null)w=!0,St(S);else{var z=n(c);z!==null&&_t(y,z.startTime-_)}}function S(_,z){w=!1,k&&(k=!1,p(C),C=-1),v=!0;var L=m;try{for(h(z),f=n(a);f!==null&&(!(f.expirationTime>z)||_&&!ye());){var V=f.callback;if(typeof V=="function"){f.callback=null,m=f.priorityLevel;var Z=V(f.expirationTime<=z);z=e.unstable_now(),typeof Z=="function"?f.callback=Z:f===n(a)&&r(a),h(z)}else r(a);f=n(a)}if(f!==null)var jt=!0;else{var P=n(c);P!==null&&_t(y,P.startTime-z),jt=!1}return jt}finally{f=null,m=L,v=!1}}var N=!1,E=null,C=-1,H=5,T=-1;function ye(){return!(e.unstable_now()-T_||125<_?console.error("forceFrameRate takes a positive int between 0 and 125, forcing frame rates higher than 125 fps is not supported"):H=0<_?Math.floor(1e3/_):5},e.unstable_getCurrentPriorityLevel=function(){return m},e.unstable_getFirstCallbackNode=function(){return n(a)},e.unstable_next=function(_){switch(m){case 1:case 2:case 3:var z=3;break;default:z=m}var L=m;m=z;try{return _()}finally{m=L}},e.unstable_pauseExecution=function(){},e.unstable_requestPaint=function(){},e.unstable_runWithPriority=function(_,z){switch(_){case 1:case 2:case 3:case 4:case 5:break;default:_=3}var L=m;m=_;try{return z()}finally{m=L}},e.unstable_scheduleCallback=function(_,z,L){var V=e.unstable_now();switch(typeof L=="object"&&L!==null?(L=L.delay,L=typeof L=="number"&&0V?(_.sortIndex=L,t(c,_),n(a)===null&&_===n(c)&&(k?(p(C),C=-1):k=!0,_t(y,L-V))):(_.sortIndex=Z,t(a,_),w||v||(w=!0,St(S))),_},e.unstable_shouldYield=ye,e.unstable_wrapCallback=function(_){var z=m;return function(){var L=m;m=z;try{return _.apply(this,arguments)}finally{m=L}}}})(os);is.exports=os;var zc=is.exports;/** + * @license React + * react-dom.production.min.js + * + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var Lc=D,Se=zc;function x(e){for(var t="https://reactjs.org/docs/error-decoder.html?invariant="+e,n=1;n"u"||typeof window.document>"u"||typeof window.document.createElement>"u"),Zl=Object.prototype.hasOwnProperty,Tc=/^[:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD][:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD\-.0-9\u00B7\u0300-\u036F\u203F-\u2040]*$/,Bo={},Ho={};function Fc(e){return Zl.call(Ho,e)?!0:Zl.call(Bo,e)?!1:Tc.test(e)?Ho[e]=!0:(Bo[e]=!0,!1)}function Oc(e,t,n,r){if(n!==null&&n.type===0)return!1;switch(typeof t){case"function":case"symbol":return!0;case"boolean":return r?!1:n!==null?!n.acceptsBooleans:(e=e.toLowerCase().slice(0,5),e!=="data-"&&e!=="aria-");default:return!1}}function Rc(e,t,n,r){if(t===null||typeof t>"u"||Oc(e,t,n,r))return!0;if(r)return!1;if(n!==null)switch(n.type){case 3:return!t;case 4:return t===!1;case 5:return isNaN(t);case 6:return isNaN(t)||1>t}return!1}function fe(e,t,n,r,l,i,o){this.acceptsBooleans=t===2||t===3||t===4,this.attributeName=r,this.attributeNamespace=l,this.mustUseProperty=n,this.propertyName=e,this.type=t,this.sanitizeURL=i,this.removeEmptyString=o}var le={};"children dangerouslySetInnerHTML defaultValue defaultChecked innerHTML suppressContentEditableWarning suppressHydrationWarning style".split(" ").forEach(function(e){le[e]=new fe(e,0,!1,e,null,!1,!1)});[["acceptCharset","accept-charset"],["className","class"],["htmlFor","for"],["httpEquiv","http-equiv"]].forEach(function(e){var t=e[0];le[t]=new fe(t,1,!1,e[1],null,!1,!1)});["contentEditable","draggable","spellCheck","value"].forEach(function(e){le[e]=new fe(e,2,!1,e.toLowerCase(),null,!1,!1)});["autoReverse","externalResourcesRequired","focusable","preserveAlpha"].forEach(function(e){le[e]=new fe(e,2,!1,e,null,!1,!1)});"allowFullScreen async autoFocus autoPlay controls default defer disabled disablePictureInPicture disableRemotePlayback formNoValidate hidden loop noModule noValidate open playsInline readOnly required reversed scoped seamless itemScope".split(" ").forEach(function(e){le[e]=new fe(e,3,!1,e.toLowerCase(),null,!1,!1)});["checked","multiple","muted","selected"].forEach(function(e){le[e]=new fe(e,3,!0,e,null,!1,!1)});["capture","download"].forEach(function(e){le[e]=new fe(e,4,!1,e,null,!1,!1)});["cols","rows","size","span"].forEach(function(e){le[e]=new fe(e,6,!1,e,null,!1,!1)});["rowSpan","start"].forEach(function(e){le[e]=new fe(e,5,!1,e.toLowerCase(),null,!1,!1)});var Ki=/[\-:]([a-z])/g;function Xi(e){return e[1].toUpperCase()}"accent-height alignment-baseline arabic-form baseline-shift cap-height clip-path clip-rule color-interpolation color-interpolation-filters color-profile color-rendering dominant-baseline enable-background fill-opacity fill-rule flood-color flood-opacity font-family font-size font-size-adjust font-stretch font-style font-variant font-weight glyph-name glyph-orientation-horizontal glyph-orientation-vertical horiz-adv-x horiz-origin-x image-rendering letter-spacing lighting-color marker-end marker-mid marker-start overline-position overline-thickness paint-order panose-1 pointer-events rendering-intent shape-rendering stop-color stop-opacity strikethrough-position strikethrough-thickness stroke-dasharray stroke-dashoffset stroke-linecap stroke-linejoin stroke-miterlimit stroke-opacity stroke-width text-anchor text-decoration text-rendering underline-position underline-thickness unicode-bidi unicode-range units-per-em v-alphabetic v-hanging v-ideographic v-mathematical vector-effect vert-adv-y vert-origin-x vert-origin-y word-spacing writing-mode xmlns:xlink x-height".split(" ").forEach(function(e){var t=e.replace(Ki,Xi);le[t]=new fe(t,1,!1,e,null,!1,!1)});"xlink:actuate xlink:arcrole xlink:role xlink:show xlink:title xlink:type".split(" ").forEach(function(e){var t=e.replace(Ki,Xi);le[t]=new fe(t,1,!1,e,"http://www.w3.org/1999/xlink",!1,!1)});["xml:base","xml:lang","xml:space"].forEach(function(e){var t=e.replace(Ki,Xi);le[t]=new fe(t,1,!1,e,"http://www.w3.org/XML/1998/namespace",!1,!1)});["tabIndex","crossOrigin"].forEach(function(e){le[e]=new fe(e,1,!1,e.toLowerCase(),null,!1,!1)});le.xlinkHref=new fe("xlinkHref",1,!1,"xlink:href","http://www.w3.org/1999/xlink",!0,!1);["src","href","action","formAction"].forEach(function(e){le[e]=new fe(e,1,!1,e.toLowerCase(),null,!0,!0)});function Gi(e,t,n,r){var l=le.hasOwnProperty(t)?le[t]:null;(l!==null?l.type!==0:r||!(2s||l[o]!==i[s]){var a=` +`+l[o].replace(" at new "," at ");return e.displayName&&a.includes("")&&(a=a.replace("",e.displayName)),a}while(1<=o&&0<=s);break}}}finally{Nl=!1,Error.prepareStackTrace=n}return(e=e?e.displayName||e.name:"")?En(e):""}function Mc(e){switch(e.tag){case 5:return En(e.type);case 16:return En("Lazy");case 13:return En("Suspense");case 19:return En("SuspenseList");case 0:case 2:case 15:return e=El(e.type,!1),e;case 11:return e=El(e.type.render,!1),e;case 1:return e=El(e.type,!0),e;default:return""}}function ei(e){if(e==null)return null;if(typeof e=="function")return e.displayName||e.name||null;if(typeof e=="string")return e;switch(e){case Ht:return"Fragment";case Bt:return"Portal";case Jl:return"Profiler";case Yi:return"StrictMode";case ql:return"Suspense";case bl:return"SuspenseList"}if(typeof e=="object")switch(e.$$typeof){case as:return(e.displayName||"Context")+".Consumer";case ss:return(e._context.displayName||"Context")+".Provider";case Zi:var t=e.render;return e=e.displayName,e||(e=t.displayName||t.name||"",e=e!==""?"ForwardRef("+e+")":"ForwardRef"),e;case Ji:return t=e.displayName||null,t!==null?t:ei(e.type)||"Memo";case nt:t=e._payload,e=e._init;try{return ei(e(t))}catch{}}return null}function Dc(e){var t=e.type;switch(e.tag){case 24:return"Cache";case 9:return(t.displayName||"Context")+".Consumer";case 10:return(t._context.displayName||"Context")+".Provider";case 18:return"DehydratedFragment";case 11:return e=t.render,e=e.displayName||e.name||"",t.displayName||(e!==""?"ForwardRef("+e+")":"ForwardRef");case 7:return"Fragment";case 5:return t;case 4:return"Portal";case 3:return"Root";case 6:return"Text";case 16:return ei(t);case 8:return t===Yi?"StrictMode":"Mode";case 22:return"Offscreen";case 12:return"Profiler";case 21:return"Scope";case 13:return"Suspense";case 19:return"SuspenseList";case 25:return"TracingMarker";case 1:case 0:case 17:case 2:case 14:case 15:if(typeof t=="function")return t.displayName||t.name||null;if(typeof t=="string")return t}return null}function gt(e){switch(typeof e){case"boolean":case"number":case"string":case"undefined":return e;case"object":return e;default:return""}}function ds(e){var t=e.type;return(e=e.nodeName)&&e.toLowerCase()==="input"&&(t==="checkbox"||t==="radio")}function Ic(e){var t=ds(e)?"checked":"value",n=Object.getOwnPropertyDescriptor(e.constructor.prototype,t),r=""+e[t];if(!e.hasOwnProperty(t)&&typeof n<"u"&&typeof n.get=="function"&&typeof n.set=="function"){var l=n.get,i=n.set;return Object.defineProperty(e,t,{configurable:!0,get:function(){return l.call(this)},set:function(o){r=""+o,i.call(this,o)}}),Object.defineProperty(e,t,{enumerable:n.enumerable}),{getValue:function(){return r},setValue:function(o){r=""+o},stopTracking:function(){e._valueTracker=null,delete e[t]}}}}function ar(e){e._valueTracker||(e._valueTracker=Ic(e))}function fs(e){if(!e)return!1;var t=e._valueTracker;if(!t)return!0;var n=t.getValue(),r="";return e&&(r=ds(e)?e.checked?"true":"false":e.value),e=r,e!==n?(t.setValue(e),!0):!1}function Dr(e){if(e=e||(typeof document<"u"?document:void 0),typeof e>"u")return null;try{return e.activeElement||e.body}catch{return e.body}}function ti(e,t){var n=t.checked;return X({},t,{defaultChecked:void 0,defaultValue:void 0,value:void 0,checked:n??e._wrapperState.initialChecked})}function Vo(e,t){var n=t.defaultValue==null?"":t.defaultValue,r=t.checked!=null?t.checked:t.defaultChecked;n=gt(t.value!=null?t.value:n),e._wrapperState={initialChecked:r,initialValue:n,controlled:t.type==="checkbox"||t.type==="radio"?t.checked!=null:t.value!=null}}function ps(e,t){t=t.checked,t!=null&&Gi(e,"checked",t,!1)}function ni(e,t){ps(e,t);var n=gt(t.value),r=t.type;if(n!=null)r==="number"?(n===0&&e.value===""||e.value!=n)&&(e.value=""+n):e.value!==""+n&&(e.value=""+n);else if(r==="submit"||r==="reset"){e.removeAttribute("value");return}t.hasOwnProperty("value")?ri(e,t.type,n):t.hasOwnProperty("defaultValue")&&ri(e,t.type,gt(t.defaultValue)),t.checked==null&&t.defaultChecked!=null&&(e.defaultChecked=!!t.defaultChecked)}function Qo(e,t,n){if(t.hasOwnProperty("value")||t.hasOwnProperty("defaultValue")){var r=t.type;if(!(r!=="submit"&&r!=="reset"||t.value!==void 0&&t.value!==null))return;t=""+e._wrapperState.initialValue,n||t===e.value||(e.value=t),e.defaultValue=t}n=e.name,n!==""&&(e.name=""),e.defaultChecked=!!e._wrapperState.initialChecked,n!==""&&(e.name=n)}function ri(e,t,n){(t!=="number"||Dr(e.ownerDocument)!==e)&&(n==null?e.defaultValue=""+e._wrapperState.initialValue:e.defaultValue!==""+n&&(e.defaultValue=""+n))}var Cn=Array.isArray;function bt(e,t,n,r){if(e=e.options,t){t={};for(var l=0;l"+t.valueOf().toString()+"",t=cr.firstChild;e.firstChild;)e.removeChild(e.firstChild);for(;t.firstChild;)e.appendChild(t.firstChild)}});function An(e,t){if(t){var n=e.firstChild;if(n&&n===e.lastChild&&n.nodeType===3){n.nodeValue=t;return}}e.textContent=t}var Ln={animationIterationCount:!0,aspectRatio:!0,borderImageOutset:!0,borderImageSlice:!0,borderImageWidth:!0,boxFlex:!0,boxFlexGroup:!0,boxOrdinalGroup:!0,columnCount:!0,columns:!0,flex:!0,flexGrow:!0,flexPositive:!0,flexShrink:!0,flexNegative:!0,flexOrder:!0,gridArea:!0,gridRow:!0,gridRowEnd:!0,gridRowSpan:!0,gridRowStart:!0,gridColumn:!0,gridColumnEnd:!0,gridColumnSpan:!0,gridColumnStart:!0,fontWeight:!0,lineClamp:!0,lineHeight:!0,opacity:!0,order:!0,orphans:!0,tabSize:!0,widows:!0,zIndex:!0,zoom:!0,fillOpacity:!0,floodOpacity:!0,stopOpacity:!0,strokeDasharray:!0,strokeDashoffset:!0,strokeMiterlimit:!0,strokeOpacity:!0,strokeWidth:!0},Uc=["Webkit","ms","Moz","O"];Object.keys(Ln).forEach(function(e){Uc.forEach(function(t){t=t+e.charAt(0).toUpperCase()+e.substring(1),Ln[t]=Ln[e]})});function vs(e,t,n){return t==null||typeof t=="boolean"||t===""?"":n||typeof t!="number"||t===0||Ln.hasOwnProperty(e)&&Ln[e]?(""+t).trim():t+"px"}function ys(e,t){e=e.style;for(var n in t)if(t.hasOwnProperty(n)){var r=n.indexOf("--")===0,l=vs(n,t[n],r);n==="float"&&(n="cssFloat"),r?e.setProperty(n,l):e[n]=l}}var $c=X({menuitem:!0},{area:!0,base:!0,br:!0,col:!0,embed:!0,hr:!0,img:!0,input:!0,keygen:!0,link:!0,meta:!0,param:!0,source:!0,track:!0,wbr:!0});function oi(e,t){if(t){if($c[e]&&(t.children!=null||t.dangerouslySetInnerHTML!=null))throw Error(x(137,e));if(t.dangerouslySetInnerHTML!=null){if(t.children!=null)throw Error(x(60));if(typeof t.dangerouslySetInnerHTML!="object"||!("__html"in t.dangerouslySetInnerHTML))throw Error(x(61))}if(t.style!=null&&typeof t.style!="object")throw Error(x(62))}}function ui(e,t){if(e.indexOf("-")===-1)return typeof t.is=="string";switch(e){case"annotation-xml":case"color-profile":case"font-face":case"font-face-src":case"font-face-uri":case"font-face-format":case"font-face-name":case"missing-glyph":return!1;default:return!0}}var si=null;function qi(e){return e=e.target||e.srcElement||window,e.correspondingUseElement&&(e=e.correspondingUseElement),e.nodeType===3?e.parentNode:e}var ai=null,en=null,tn=null;function Go(e){if(e=ir(e)){if(typeof ai!="function")throw Error(x(280));var t=e.stateNode;t&&(t=dl(t),ai(e.stateNode,e.type,t))}}function xs(e){en?tn?tn.push(e):tn=[e]:en=e}function ws(){if(en){var e=en,t=tn;if(tn=en=null,Go(e),t)for(e=0;e>>=0,e===0?32:31-(Zc(e)/Jc|0)|0}var dr=64,fr=4194304;function Pn(e){switch(e&-e){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return e&4194240;case 4194304:case 8388608:case 16777216:case 33554432:case 67108864:return e&130023424;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 1073741824;default:return e}}function Ar(e,t){var n=e.pendingLanes;if(n===0)return 0;var r=0,l=e.suspendedLanes,i=e.pingedLanes,o=n&268435455;if(o!==0){var s=o&~l;s!==0?r=Pn(s):(i&=o,i!==0&&(r=Pn(i)))}else o=n&~l,o!==0?r=Pn(o):i!==0&&(r=Pn(i));if(r===0)return 0;if(t!==0&&t!==r&&!(t&l)&&(l=r&-r,i=t&-t,l>=i||l===16&&(i&4194240)!==0))return t;if(r&4&&(r|=n&16),t=e.entangledLanes,t!==0)for(e=e.entanglements,t&=r;0n;n++)t.push(e);return t}function rr(e,t,n){e.pendingLanes|=t,t!==536870912&&(e.suspendedLanes=0,e.pingedLanes=0),e=e.eventTimes,t=31-De(t),e[t]=n}function td(e,t){var n=e.pendingLanes&~t;e.pendingLanes=t,e.suspendedLanes=0,e.pingedLanes=0,e.expiredLanes&=t,e.mutableReadLanes&=t,e.entangledLanes&=t,t=e.entanglements;var r=e.eventTimes;for(e=e.expirationTimes;0=Fn),ru=" ",lu=!1;function As(e,t){switch(e){case"keyup":return zd.indexOf(t.keyCode)!==-1;case"keydown":return t.keyCode!==229;case"keypress":case"mousedown":case"focusout":return!0;default:return!1}}function Bs(e){return e=e.detail,typeof e=="object"&&"data"in e?e.data:null}var Wt=!1;function Td(e,t){switch(e){case"compositionend":return Bs(t);case"keypress":return t.which!==32?null:(lu=!0,ru);case"textInput":return e=t.data,e===ru&&lu?null:e;default:return null}}function Fd(e,t){if(Wt)return e==="compositionend"||!oo&&As(e,t)?(e=Us(),Cr=ro=ot=null,Wt=!1,e):null;switch(e){case"paste":return null;case"keypress":if(!(t.ctrlKey||t.altKey||t.metaKey)||t.ctrlKey&&t.altKey){if(t.char&&1=t)return{node:n,offset:t-e};e=r}e:{for(;n;){if(n.nextSibling){n=n.nextSibling;break e}n=n.parentNode}n=void 0}n=su(n)}}function Qs(e,t){return e&&t?e===t?!0:e&&e.nodeType===3?!1:t&&t.nodeType===3?Qs(e,t.parentNode):"contains"in e?e.contains(t):e.compareDocumentPosition?!!(e.compareDocumentPosition(t)&16):!1:!1}function Ks(){for(var e=window,t=Dr();t instanceof e.HTMLIFrameElement;){try{var n=typeof t.contentWindow.location.href=="string"}catch{n=!1}if(n)e=t.contentWindow;else break;t=Dr(e.document)}return t}function uo(e){var t=e&&e.nodeName&&e.nodeName.toLowerCase();return t&&(t==="input"&&(e.type==="text"||e.type==="search"||e.type==="tel"||e.type==="url"||e.type==="password")||t==="textarea"||e.contentEditable==="true")}function Bd(e){var t=Ks(),n=e.focusedElem,r=e.selectionRange;if(t!==n&&n&&n.ownerDocument&&Qs(n.ownerDocument.documentElement,n)){if(r!==null&&uo(n)){if(t=r.start,e=r.end,e===void 0&&(e=t),"selectionStart"in n)n.selectionStart=t,n.selectionEnd=Math.min(e,n.value.length);else if(e=(t=n.ownerDocument||document)&&t.defaultView||window,e.getSelection){e=e.getSelection();var l=n.textContent.length,i=Math.min(r.start,l);r=r.end===void 0?i:Math.min(r.end,l),!e.extend&&i>r&&(l=r,r=i,i=l),l=au(n,i);var o=au(n,r);l&&o&&(e.rangeCount!==1||e.anchorNode!==l.node||e.anchorOffset!==l.offset||e.focusNode!==o.node||e.focusOffset!==o.offset)&&(t=t.createRange(),t.setStart(l.node,l.offset),e.removeAllRanges(),i>r?(e.addRange(t),e.extend(o.node,o.offset)):(t.setEnd(o.node,o.offset),e.addRange(t)))}}for(t=[],e=n;e=e.parentNode;)e.nodeType===1&&t.push({element:e,left:e.scrollLeft,top:e.scrollTop});for(typeof n.focus=="function"&&n.focus(),n=0;n=document.documentMode,Vt=null,mi=null,Rn=null,gi=!1;function cu(e,t,n){var r=n.window===n?n.document:n.nodeType===9?n:n.ownerDocument;gi||Vt==null||Vt!==Dr(r)||(r=Vt,"selectionStart"in r&&uo(r)?r={start:r.selectionStart,end:r.selectionEnd}:(r=(r.ownerDocument&&r.ownerDocument.defaultView||window).getSelection(),r={anchorNode:r.anchorNode,anchorOffset:r.anchorOffset,focusNode:r.focusNode,focusOffset:r.focusOffset}),Rn&&Kn(Rn,r)||(Rn=r,r=Wr(mi,"onSelect"),0Xt||(e.current=Si[Xt],Si[Xt]=null,Xt--)}function U(e,t){Xt++,Si[Xt]=e.current,e.current=t}var vt={},se=xt(vt),me=xt(!1),Ot=vt;function un(e,t){var n=e.type.contextTypes;if(!n)return vt;var r=e.stateNode;if(r&&r.__reactInternalMemoizedUnmaskedChildContext===t)return r.__reactInternalMemoizedMaskedChildContext;var l={},i;for(i in n)l[i]=t[i];return r&&(e=e.stateNode,e.__reactInternalMemoizedUnmaskedChildContext=t,e.__reactInternalMemoizedMaskedChildContext=l),l}function ge(e){return e=e.childContextTypes,e!=null}function Qr(){B(me),B(se)}function vu(e,t,n){if(se.current!==vt)throw Error(x(168));U(se,t),U(me,n)}function ta(e,t,n){var r=e.stateNode;if(t=t.childContextTypes,typeof r.getChildContext!="function")return n;r=r.getChildContext();for(var l in r)if(!(l in t))throw Error(x(108,Dc(e)||"Unknown",l));return X({},n,r)}function Kr(e){return e=(e=e.stateNode)&&e.__reactInternalMemoizedMergedChildContext||vt,Ot=se.current,U(se,e),U(me,me.current),!0}function yu(e,t,n){var r=e.stateNode;if(!r)throw Error(x(169));n?(e=ta(e,t,Ot),r.__reactInternalMemoizedMergedChildContext=e,B(me),B(se),U(se,e)):B(me),U(me,n)}var Qe=null,fl=!1,Al=!1;function na(e){Qe===null?Qe=[e]:Qe.push(e)}function bd(e){fl=!0,na(e)}function wt(){if(!Al&&Qe!==null){Al=!0;var e=0,t=I;try{var n=Qe;for(I=1;e>=o,l-=o,Ke=1<<32-De(t)+l|n<C?(H=E,E=null):H=E.sibling;var T=m(p,E,h[C],y);if(T===null){E===null&&(E=H);break}e&&E&&T.alternate===null&&t(p,E),d=i(T,d,C),N===null?S=T:N.sibling=T,N=T,E=H}if(C===h.length)return n(p,E),W&&Nt(p,C),S;if(E===null){for(;CC?(H=E,E=null):H=E.sibling;var ye=m(p,E,T.value,y);if(ye===null){E===null&&(E=H);break}e&&E&&ye.alternate===null&&t(p,E),d=i(ye,d,C),N===null?S=ye:N.sibling=ye,N=ye,E=H}if(T.done)return n(p,E),W&&Nt(p,C),S;if(E===null){for(;!T.done;C++,T=h.next())T=f(p,T.value,y),T!==null&&(d=i(T,d,C),N===null?S=T:N.sibling=T,N=T);return W&&Nt(p,C),S}for(E=r(p,E);!T.done;C++,T=h.next())T=v(E,p,C,T.value,y),T!==null&&(e&&T.alternate!==null&&E.delete(T.key===null?C:T.key),d=i(T,d,C),N===null?S=T:N.sibling=T,N=T);return e&&E.forEach(function(kt){return t(p,kt)}),W&&Nt(p,C),S}function $(p,d,h,y){if(typeof h=="object"&&h!==null&&h.type===Ht&&h.key===null&&(h=h.props.children),typeof h=="object"&&h!==null){switch(h.$$typeof){case sr:e:{for(var S=h.key,N=d;N!==null;){if(N.key===S){if(S=h.type,S===Ht){if(N.tag===7){n(p,N.sibling),d=l(N,h.props.children),d.return=p,p=d;break e}}else if(N.elementType===S||typeof S=="object"&&S!==null&&S.$$typeof===nt&&ku(S)===N.type){n(p,N.sibling),d=l(N,h.props),d.ref=_n(p,N,h),d.return=p,p=d;break e}n(p,N);break}else t(p,N);N=N.sibling}h.type===Ht?(d=Tt(h.props.children,p.mode,y,h.key),d.return=p,p=d):(y=Mr(h.type,h.key,h.props,null,p.mode,y),y.ref=_n(p,d,h),y.return=p,p=y)}return o(p);case Bt:e:{for(N=h.key;d!==null;){if(d.key===N)if(d.tag===4&&d.stateNode.containerInfo===h.containerInfo&&d.stateNode.implementation===h.implementation){n(p,d.sibling),d=l(d,h.children||[]),d.return=p,p=d;break e}else{n(p,d);break}else t(p,d);d=d.sibling}d=Gl(h,p.mode,y),d.return=p,p=d}return o(p);case nt:return N=h._init,$(p,d,N(h._payload),y)}if(Cn(h))return w(p,d,h,y);if(yn(h))return k(p,d,h,y);xr(p,h)}return typeof h=="string"&&h!==""||typeof h=="number"?(h=""+h,d!==null&&d.tag===6?(n(p,d.sibling),d=l(d,h),d.return=p,p=d):(n(p,d),d=Xl(h,p.mode,y),d.return=p,p=d),o(p)):n(p,d)}return $}var an=oa(!0),ua=oa(!1),Yr=xt(null),Zr=null,Zt=null,fo=null;function po(){fo=Zt=Zr=null}function ho(e){var t=Yr.current;B(Yr),e._currentValue=t}function Ni(e,t,n){for(;e!==null;){var r=e.alternate;if((e.childLanes&t)!==t?(e.childLanes|=t,r!==null&&(r.childLanes|=t)):r!==null&&(r.childLanes&t)!==t&&(r.childLanes|=t),e===n)break;e=e.return}}function rn(e,t){Zr=e,fo=Zt=null,e=e.dependencies,e!==null&&e.firstContext!==null&&(e.lanes&t&&(he=!0),e.firstContext=null)}function ze(e){var t=e._currentValue;if(fo!==e)if(e={context:e,memoizedValue:t,next:null},Zt===null){if(Zr===null)throw Error(x(308));Zt=e,Zr.dependencies={lanes:0,firstContext:e}}else Zt=Zt.next=e;return t}var Pt=null;function mo(e){Pt===null?Pt=[e]:Pt.push(e)}function sa(e,t,n,r){var l=t.interleaved;return l===null?(n.next=n,mo(t)):(n.next=l.next,l.next=n),t.interleaved=n,Je(e,r)}function Je(e,t){e.lanes|=t;var n=e.alternate;for(n!==null&&(n.lanes|=t),n=e,e=e.return;e!==null;)e.childLanes|=t,n=e.alternate,n!==null&&(n.childLanes|=t),n=e,e=e.return;return n.tag===3?n.stateNode:null}var rt=!1;function go(e){e.updateQueue={baseState:e.memoizedState,firstBaseUpdate:null,lastBaseUpdate:null,shared:{pending:null,interleaved:null,lanes:0},effects:null}}function aa(e,t){e=e.updateQueue,t.updateQueue===e&&(t.updateQueue={baseState:e.baseState,firstBaseUpdate:e.firstBaseUpdate,lastBaseUpdate:e.lastBaseUpdate,shared:e.shared,effects:e.effects})}function Ge(e,t){return{eventTime:e,lane:t,tag:0,payload:null,callback:null,next:null}}function ft(e,t,n){var r=e.updateQueue;if(r===null)return null;if(r=r.shared,O&2){var l=r.pending;return l===null?t.next=t:(t.next=l.next,l.next=t),r.pending=t,Je(e,n)}return l=r.interleaved,l===null?(t.next=t,mo(r)):(t.next=l.next,l.next=t),r.interleaved=t,Je(e,n)}function zr(e,t,n){if(t=t.updateQueue,t!==null&&(t=t.shared,(n&4194240)!==0)){var r=t.lanes;r&=e.pendingLanes,n|=r,t.lanes=n,eo(e,n)}}function Su(e,t){var n=e.updateQueue,r=e.alternate;if(r!==null&&(r=r.updateQueue,n===r)){var l=null,i=null;if(n=n.firstBaseUpdate,n!==null){do{var o={eventTime:n.eventTime,lane:n.lane,tag:n.tag,payload:n.payload,callback:n.callback,next:null};i===null?l=i=o:i=i.next=o,n=n.next}while(n!==null);i===null?l=i=t:i=i.next=t}else l=i=t;n={baseState:r.baseState,firstBaseUpdate:l,lastBaseUpdate:i,shared:r.shared,effects:r.effects},e.updateQueue=n;return}e=n.lastBaseUpdate,e===null?n.firstBaseUpdate=t:e.next=t,n.lastBaseUpdate=t}function Jr(e,t,n,r){var l=e.updateQueue;rt=!1;var i=l.firstBaseUpdate,o=l.lastBaseUpdate,s=l.shared.pending;if(s!==null){l.shared.pending=null;var a=s,c=a.next;a.next=null,o===null?i=c:o.next=c,o=a;var g=e.alternate;g!==null&&(g=g.updateQueue,s=g.lastBaseUpdate,s!==o&&(s===null?g.firstBaseUpdate=c:s.next=c,g.lastBaseUpdate=a))}if(i!==null){var f=l.baseState;o=0,g=c=a=null,s=i;do{var m=s.lane,v=s.eventTime;if((r&m)===m){g!==null&&(g=g.next={eventTime:v,lane:0,tag:s.tag,payload:s.payload,callback:s.callback,next:null});e:{var w=e,k=s;switch(m=t,v=n,k.tag){case 1:if(w=k.payload,typeof w=="function"){f=w.call(v,f,m);break e}f=w;break e;case 3:w.flags=w.flags&-65537|128;case 0:if(w=k.payload,m=typeof w=="function"?w.call(v,f,m):w,m==null)break e;f=X({},f,m);break e;case 2:rt=!0}}s.callback!==null&&s.lane!==0&&(e.flags|=64,m=l.effects,m===null?l.effects=[s]:m.push(s))}else v={eventTime:v,lane:m,tag:s.tag,payload:s.payload,callback:s.callback,next:null},g===null?(c=g=v,a=f):g=g.next=v,o|=m;if(s=s.next,s===null){if(s=l.shared.pending,s===null)break;m=s,s=m.next,m.next=null,l.lastBaseUpdate=m,l.shared.pending=null}}while(!0);if(g===null&&(a=f),l.baseState=a,l.firstBaseUpdate=c,l.lastBaseUpdate=g,t=l.shared.interleaved,t!==null){l=t;do o|=l.lane,l=l.next;while(l!==t)}else i===null&&(l.shared.lanes=0);Dt|=o,e.lanes=o,e.memoizedState=f}}function _u(e,t,n){if(e=t.effects,t.effects=null,e!==null)for(t=0;tn?n:4,e(!0);var r=Hl.transition;Hl.transition={};try{e(!1),t()}finally{I=n,Hl.transition=r}}function Ea(){return Le().memoizedState}function rf(e,t,n){var r=ht(e);if(n={lane:r,action:n,hasEagerState:!1,eagerState:null,next:null},Ca(e))Pa(t,n);else if(n=sa(e,t,n,r),n!==null){var l=ce();Ie(n,e,r,l),za(n,t,r)}}function lf(e,t,n){var r=ht(e),l={lane:r,action:n,hasEagerState:!1,eagerState:null,next:null};if(Ca(e))Pa(t,l);else{var i=e.alternate;if(e.lanes===0&&(i===null||i.lanes===0)&&(i=t.lastRenderedReducer,i!==null))try{var o=t.lastRenderedState,s=i(o,n);if(l.hasEagerState=!0,l.eagerState=s,Ue(s,o)){var a=t.interleaved;a===null?(l.next=l,mo(t)):(l.next=a.next,a.next=l),t.interleaved=l;return}}catch{}finally{}n=sa(e,t,l,r),n!==null&&(l=ce(),Ie(n,e,r,l),za(n,t,r))}}function Ca(e){var t=e.alternate;return e===K||t!==null&&t===K}function Pa(e,t){Mn=br=!0;var n=e.pending;n===null?t.next=t:(t.next=n.next,n.next=t),e.pending=t}function za(e,t,n){if(n&4194240){var r=t.lanes;r&=e.pendingLanes,n|=r,t.lanes=n,eo(e,n)}}var el={readContext:ze,useCallback:ie,useContext:ie,useEffect:ie,useImperativeHandle:ie,useInsertionEffect:ie,useLayoutEffect:ie,useMemo:ie,useReducer:ie,useRef:ie,useState:ie,useDebugValue:ie,useDeferredValue:ie,useTransition:ie,useMutableSource:ie,useSyncExternalStore:ie,useId:ie,unstable_isNewReconciler:!1},of={readContext:ze,useCallback:function(e,t){return Ae().memoizedState=[e,t===void 0?null:t],e},useContext:ze,useEffect:Nu,useImperativeHandle:function(e,t,n){return n=n!=null?n.concat([e]):null,Tr(4194308,4,ka.bind(null,t,e),n)},useLayoutEffect:function(e,t){return Tr(4194308,4,e,t)},useInsertionEffect:function(e,t){return Tr(4,2,e,t)},useMemo:function(e,t){var n=Ae();return t=t===void 0?null:t,e=e(),n.memoizedState=[e,t],e},useReducer:function(e,t,n){var r=Ae();return t=n!==void 0?n(t):t,r.memoizedState=r.baseState=t,e={pending:null,interleaved:null,lanes:0,dispatch:null,lastRenderedReducer:e,lastRenderedState:t},r.queue=e,e=e.dispatch=rf.bind(null,K,e),[r.memoizedState,e]},useRef:function(e){var t=Ae();return e={current:e},t.memoizedState=e},useState:ju,useDebugValue:jo,useDeferredValue:function(e){return Ae().memoizedState=e},useTransition:function(){var e=ju(!1),t=e[0];return e=nf.bind(null,e[1]),Ae().memoizedState=e,[t,e]},useMutableSource:function(){},useSyncExternalStore:function(e,t,n){var r=K,l=Ae();if(W){if(n===void 0)throw Error(x(407));n=n()}else{if(n=t(),te===null)throw Error(x(349));Mt&30||pa(r,t,n)}l.memoizedState=n;var i={value:n,getSnapshot:t};return l.queue=i,Nu(ma.bind(null,r,i,e),[e]),r.flags|=2048,er(9,ha.bind(null,r,i,n,t),void 0,null),n},useId:function(){var e=Ae(),t=te.identifierPrefix;if(W){var n=Xe,r=Ke;n=(r&~(1<<32-De(r)-1)).toString(32)+n,t=":"+t+"R"+n,n=qn++,0<\/script>",e=e.removeChild(e.firstChild)):typeof r.is=="string"?e=o.createElement(n,{is:r.is}):(e=o.createElement(n),n==="select"&&(o=e,r.multiple?o.multiple=!0:r.size&&(o.size=r.size))):e=o.createElementNS(e,n),e[Be]=t,e[Yn]=r,$a(e,t,!1,!1),t.stateNode=e;e:{switch(o=ui(n,r),n){case"dialog":A("cancel",e),A("close",e),l=r;break;case"iframe":case"object":case"embed":A("load",e),l=r;break;case"video":case"audio":for(l=0;lfn&&(t.flags|=128,r=!0,jn(i,!1),t.lanes=4194304)}else{if(!r)if(e=qr(o),e!==null){if(t.flags|=128,r=!0,n=e.updateQueue,n!==null&&(t.updateQueue=n,t.flags|=4),jn(i,!0),i.tail===null&&i.tailMode==="hidden"&&!o.alternate&&!W)return oe(t),null}else 2*Y()-i.renderingStartTime>fn&&n!==1073741824&&(t.flags|=128,r=!0,jn(i,!1),t.lanes=4194304);i.isBackwards?(o.sibling=t.child,t.child=o):(n=i.last,n!==null?n.sibling=o:t.child=o,i.last=o)}return i.tail!==null?(t=i.tail,i.rendering=t,i.tail=t.sibling,i.renderingStartTime=Y(),t.sibling=null,n=Q.current,U(Q,r?n&1|2:n&1),t):(oe(t),null);case 22:case 23:return Lo(),r=t.memoizedState!==null,e!==null&&e.memoizedState!==null!==r&&(t.flags|=8192),r&&t.mode&1?xe&1073741824&&(oe(t),t.subtreeFlags&6&&(t.flags|=8192)):oe(t),null;case 24:return null;case 25:return null}throw Error(x(156,t.tag))}function hf(e,t){switch(ao(t),t.tag){case 1:return ge(t.type)&&Qr(),e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 3:return cn(),B(me),B(se),xo(),e=t.flags,e&65536&&!(e&128)?(t.flags=e&-65537|128,t):null;case 5:return yo(t),null;case 13:if(B(Q),e=t.memoizedState,e!==null&&e.dehydrated!==null){if(t.alternate===null)throw Error(x(340));sn()}return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 19:return B(Q),null;case 4:return cn(),null;case 10:return ho(t.type._context),null;case 22:case 23:return Lo(),null;case 24:return null;default:return null}}var kr=!1,ue=!1,mf=typeof WeakSet=="function"?WeakSet:Set,j=null;function Jt(e,t){var n=e.ref;if(n!==null)if(typeof n=="function")try{n(null)}catch(r){G(e,t,r)}else n.current=null}function Ri(e,t,n){try{n()}catch(r){G(e,t,r)}}var Du=!1;function gf(e,t){if(vi=Br,e=Ks(),uo(e)){if("selectionStart"in e)var n={start:e.selectionStart,end:e.selectionEnd};else e:{n=(n=e.ownerDocument)&&n.defaultView||window;var r=n.getSelection&&n.getSelection();if(r&&r.rangeCount!==0){n=r.anchorNode;var l=r.anchorOffset,i=r.focusNode;r=r.focusOffset;try{n.nodeType,i.nodeType}catch{n=null;break e}var o=0,s=-1,a=-1,c=0,g=0,f=e,m=null;t:for(;;){for(var v;f!==n||l!==0&&f.nodeType!==3||(s=o+l),f!==i||r!==0&&f.nodeType!==3||(a=o+r),f.nodeType===3&&(o+=f.nodeValue.length),(v=f.firstChild)!==null;)m=f,f=v;for(;;){if(f===e)break t;if(m===n&&++c===l&&(s=o),m===i&&++g===r&&(a=o),(v=f.nextSibling)!==null)break;f=m,m=f.parentNode}f=v}n=s===-1||a===-1?null:{start:s,end:a}}else n=null}n=n||{start:0,end:0}}else n=null;for(yi={focusedElem:e,selectionRange:n},Br=!1,j=t;j!==null;)if(t=j,e=t.child,(t.subtreeFlags&1028)!==0&&e!==null)e.return=t,j=e;else for(;j!==null;){t=j;try{var w=t.alternate;if(t.flags&1024)switch(t.tag){case 0:case 11:case 15:break;case 1:if(w!==null){var k=w.memoizedProps,$=w.memoizedState,p=t.stateNode,d=p.getSnapshotBeforeUpdate(t.elementType===t.type?k:Oe(t.type,k),$);p.__reactInternalSnapshotBeforeUpdate=d}break;case 3:var h=t.stateNode.containerInfo;h.nodeType===1?h.textContent="":h.nodeType===9&&h.documentElement&&h.removeChild(h.documentElement);break;case 5:case 6:case 4:case 17:break;default:throw Error(x(163))}}catch(y){G(t,t.return,y)}if(e=t.sibling,e!==null){e.return=t.return,j=e;break}j=t.return}return w=Du,Du=!1,w}function Dn(e,t,n){var r=t.updateQueue;if(r=r!==null?r.lastEffect:null,r!==null){var l=r=r.next;do{if((l.tag&e)===e){var i=l.destroy;l.destroy=void 0,i!==void 0&&Ri(t,n,i)}l=l.next}while(l!==r)}}function ml(e,t){if(t=t.updateQueue,t=t!==null?t.lastEffect:null,t!==null){var n=t=t.next;do{if((n.tag&e)===e){var r=n.create;n.destroy=r()}n=n.next}while(n!==t)}}function Mi(e){var t=e.ref;if(t!==null){var n=e.stateNode;switch(e.tag){case 5:e=n;break;default:e=n}typeof t=="function"?t(e):t.current=e}}function Ha(e){var t=e.alternate;t!==null&&(e.alternate=null,Ha(t)),e.child=null,e.deletions=null,e.sibling=null,e.tag===5&&(t=e.stateNode,t!==null&&(delete t[Be],delete t[Yn],delete t[ki],delete t[Jd],delete t[qd])),e.stateNode=null,e.return=null,e.dependencies=null,e.memoizedProps=null,e.memoizedState=null,e.pendingProps=null,e.stateNode=null,e.updateQueue=null}function Wa(e){return e.tag===5||e.tag===3||e.tag===4}function Iu(e){e:for(;;){for(;e.sibling===null;){if(e.return===null||Wa(e.return))return null;e=e.return}for(e.sibling.return=e.return,e=e.sibling;e.tag!==5&&e.tag!==6&&e.tag!==18;){if(e.flags&2||e.child===null||e.tag===4)continue e;e.child.return=e,e=e.child}if(!(e.flags&2))return e.stateNode}}function Di(e,t,n){var r=e.tag;if(r===5||r===6)e=e.stateNode,t?n.nodeType===8?n.parentNode.insertBefore(e,t):n.insertBefore(e,t):(n.nodeType===8?(t=n.parentNode,t.insertBefore(e,n)):(t=n,t.appendChild(e)),n=n._reactRootContainer,n!=null||t.onclick!==null||(t.onclick=Vr));else if(r!==4&&(e=e.child,e!==null))for(Di(e,t,n),e=e.sibling;e!==null;)Di(e,t,n),e=e.sibling}function Ii(e,t,n){var r=e.tag;if(r===5||r===6)e=e.stateNode,t?n.insertBefore(e,t):n.appendChild(e);else if(r!==4&&(e=e.child,e!==null))for(Ii(e,t,n),e=e.sibling;e!==null;)Ii(e,t,n),e=e.sibling}var ne=null,Re=!1;function tt(e,t,n){for(n=n.child;n!==null;)Va(e,t,n),n=n.sibling}function Va(e,t,n){if(He&&typeof He.onCommitFiberUnmount=="function")try{He.onCommitFiberUnmount(ul,n)}catch{}switch(n.tag){case 5:ue||Jt(n,t);case 6:var r=ne,l=Re;ne=null,tt(e,t,n),ne=r,Re=l,ne!==null&&(Re?(e=ne,n=n.stateNode,e.nodeType===8?e.parentNode.removeChild(n):e.removeChild(n)):ne.removeChild(n.stateNode));break;case 18:ne!==null&&(Re?(e=ne,n=n.stateNode,e.nodeType===8?$l(e.parentNode,n):e.nodeType===1&&$l(e,n),Vn(e)):$l(ne,n.stateNode));break;case 4:r=ne,l=Re,ne=n.stateNode.containerInfo,Re=!0,tt(e,t,n),ne=r,Re=l;break;case 0:case 11:case 14:case 15:if(!ue&&(r=n.updateQueue,r!==null&&(r=r.lastEffect,r!==null))){l=r=r.next;do{var i=l,o=i.destroy;i=i.tag,o!==void 0&&(i&2||i&4)&&Ri(n,t,o),l=l.next}while(l!==r)}tt(e,t,n);break;case 1:if(!ue&&(Jt(n,t),r=n.stateNode,typeof r.componentWillUnmount=="function"))try{r.props=n.memoizedProps,r.state=n.memoizedState,r.componentWillUnmount()}catch(s){G(n,t,s)}tt(e,t,n);break;case 21:tt(e,t,n);break;case 22:n.mode&1?(ue=(r=ue)||n.memoizedState!==null,tt(e,t,n),ue=r):tt(e,t,n);break;default:tt(e,t,n)}}function Uu(e){var t=e.updateQueue;if(t!==null){e.updateQueue=null;var n=e.stateNode;n===null&&(n=e.stateNode=new mf),t.forEach(function(r){var l=Nf.bind(null,e,r);n.has(r)||(n.add(r),r.then(l,l))})}}function Fe(e,t){var n=t.deletions;if(n!==null)for(var r=0;rl&&(l=o),r&=~i}if(r=l,r=Y()-r,r=(120>r?120:480>r?480:1080>r?1080:1920>r?1920:3e3>r?3e3:4320>r?4320:1960*yf(r/1960))-r,10e?16:e,ut===null)var r=!1;else{if(e=ut,ut=null,rl=0,O&6)throw Error(x(331));var l=O;for(O|=4,j=e.current;j!==null;){var i=j,o=i.child;if(j.flags&16){var s=i.deletions;if(s!==null){for(var a=0;aY()-Po?Lt(e,0):Co|=n),ve(e,t)}function qa(e,t){t===0&&(e.mode&1?(t=fr,fr<<=1,!(fr&130023424)&&(fr=4194304)):t=1);var n=ce();e=Je(e,t),e!==null&&(rr(e,t,n),ve(e,n))}function jf(e){var t=e.memoizedState,n=0;t!==null&&(n=t.retryLane),qa(e,n)}function Nf(e,t){var n=0;switch(e.tag){case 13:var r=e.stateNode,l=e.memoizedState;l!==null&&(n=l.retryLane);break;case 19:r=e.stateNode;break;default:throw Error(x(314))}r!==null&&r.delete(t),qa(e,n)}var ba;ba=function(e,t,n){if(e!==null)if(e.memoizedProps!==t.pendingProps||me.current)he=!0;else{if(!(e.lanes&n)&&!(t.flags&128))return he=!1,ff(e,t,n);he=!!(e.flags&131072)}else he=!1,W&&t.flags&1048576&&ra(t,Gr,t.index);switch(t.lanes=0,t.tag){case 2:var r=t.type;Fr(e,t),e=t.pendingProps;var l=un(t,se.current);rn(t,n),l=ko(null,t,r,e,l,n);var i=So();return t.flags|=1,typeof l=="object"&&l!==null&&typeof l.render=="function"&&l.$$typeof===void 0?(t.tag=1,t.memoizedState=null,t.updateQueue=null,ge(r)?(i=!0,Kr(t)):i=!1,t.memoizedState=l.state!==null&&l.state!==void 0?l.state:null,go(t),l.updater=hl,t.stateNode=l,l._reactInternals=t,Ci(t,r,e,n),t=Li(null,t,r,!0,i,n)):(t.tag=0,W&&i&&so(t),ae(null,t,l,n),t=t.child),t;case 16:r=t.elementType;e:{switch(Fr(e,t),e=t.pendingProps,l=r._init,r=l(r._payload),t.type=r,l=t.tag=Cf(r),e=Oe(r,e),l){case 0:t=zi(null,t,r,e,n);break e;case 1:t=Ou(null,t,r,e,n);break e;case 11:t=Tu(null,t,r,e,n);break e;case 14:t=Fu(null,t,r,Oe(r.type,e),n);break e}throw Error(x(306,r,""))}return t;case 0:return r=t.type,l=t.pendingProps,l=t.elementType===r?l:Oe(r,l),zi(e,t,r,l,n);case 1:return r=t.type,l=t.pendingProps,l=t.elementType===r?l:Oe(r,l),Ou(e,t,r,l,n);case 3:e:{if(Da(t),e===null)throw Error(x(387));r=t.pendingProps,i=t.memoizedState,l=i.element,aa(e,t),Jr(t,r,null,n);var o=t.memoizedState;if(r=o.element,i.isDehydrated)if(i={element:r,isDehydrated:!1,cache:o.cache,pendingSuspenseBoundaries:o.pendingSuspenseBoundaries,transitions:o.transitions},t.updateQueue.baseState=i,t.memoizedState=i,t.flags&256){l=dn(Error(x(423)),t),t=Ru(e,t,r,n,l);break e}else if(r!==l){l=dn(Error(x(424)),t),t=Ru(e,t,r,n,l);break e}else for(we=dt(t.stateNode.containerInfo.firstChild),ke=t,W=!0,Me=null,n=ua(t,null,r,n),t.child=n;n;)n.flags=n.flags&-3|4096,n=n.sibling;else{if(sn(),r===l){t=qe(e,t,n);break e}ae(e,t,r,n)}t=t.child}return t;case 5:return ca(t),e===null&&ji(t),r=t.type,l=t.pendingProps,i=e!==null?e.memoizedProps:null,o=l.children,xi(r,l)?o=null:i!==null&&xi(r,i)&&(t.flags|=32),Ma(e,t),ae(e,t,o,n),t.child;case 6:return e===null&&ji(t),null;case 13:return Ia(e,t,n);case 4:return vo(t,t.stateNode.containerInfo),r=t.pendingProps,e===null?t.child=an(t,null,r,n):ae(e,t,r,n),t.child;case 11:return r=t.type,l=t.pendingProps,l=t.elementType===r?l:Oe(r,l),Tu(e,t,r,l,n);case 7:return ae(e,t,t.pendingProps,n),t.child;case 8:return ae(e,t,t.pendingProps.children,n),t.child;case 12:return ae(e,t,t.pendingProps.children,n),t.child;case 10:e:{if(r=t.type._context,l=t.pendingProps,i=t.memoizedProps,o=l.value,U(Yr,r._currentValue),r._currentValue=o,i!==null)if(Ue(i.value,o)){if(i.children===l.children&&!me.current){t=qe(e,t,n);break e}}else for(i=t.child,i!==null&&(i.return=t);i!==null;){var s=i.dependencies;if(s!==null){o=i.child;for(var a=s.firstContext;a!==null;){if(a.context===r){if(i.tag===1){a=Ge(-1,n&-n),a.tag=2;var c=i.updateQueue;if(c!==null){c=c.shared;var g=c.pending;g===null?a.next=a:(a.next=g.next,g.next=a),c.pending=a}}i.lanes|=n,a=i.alternate,a!==null&&(a.lanes|=n),Ni(i.return,n,t),s.lanes|=n;break}a=a.next}}else if(i.tag===10)o=i.type===t.type?null:i.child;else if(i.tag===18){if(o=i.return,o===null)throw Error(x(341));o.lanes|=n,s=o.alternate,s!==null&&(s.lanes|=n),Ni(o,n,t),o=i.sibling}else o=i.child;if(o!==null)o.return=i;else for(o=i;o!==null;){if(o===t){o=null;break}if(i=o.sibling,i!==null){i.return=o.return,o=i;break}o=o.return}i=o}ae(e,t,l.children,n),t=t.child}return t;case 9:return l=t.type,r=t.pendingProps.children,rn(t,n),l=ze(l),r=r(l),t.flags|=1,ae(e,t,r,n),t.child;case 14:return r=t.type,l=Oe(r,t.pendingProps),l=Oe(r.type,l),Fu(e,t,r,l,n);case 15:return Oa(e,t,t.type,t.pendingProps,n);case 17:return r=t.type,l=t.pendingProps,l=t.elementType===r?l:Oe(r,l),Fr(e,t),t.tag=1,ge(r)?(e=!0,Kr(t)):e=!1,rn(t,n),La(t,r,l),Ci(t,r,l,n),Li(null,t,r,!0,e,n);case 19:return Ua(e,t,n);case 22:return Ra(e,t,n)}throw Error(x(156,t.tag))};function ec(e,t){return Cs(e,t)}function Ef(e,t,n,r){this.tag=e,this.key=n,this.sibling=this.child=this.return=this.stateNode=this.type=this.elementType=null,this.index=0,this.ref=null,this.pendingProps=t,this.dependencies=this.memoizedState=this.updateQueue=this.memoizedProps=null,this.mode=r,this.subtreeFlags=this.flags=0,this.deletions=null,this.childLanes=this.lanes=0,this.alternate=null}function Ce(e,t,n,r){return new Ef(e,t,n,r)}function Fo(e){return e=e.prototype,!(!e||!e.isReactComponent)}function Cf(e){if(typeof e=="function")return Fo(e)?1:0;if(e!=null){if(e=e.$$typeof,e===Zi)return 11;if(e===Ji)return 14}return 2}function mt(e,t){var n=e.alternate;return n===null?(n=Ce(e.tag,t,e.key,e.mode),n.elementType=e.elementType,n.type=e.type,n.stateNode=e.stateNode,n.alternate=e,e.alternate=n):(n.pendingProps=t,n.type=e.type,n.flags=0,n.subtreeFlags=0,n.deletions=null),n.flags=e.flags&14680064,n.childLanes=e.childLanes,n.lanes=e.lanes,n.child=e.child,n.memoizedProps=e.memoizedProps,n.memoizedState=e.memoizedState,n.updateQueue=e.updateQueue,t=e.dependencies,n.dependencies=t===null?null:{lanes:t.lanes,firstContext:t.firstContext},n.sibling=e.sibling,n.index=e.index,n.ref=e.ref,n}function Mr(e,t,n,r,l,i){var o=2;if(r=e,typeof e=="function")Fo(e)&&(o=1);else if(typeof e=="string")o=5;else e:switch(e){case Ht:return Tt(n.children,l,i,t);case Yi:o=8,l|=8;break;case Jl:return e=Ce(12,n,t,l|2),e.elementType=Jl,e.lanes=i,e;case ql:return e=Ce(13,n,t,l),e.elementType=ql,e.lanes=i,e;case bl:return e=Ce(19,n,t,l),e.elementType=bl,e.lanes=i,e;case cs:return vl(n,l,i,t);default:if(typeof e=="object"&&e!==null)switch(e.$$typeof){case ss:o=10;break e;case as:o=9;break e;case Zi:o=11;break e;case Ji:o=14;break e;case nt:o=16,r=null;break e}throw Error(x(130,e==null?e:typeof e,""))}return t=Ce(o,n,t,l),t.elementType=e,t.type=r,t.lanes=i,t}function Tt(e,t,n,r){return e=Ce(7,e,r,t),e.lanes=n,e}function vl(e,t,n,r){return e=Ce(22,e,r,t),e.elementType=cs,e.lanes=n,e.stateNode={isHidden:!1},e}function Xl(e,t,n){return e=Ce(6,e,null,t),e.lanes=n,e}function Gl(e,t,n){return t=Ce(4,e.children!==null?e.children:[],e.key,t),t.lanes=n,t.stateNode={containerInfo:e.containerInfo,pendingChildren:null,implementation:e.implementation},t}function Pf(e,t,n,r,l){this.tag=t,this.containerInfo=e,this.finishedWork=this.pingCache=this.current=this.pendingChildren=null,this.timeoutHandle=-1,this.callbackNode=this.pendingContext=this.context=null,this.callbackPriority=0,this.eventTimes=Pl(0),this.expirationTimes=Pl(-1),this.entangledLanes=this.finishedLanes=this.mutableReadLanes=this.expiredLanes=this.pingedLanes=this.suspendedLanes=this.pendingLanes=0,this.entanglements=Pl(0),this.identifierPrefix=r,this.onRecoverableError=l,this.mutableSourceEagerHydrationData=null}function Oo(e,t,n,r,l,i,o,s,a){return e=new Pf(e,t,n,s,a),t===1?(t=1,i===!0&&(t|=8)):t=0,i=Ce(3,null,null,t),e.current=i,i.stateNode=e,i.memoizedState={element:r,isDehydrated:n,cache:null,transitions:null,pendingSuspenseBoundaries:null},go(i),e}function zf(e,t,n){var r=3"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(lc)}catch(e){console.error(e)}}lc(),ls.exports=_e;var Rf=ls.exports,Ku=Rf;Yl.createRoot=Ku.createRoot,Yl.hydrateRoot=Ku.hydrateRoot;const ic=["Bitget","Binance","OKX"];function Ft(e){return e==null||e<=0?"-":e>=100?e.toFixed(2):e>=1?e.toFixed(4):e.toFixed(6)}function Mf(){const[e,t]=D.useState("--:--:--"),[n,r]=D.useState("● 未连接"),[l,i]=D.useState(!1),[o,s]=D.useState({}),[a,c]=D.useState([]),[g,f]=D.useState(""),[m,v]=D.useState([]),[w,k]=D.useState([]),[$,p]=D.useState([]),[d,h]=D.useState([]),[y,S]=D.useState([]),[N,E]=D.useState([]),[C,H]=D.useState([]),[T,ye]=D.useState([]),[kt,et]=D.useState([]),gn=D.useRef({});D.useEffect(()=>{const P=()=>t(new Date().toLocaleTimeString("zh-CN",{hour12:!1}));P();const M=setInterval(P,1e3);return()=>clearInterval(M)},[]),D.useEffect(()=>{let P=new EventSource("/events");return P.addEventListener("connected",()=>{r("● 已连接"),i(!0)}),P.onerror=()=>{r("● 已断开 (重连中...)"),i(!1),setTimeout(()=>{P=new EventSource("/events")},3e3)},P.onmessage=M=>{try{const R=JSON.parse(M.data);switch(R.event){case"prices":_(R.data);break;case"spread_3ex":v(R.data||[]);break;case"momentum":k(R.data||[]);break;case"trend":p(R.data||[]);break;case"cumulative":h(R.data||[]);break;case"trend_filter":E(R.data||[]);break;case"trend_signal":H(Te=>[R.data,...Te].slice(0,100));break;case"surge":ye(R.data||[]);break;case"surge_event":et(Te=>[R.data,...Te].slice(0,200));break;case"status":R.data&&R.data.connections&&s(R.data.connections);break}}catch{}},()=>P.close()},[]);const vn=D.useCallback(async()=>{try{const M=await(await fetch("/api/cm-history")).json();S(M.events||[])}catch{}},[]);D.useEffect(()=>{vn();const P=setInterval(vn,5e3);return()=>clearInterval(P)},[vn]);const St=D.useCallback(async()=>{try{const M=await(await fetch("/api/trend-signals")).json();M.signals&&H(M.signals)}catch{}},[]);D.useEffect(()=>{St();const P=setInterval(St,5e3);return()=>clearInterval(P)},[St]);const _t=D.useCallback(async()=>{try{const M=await(await fetch("/api/surge-events?limit=100")).json();M.events&&et(M.events)}catch{}},[]);D.useEffect(()=>{_t();const P=setInterval(_t,1e4);return()=>clearInterval(P)},[_t]);function _(P){if(!P||P.length===0)return;c(P),f(new Date().toLocaleTimeString("zh-CN",{hour12:!1}));const M=gn.current;for(const R of P)for(const Te of ic){const Sl=R.coin+"."+Te,Io=R[Te]||0;M[Sl]?M[Sl].last=Io:M[Sl]={last:Io}}}function z(){const P=new Set,M=[];if(!a)return M;for(const R of a)P.has(R.coin)||(P.add(R.coin),M.push(R.coin));return M}function L(P,M){var R;return(R=gn.current[P+"."+M])==null?void 0:R.last}function V(P,M){return P==null||M==null?"":M>P?"text-green":M`${P}:${M}`).join(" ");return u.jsxs("div",{id:"app",children:[u.jsxs("header",{children:[u.jsx("h1",{children:"⚡ 三所价差异动监控"}),u.jsxs("div",{className:"header-meta",children:[u.jsx("span",{children:e}),u.jsx("span",{className:"sep",children:"|"}),u.jsx("span",{className:l?"status-online":"status-offline",children:n}),jt&&u.jsxs(u.Fragment,{children:[u.jsx("span",{className:"sep",children:"|"}),u.jsx("span",{style:{fontSize:11},children:jt})]})]})]}),u.jsxs("div",{className:"grid",children:[u.jsx(Hf,{filterData:N}),u.jsx(Wf,{signals:C}),u.jsx(Vf,{signals:C}),u.jsx(Af,{data:d}),u.jsx(Bf,{history:y}),u.jsx(Df,{coins:Z,prices:a,getPrevPrice:L,priceClass:V,pricesAge:g}),u.jsx(If,{data:m}),u.jsx(Uf,{snapshots:T,events:kt}),u.jsx($f,{momentum:w})]})]})}function Df({coins:e,prices:t,getPrevPrice:n,priceClass:r,pricesAge:l}){return u.jsxs("section",{className:"card",id:"prices-card",children:[u.jsxs("h2",{children:["💰 实时价格 ",u.jsx("span",{className:"text-dim",style:{fontSize:11},children:l})]}),u.jsx("div",{className:"table-wrap",children:u.jsxs("table",{id:"price-table",children:[u.jsx("thead",{children:u.jsxs("tr",{children:[u.jsx("th",{children:"币种"}),u.jsx("th",{children:"Bitget"}),u.jsx("th",{children:"Binance"}),u.jsx("th",{children:"OKX"}),u.jsx("th",{children:"三所价差"})]})}),u.jsx("tbody",{id:"price-body",children:e.length===0?u.jsx("tr",{children:u.jsx("td",{colSpan:"5",className:"loading",children:"等待数据..."})}):e.map(i=>{const o=t.find(g=>g.coin===i);if(!o)return u.jsxs("tr",{children:[u.jsx("td",{children:i}),u.jsx("td",{className:"text-dim",children:"-"}),u.jsx("td",{className:"text-dim",children:"-"}),u.jsx("td",{className:"text-dim",children:"-"}),u.jsx("td",{className:"text-dim",children:"-"})]},i);const s=ic.map(g=>{const f=o[g],m=n(i,g),v=m?r(m,f||0):"";return u.jsx("td",{className:v,children:Ft(f)},g)}),a=o.spread_3ex,c=a>.3?"text-green":a>.1?"text-yellow":"text-dim";return u.jsxs("tr",{children:[u.jsx("td",{children:u.jsx("strong",{children:i})}),s,u.jsx("td",{className:c,children:a!=null?a.toFixed(4)+"%":"-"})]},i)})})]})})]})}function If({data:e}){function t(n){return n==null?"":n>.3?"text-green":n>.1?"text-yellow":"text-dim"}return u.jsxs("section",{className:"card",id:"spread-card",children:[u.jsx("h2",{children:"📊 三所价差扫描 (BN/OKX/BG)"}),u.jsx("div",{className:"table-wrap",children:u.jsxs("table",{id:"spread-table",children:[u.jsx("thead",{children:u.jsxs("tr",{children:[u.jsx("th",{children:"币种"}),u.jsx("th",{children:"价差%"}),u.jsx("th",{children:"BN价格"}),u.jsx("th",{children:"OKX价格"}),u.jsx("th",{children:"BG价格"}),u.jsx("th",{children:"最高所"}),u.jsx("th",{children:"最低所"})]})}),u.jsx("tbody",{children:!e||e.length===0?u.jsx("tr",{children:u.jsx("td",{colSpan:"7",className:"text-dim",children:"等待扫描数据..."})}):e.slice(0,50).map((n,r)=>u.jsxs("tr",{children:[u.jsx("td",{children:u.jsx("strong",{children:n.coin})}),u.jsx("td",{className:"text-right "+t(n.spread_pct),children:u.jsx("strong",{children:n.spread_pct!=null?n.spread_pct.toFixed(4)+"%":"-"})}),u.jsx("td",{className:"text-right",children:n.bn_price?Ft(n.bn_price):"-"}),u.jsx("td",{className:"text-right",children:n.okx_price?Ft(n.okx_price):"-"}),u.jsx("td",{className:"text-right",children:n.bg_price?Ft(n.bg_price):"-"}),u.jsx("td",{children:n.max_ex||"-"}),u.jsx("td",{children:n.min_ex||"-"})]},n.coin||r))})]})})]})}function Uf({snapshots:e,events:t}){const[n,r]=D.useState("snapshot");function l(i,o){return i==null||o==null?"":i>=o?"text-green":"text-dim"}return u.jsxs("section",{className:"card card-wide",id:"surge-card",children:[u.jsxs("h2",{children:["🚀 Surge异动检测",u.jsxs("span",{style:{marginLeft:12,fontSize:12,fontWeight:400},children:[u.jsx("button",{className:"tab-btn"+(n==="snapshot"?" active":""),onClick:()=>r("snapshot"),children:"实时基线"}),u.jsxs("button",{className:"tab-btn"+(n==="events"?" active":""),onClick:()=>r("events"),children:["事件记录 (",t.length,")"]})]})]}),n==="snapshot"&&u.jsx("div",{className:"table-wrap",style:{maxHeight:350},children:u.jsxs("table",{id:"surge-snapshot-table",children:[u.jsx("thead",{children:u.jsxs("tr",{children:[u.jsx("th",{children:"币种"}),u.jsx("th",{children:"当前价差%"}),u.jsx("th",{children:"基线%"}),u.jsx("th",{children:"阈值%"}),u.jsx("th",{children:"窗口样本"})]})}),u.jsx("tbody",{children:e.length===0?u.jsx("tr",{children:u.jsx("td",{colSpan:"5",className:"text-dim",style:{textAlign:"center",padding:20},children:"收集基线数据中... (需要至少 10 个样本)"})}):e.slice(0,50).map((i,o)=>{const s=i.direction;return u.jsxs("tr",{className:s?"surge-active":"",children:[u.jsx("td",{children:u.jsx("strong",{children:i.coin})}),u.jsxs("td",{className:"text-right "+l(i.spread_pct,i.threshold_pct),style:{fontWeight:700},children:[i.spread_pct!=null?i.spread_pct.toFixed(4)+"%":"-",s?" ⚡":""]}),u.jsx("td",{className:"text-right text-dim",children:i.baseline_pct!=null?i.baseline_pct.toFixed(4)+"%":"-"}),u.jsx("td",{className:"text-right text-dim",children:i.threshold_pct!=null?i.threshold_pct.toFixed(4)+"%":"-"}),u.jsx("td",{className:"text-right text-dim",children:i.window_size||0})]},i.coin||o)})})]})}),n==="events"&&u.jsx("div",{className:"table-wrap",style:{maxHeight:400},children:u.jsxs("table",{id:"surge-events-table",children:[u.jsx("thead",{children:u.jsxs("tr",{children:[u.jsx("th",{children:"时间"}),u.jsx("th",{children:"币种"}),u.jsx("th",{children:"方向"}),u.jsx("th",{children:"价差%"}),u.jsx("th",{children:"基线%"}),u.jsx("th",{children:"阈值%"}),u.jsx("th",{children:"比率"}),u.jsx("th",{children:"BN价格"}),u.jsx("th",{children:"OKX价格"}),u.jsx("th",{children:"BG价格"}),u.jsx("th",{children:"领先所"})]})}),u.jsx("tbody",{children:t.length===0?u.jsx("tr",{children:u.jsx("td",{colSpan:"11",className:"text-dim",style:{textAlign:"center",padding:20},children:"暂无Surge事件记录"})}):t.slice(0,100).map((i,o)=>{const s=i.timestamp?new Date(i.timestamp).toLocaleTimeString("zh-CN",{hour12:!1}):"-";return u.jsxs("tr",{className:i.direction==="up"?"surge-up":"surge-down",children:[u.jsx("td",{className:"text-dim",children:s}),u.jsx("td",{children:u.jsx("strong",{children:i.coin})}),u.jsx("td",{className:i.direction==="up"?"text-green":"text-red",style:{fontWeight:600,textAlign:"center"},children:i.direction==="up"?"↑ 涨":"↓ 跌"}),u.jsx("td",{className:"text-right",style:{fontWeight:700},children:i.spread_pct!=null?i.spread_pct.toFixed(4)+"%":"-"}),u.jsx("td",{className:"text-right text-dim",children:i.baseline_pct!=null?i.baseline_pct.toFixed(4)+"%":"-"}),u.jsx("td",{className:"text-right text-dim",children:i.threshold_pct!=null?i.threshold_pct.toFixed(4)+"%":"-"}),u.jsx("td",{className:"text-right",children:i.ratio!=null?i.ratio.toFixed(1)+"x":"-"}),u.jsx("td",{className:"text-right",children:i.bn_price?Ft(i.bn_price):"-"}),u.jsx("td",{className:"text-right",children:i.okx_price?Ft(i.okx_price):"-"}),u.jsx("td",{className:"text-right",children:i.bg_price?Ft(i.bg_price):"-"}),u.jsx("td",{children:i.leading_exchange||"-"})]},i.timestamp+"-"+i.coin+"-"+o)})})]})})]})}function $f({momentum:e}){const[t,n]=D.useState("score"),[r,l]=D.useState("desc");function i(f){t===f?l(r==="asc"?"desc":"asc"):(n(f),l("desc"))}function o(f){return t!==f?"":r==="asc"?" ▲":" ▼"}const s=[...e].sort((f,m)=>{let v,w;switch(t){case"coin":v=f.coin,w=m.coin;break;case"bg_1s":v=f.bg_1s||0,w=m.bg_1s||0;break;case"bg_5s":v=f.bg_5s||0,w=m.bg_5s||0;break;case"bg_15s":v=f.bg_15s||0,w=m.bg_15s||0;break;case"bn_1s":v=f.bn_1s||0,w=m.bn_1s||0;break;case"bn_5s":v=f.bn_5s||0,w=m.bn_5s||0;break;case"bn_15s":v=f.bn_15s||0,w=m.bn_15s||0;break;case"okx_1s":v=f.okx_1s||0,w=m.okx_1s||0;break;case"okx_5s":v=f.okx_5s||0,w=m.okx_5s||0;break;case"okx_15s":v=f.okx_15s||0,w=m.okx_15s||0;break;default:v=f.score||0,w=m.score||0}return typeof v=="string"?r==="asc"?v.localeCompare(w):w.localeCompare(v):r==="asc"?v-w:w-v});function a(f){switch(f){case"up":return"↑";case"down":return"↓";case"flat":return"→";case"mixed":return"↕";default:return"-"}}function c(f){switch(f){case"up":return"text-green";case"down":return"text-red";case"mixed":return"text-yellow";default:return""}}function g(f){return f==null||f===0?"":f>0?"text-green":"text-red"}return u.jsxs("section",{className:"card card-wide",id:"momentum-card",children:[u.jsx("h2",{children:"⚡ 动量扫描 (价格变动%)"}),u.jsx("div",{className:"table-wrap",style:{maxHeight:400},children:u.jsxs("table",{id:"momentum-table",children:[u.jsx("thead",{children:u.jsxs("tr",{children:[u.jsxs("th",{onClick:()=>i("coin"),style:{cursor:"pointer"},children:["币种",o("coin")]}),u.jsxs("th",{onClick:()=>i("score"),style:{cursor:"pointer"},children:["分数",o("score")]}),u.jsx("th",{children:"方向"}),u.jsxs("th",{onClick:()=>i("bg_1s"),style:{cursor:"pointer"},children:["BG 1s",o("bg_1s")]}),u.jsxs("th",{onClick:()=>i("bg_5s"),style:{cursor:"pointer"},children:["BG 5s",o("bg_5s")]}),u.jsxs("th",{onClick:()=>i("bg_15s"),style:{cursor:"pointer"},children:["BG 15s",o("bg_15s")]}),u.jsxs("th",{onClick:()=>i("bn_1s"),style:{cursor:"pointer"},children:["BN 1s",o("bn_1s")]}),u.jsxs("th",{onClick:()=>i("bn_5s"),style:{cursor:"pointer"},children:["BN 5s",o("bn_5s")]}),u.jsxs("th",{onClick:()=>i("bn_15s"),style:{cursor:"pointer"},children:["BN 15s",o("bn_15s")]}),u.jsxs("th",{onClick:()=>i("okx_1s"),style:{cursor:"pointer"},children:["OKX 1s",o("okx_1s")]}),u.jsxs("th",{onClick:()=>i("okx_5s"),style:{cursor:"pointer"},children:["OKX 5s",o("okx_5s")]}),u.jsxs("th",{onClick:()=>i("okx_15s"),style:{cursor:"pointer"},children:["OKX 15s",o("okx_15s")]})]})}),u.jsx("tbody",{children:s.length===0?u.jsx("tr",{children:u.jsx("td",{colSpan:"12",className:"text-dim",style:{textAlign:"center",padding:20},children:"正在收集动量数据... (需要至少 15 秒数据)"})}):s.slice(0,50).map(f=>u.jsxs("tr",{children:[u.jsx("td",{children:u.jsx("strong",{children:f.coin})}),u.jsxs("td",{className:"text-right",style:{fontWeight:700},children:[f.score.toFixed(4),"%"]}),u.jsx("td",{className:c(f.direction),style:{textAlign:"center",fontSize:18},children:a(f.direction)}),u.jsx("td",{className:"text-right "+g(f.bg_1s),children:f.bg_1s!=null?f.bg_1s.toFixed(3)+"%":"-"}),u.jsx("td",{className:"text-right "+g(f.bg_5s),children:f.bg_5s!=null?f.bg_5s.toFixed(3)+"%":"-"}),u.jsx("td",{className:"text-right "+g(f.bg_15s),children:f.bg_15s!=null?f.bg_15s.toFixed(3)+"%":"-"}),u.jsx("td",{className:"text-right "+g(f.bn_1s),children:f.bn_1s!=null?f.bn_1s.toFixed(3)+"%":"-"}),u.jsx("td",{className:"text-right "+g(f.bn_5s),children:f.bn_5s!=null?f.bn_5s.toFixed(3)+"%":"-"}),u.jsx("td",{className:"text-right "+g(f.bn_15s),children:f.bn_15s!=null?f.bn_15s.toFixed(3)+"%":"-"}),u.jsx("td",{className:"text-right "+g(f.okx_1s),children:f.okx_1s!=null?f.okx_1s.toFixed(3)+"%":"-"}),u.jsx("td",{className:"text-right "+g(f.okx_5s),children:f.okx_5s!=null?f.okx_5s.toFixed(3)+"%":"-"}),u.jsx("td",{className:"text-right "+g(f.okx_15s),children:f.okx_15s!=null?f.okx_15s.toFixed(3)+"%":"-"})]},f.coin))})]})})]})}function Af({data:e}){function t(i){switch(i){case"rising":return"↑ 上涨";case"falling":return"↓ 下跌";default:return"− 中性"}}function n(i){switch(i){case"rising":return"text-green";case"falling":return"text-red";default:return"text-dim"}}function r(i){return i==="up"?"text-green":"text-red"}function l(i){return i==null||i===0?"":i>0?"text-green":"text-red"}return u.jsxs("section",{className:"card card-wide",id:"cm-card",children:[u.jsx("h2",{children:"📊 累积变动 (1min 共识)"}),u.jsx("div",{className:"table-wrap",style:{maxHeight:300},children:u.jsxs("table",{id:"cm-table",children:[u.jsx("thead",{children:u.jsxs("tr",{children:[u.jsx("th",{children:"币种"}),u.jsx("th",{children:"状态"}),u.jsx("th",{children:"方向"}),u.jsx("th",{children:"分数"}),u.jsx("th",{children:"均值%"}),u.jsx("th",{children:"一致"}),u.jsx("th",{children:"BG 1m"}),u.jsx("th",{children:"BN 1m"}),u.jsx("th",{children:"OKX 1m"}),u.jsx("th",{children:"BG 5m"}),u.jsx("th",{children:"BN 5m"}),u.jsx("th",{children:"OKX 5m"}),u.jsx("th",{colSpan:3,style:{borderLeft:"2px solid var(--border)"},children:"1h 趋势"}),u.jsx("th",{children:"BG 1h"}),u.jsx("th",{children:"BN 1h"}),u.jsx("th",{children:"OKX 1h"})]})}),u.jsx("tbody",{children:!e||e.length===0?u.jsx("tr",{children:u.jsx("td",{colSpan:"17",className:"text-dim",style:{textAlign:"center",padding:20},children:"等待累积数据... (需要至少 1 分钟数据)"})}):e.slice(0,30).map(i=>u.jsxs("tr",{className:n(i.state),children:[u.jsx("td",{children:u.jsx("strong",{children:i.coin})}),u.jsx("td",{children:t(i.state)}),u.jsx("td",{className:r(i.direction),style:{textAlign:"center",fontSize:16},children:i.direction==="up"?"↑":"↓"}),u.jsx("td",{className:"text-right",style:{fontWeight:700},children:(i.score||0).toFixed(2)}),u.jsxs("td",{className:"text-right",children:[(i.avg_1m||0).toFixed(3),"%"]}),u.jsxs("td",{className:"text-right",children:[i.ex_agree||0,"/",i.ex_total||0]}),u.jsx("td",{className:"text-right "+l(i.bg_1m),children:i.bg_1m!=null?i.bg_1m.toFixed(3)+"%":"-"}),u.jsx("td",{className:"text-right "+l(i.bn_1m),children:i.bn_1m!=null?i.bn_1m.toFixed(3)+"%":"-"}),u.jsx("td",{className:"text-right "+l(i.okx_1m),children:i.okx_1m!=null?i.okx_1m.toFixed(3)+"%":"-"}),u.jsx("td",{className:"text-right "+l(i.bg_5m),children:i.bg_5m!=null?i.bg_5m.toFixed(3)+"%":"-"}),u.jsx("td",{className:"text-right "+l(i.bn_5m),children:i.bn_5m!=null?i.bn_5m.toFixed(3)+"%":"-"}),u.jsx("td",{className:"text-right "+l(i.okx_5m),children:i.okx_5m!=null?i.okx_5m.toFixed(3)+"%":"-"}),u.jsxs("td",{className:"text-right "+r(i.direction),style:{fontWeight:600,borderLeft:"2px solid var(--border)"},children:[(i.avg_1h||0).toFixed(2),"%"]}),u.jsx("td",{className:"text-right "+l(i.bg_1h),children:i.bg_1h!=null?i.bg_1h.toFixed(2)+"%":"-"}),u.jsx("td",{className:"text-right "+l(i.bn_1h),children:i.bn_1h!=null?i.bn_1h.toFixed(2)+"%":"-"}),u.jsx("td",{className:"text-right "+l(i.okx_1h),children:i.okx_1h!=null?i.okx_1h.toFixed(2)+"%":"-"})]},i.coin))})]})})]})}function Bf({history:e}){function t(i){switch(i){case"rising":return"↑ 上涨";case"falling":return"↓ 下跌";default:return"− 中性"}}function n(i){switch(i){case"rising":return"text-green";case"falling":return"text-red";default:return"text-dim"}}function r(i){return i==="up"?"text-green":"text-red"}function l(i){return i==null||i===0?"":i>0?"text-green":"text-red"}return u.jsxs("section",{className:"card card-wide",id:"cm-history-card",children:[u.jsx("h2",{children:"📋 累积变动事件记录"}),u.jsx("div",{className:"table-wrap",style:{maxHeight:400},children:u.jsxs("table",{id:"cm-history-table",children:[u.jsx("thead",{children:u.jsxs("tr",{children:[u.jsx("th",{children:"时间"}),u.jsx("th",{children:"币种"}),u.jsx("th",{children:"转换"}),u.jsx("th",{children:"方向"}),u.jsx("th",{children:"分数"}),u.jsx("th",{children:"均值%"}),u.jsx("th",{children:"一致"}),u.jsx("th",{children:"BG 1m"}),u.jsx("th",{children:"BN 1m"}),u.jsx("th",{children:"OKX 1m"}),u.jsx("th",{children:"BG 5m"}),u.jsx("th",{children:"BN 5m"}),u.jsx("th",{children:"OKX 5m"})]})}),u.jsx("tbody",{children:e.length===0?u.jsx("tr",{children:u.jsx("td",{colSpan:"13",className:"text-dim",style:{textAlign:"center",padding:20},children:"暂无累积变动事件记录"})}):e.slice(0,100).map((i,o)=>u.jsxs("tr",{children:[u.jsx("td",{className:"text-dim",children:i.created_at?new Date(i.created_at).toLocaleTimeString("zh-CN",{hour12:!1}):"-"}),u.jsx("td",{children:u.jsx("strong",{children:i.coin})}),u.jsxs("td",{className:n(i.new_state),children:[i.prev_state," → ",t(i.new_state)]}),u.jsx("td",{className:r(i.direction),style:{textAlign:"center",fontSize:16},children:i.direction==="up"?"↑":"↓"}),u.jsx("td",{className:"text-right",children:(i.score||0).toFixed(2)}),u.jsxs("td",{className:"text-right",children:[(i.avg_change||0).toFixed(3),"%"]}),u.jsxs("td",{className:"text-right",children:[i.ex_agree||0,"/",i.ex_total||0]}),u.jsx("td",{className:"text-right "+l(i.bg_1m),children:i.bg_1m!=null?i.bg_1m.toFixed(3)+"%":"-"}),u.jsx("td",{className:"text-right "+l(i.bn_1m),children:i.bn_1m!=null?i.bn_1m.toFixed(3)+"%":"-"}),u.jsx("td",{className:"text-right "+l(i.okx_1m),children:i.okx_1m!=null?i.okx_1m.toFixed(3)+"%":"-"}),u.jsx("td",{className:"text-right "+l(i.bg_5m),children:i.bg_5m!=null?i.bg_5m.toFixed(3)+"%":"-"}),u.jsx("td",{className:"text-right "+l(i.bn_5m),children:i.bn_5m!=null?i.bn_5m.toFixed(3)+"%":"-"}),u.jsx("td",{className:"text-right "+l(i.okx_5m),children:i.okx_5m!=null?i.okx_5m.toFixed(3)+"%":"-"})]},(i.id||o)+"-cm"))})]})})]})}function Hf({filterData:e}){const t=e.filter(c=>c.passes_filter).length,n=e.filter(c=>c.signal_score>=80).length,r=e.filter(c=>c.signal_score>=50&&c.signal_score<80).length,l=e.filter(c=>c.fresh_anomaly).length;let i=`高分${n} 中分${r}`;l>0?(i+=` | ${l}币异动中`,t>0&&(i+=` → ${t}通过!`)):i+=" | 等待异动信号";function o(c){return c==null?"":c>=80?"text-green":c>=50?"text-yellow":"text-dim"}function s(c){return c==null||c<=1.5?"":c>3?"text-red":"text-orange"}function a(c){return c==null||c===0?"":c>0?"text-green":"text-red"}return u.jsxs("section",{className:"card card-wide",id:"trend-filter-card",children:[u.jsxs("h2",{children:["趋势过滤 (",t,"通过 / ",e.length,") ",u.jsx("span",{className:"text-dim",style:{fontSize:12,fontWeight:400},children:i})]}),u.jsx("div",{className:"table-wrap",style:{maxHeight:400},children:u.jsxs("table",{id:"trend-filter-table",children:[u.jsx("thead",{children:u.jsxs("tr",{children:[u.jsx("th",{children:"币种"}),u.jsx("th",{children:"分数"}),u.jsx("th",{children:"24h范围"}),u.jsx("th",{children:"基线"}),u.jsx("th",{children:"1h范围"}),u.jsx("th",{children:"成交量比"}),u.jsx("th",{children:"1h变化"}),u.jsx("th",{children:"EMA52"}),u.jsx("th",{children:"EMA斜率"}),u.jsx("th",{children:"现价"}),u.jsx("th",{children:"> EMA"}),u.jsx("th",{children:"安静24h"}),u.jsx("th",{children:"安静1h"}),u.jsx("th",{children:"异动"}),u.jsx("th",{children:"更新于"})]})}),u.jsx("tbody",{children:e.length===0?u.jsx("tr",{children:u.jsx("td",{colSpan:"15",className:"text-dim",style:{textAlign:"center",padding:20},children:"等待K线数据..."})}):e.map(c=>u.jsxs("tr",{className:c.passes_filter?"filter-pass":"",children:[u.jsx("td",{children:u.jsx("strong",{children:c.coin})}),u.jsx("td",{className:"text-right "+o(c.signal_score),style:{fontWeight:700},children:c.signal_score!=null?c.signal_score.toFixed(0):"-"}),u.jsx("td",{className:"text-right "+(c.quiet_24h?"text-green":""),children:c.range_24h!=null?c.range_24h.toFixed(2)+"%":"-"}),u.jsx("td",{className:"text-right text-dim",children:c.vol_baseline!=null?c.vol_baseline.toFixed(2)+"%":"-"}),u.jsx("td",{className:"text-right "+(c.quiet_1h?"text-green":""),children:c.range_1h!=null?c.range_1h.toFixed(2)+"%":"-"}),u.jsx("td",{className:"text-right "+s(c.volume_ratio),children:c.volume_ratio!=null?c.volume_ratio.toFixed(2)+"x":"-"}),u.jsx("td",{className:"text-right "+(c.change_1h>0?"text-green":c.change_1h<0?"text-red":""),children:c.change_1h!=null?(c.change_1h>0?"+":"")+c.change_1h.toFixed(2)+"%":"-"}),u.jsx("td",{className:"text-right",children:c.ema_52?c.ema_52.toFixed(4):"-"}),u.jsx("td",{className:"text-right "+a(c.ema_slope),children:c.ema_slope!=null?(c.ema_slope>0?"+":"")+c.ema_slope.toFixed(3)+"%":"-"}),u.jsx("td",{className:"text-right",children:c.current_price?c.current_price.toFixed(4):"-"}),u.jsx("td",{className:c.price_above_ema?"text-green":"text-red",children:c.price_above_ema!=null?c.price_above_ema?"↑":"↓":"-"}),u.jsx("td",{className:c.quiet_24h?"text-green":"text-dim",children:c.quiet_24h!=null?c.quiet_24h?"✓":"✗":"-"}),u.jsx("td",{className:c.quiet_1h?"text-green":"text-dim",children:c.quiet_1h!=null?c.quiet_1h?"✓":"✗":"-"}),u.jsx("td",{className:c.fresh_anomaly?"text-orange":"text-dim",children:c.fresh_anomaly!=null&&c.fresh_anomaly?"⚠":"-"}),u.jsx("td",{className:"text-dim",children:c.last_updated?new Date(c.last_updated).toLocaleTimeString("zh-CN",{hour12:!1}):"-"})]},c.coin))})]})})]})}function Wf({signals:e}){const t=e.filter(r=>r.category==="full"),n=t.filter(r=>r.type==="enter").length;return u.jsxs("section",{className:"card card-wide",id:"trend-signal-card",children:[u.jsxs("h2",{children:["完整信号 (异动+分数≥70) ",n>0&&u.jsxs("span",{className:"text-green",style:{fontSize:12,fontWeight:400,marginLeft:8},children:["共",n,"条"]})]}),u.jsx("div",{className:"table-wrap",style:{maxHeight:350},children:u.jsxs("table",{id:"trend-signal-table",children:[u.jsx("thead",{children:u.jsxs("tr",{children:[u.jsx("th",{children:"时间"}),u.jsx("th",{children:"币种"}),u.jsx("th",{children:"类型"}),u.jsx("th",{children:"分数"}),u.jsx("th",{children:"价格"}),u.jsx("th",{children:"成交量比"}),u.jsx("th",{children:"EMA斜率"}),u.jsx("th",{children:"趋势状态"})]})}),u.jsx("tbody",{children:t.length===0?u.jsx("tr",{children:u.jsx("td",{colSpan:"8",className:"text-dim",style:{textAlign:"center",padding:20},children:"等待完整信号... (FreshAnomaly + 分数≥70)"})}):t.slice(0,50).map((r,l)=>{const i=r.type==="enter"?"signal-enter":"signal-exit",o=r.timestamp?new Date(r.timestamp).toLocaleTimeString("zh-CN",{hour12:!1}):"-";return u.jsxs("tr",{className:i,children:[u.jsx("td",{className:"text-dim",children:o}),u.jsx("td",{children:u.jsx("strong",{children:r.coin})}),u.jsx("td",{className:r.type==="enter"?"text-green":"text-dim",style:{fontWeight:600},children:r.type==="enter"?"开":"关"}),u.jsx("td",{className:"text-right",style:{fontWeight:700},children:r.signal_score!=null?r.signal_score.toFixed(0):"-"}),u.jsx("td",{className:"text-right",children:r.price?r.price.toFixed(4):"-"}),u.jsx("td",{className:"text-right",children:r.volume_ratio!=null?r.volume_ratio.toFixed(2)+"x":"-"}),u.jsx("td",{className:"text-right",children:r.ema_slope!=null?(r.ema_slope>0?"+":"")+r.ema_slope.toFixed(3)+"%":"-"}),u.jsx("td",{className:"text-dim",children:r.state||"-"})]},"full-"+r.timestamp+"-"+r.coin+"-"+l)})})]})})]})}function Vf({signals:e}){const t=e.filter(r=>r.category==="high"),n=t.filter(r=>r.type==="enter").length;return u.jsxs("section",{className:"card card-wide",id:"high-score-card",children:[u.jsxs("h2",{children:["高分信号 (分数≥90) ",n>0&&u.jsxs("span",{className:"text-green",style:{fontSize:12,fontWeight:400,marginLeft:8},children:["共",n,"条"]})]}),u.jsx("div",{className:"table-wrap",style:{maxHeight:350},children:u.jsxs("table",{id:"high-score-table",children:[u.jsx("thead",{children:u.jsxs("tr",{children:[u.jsx("th",{children:"时间"}),u.jsx("th",{children:"币种"}),u.jsx("th",{children:"类型"}),u.jsx("th",{children:"分数"}),u.jsx("th",{children:"价格"}),u.jsx("th",{children:"成交量比"}),u.jsx("th",{children:"EMA斜率"}),u.jsx("th",{children:"趋势状态"})]})}),u.jsx("tbody",{children:t.length===0?u.jsx("tr",{children:u.jsx("td",{colSpan:"8",className:"text-dim",style:{textAlign:"center",padding:20},children:"等待高分信号... (分数≥90)"})}):t.slice(0,50).map((r,l)=>{const i=r.type==="enter"?"signal-enter":"signal-exit",o=r.timestamp?new Date(r.timestamp).toLocaleTimeString("zh-CN",{hour12:!1}):"-";return u.jsxs("tr",{className:i,children:[u.jsx("td",{className:"text-dim",children:o}),u.jsx("td",{children:u.jsx("strong",{children:r.coin})}),u.jsx("td",{className:r.type==="enter"?"text-green":"text-dim",style:{fontWeight:600},children:r.type==="enter"?"开":"关"}),u.jsx("td",{className:"text-right",style:{fontWeight:700},children:r.signal_score!=null?r.signal_score.toFixed(0):"-"}),u.jsx("td",{className:"text-right",children:r.price?r.price.toFixed(4):"-"}),u.jsx("td",{className:"text-right",children:r.volume_ratio!=null?r.volume_ratio.toFixed(2)+"x":"-"}),u.jsx("td",{className:"text-right",children:r.ema_slope!=null?(r.ema_slope>0?"+":"")+r.ema_slope.toFixed(3)+"%":"-"}),u.jsx("td",{className:"text-dim",children:r.state||"-"})]},"high-"+r.timestamp+"-"+r.coin+"-"+l)})})]})})]})}Yl.createRoot(document.getElementById("root")).render(u.jsx(Sc.StrictMode,{children:u.jsx(Mf,{})})); diff --git a/frontend/dist/assets/index-vvNDQq2K.css b/frontend/dist/assets/index-vvNDQq2K.css new file mode 100644 index 0000000..7968712 --- /dev/null +++ b/frontend/dist/assets/index-vvNDQq2K.css @@ -0,0 +1 @@ +:root{--bg: #0d1117;--card: #161b22;--border: #30363d;--text: #c9d1d9;--text-dim: #8b949e;--accent: #58a6ff;--green: #3fb950;--red: #f85149;--yellow: #d29922;--blue: #58a6ff}*{margin:0;padding:0;box-sizing:border-box}body{font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,Arial,sans-serif;background:var(--bg);color:var(--text);font-size:14px;line-height:1.5;min-height:100vh}#app{max-width:1440px;margin:0 auto;padding:16px}header{display:flex;justify-content:space-between;align-items:center;padding:12px 16px;background:var(--card);border:1px solid var(--border);border-radius:8px;margin-bottom:16px}header h1{font-size:18px;font-weight:600}.header-meta{display:flex;align-items:center;gap:8px;font-size:13px;color:var(--text-dim)}.sep{color:var(--border)}.status-offline{color:var(--red)}.status-online{color:var(--green)}.grid{display:grid;grid-template-columns:1fr 1fr;gap:12px}.card-wide{grid-column:1 / -1}.card{background:var(--card);border:1px solid var(--border);border-radius:8px;padding:12px}.card h2{font-size:14px;font-weight:600;color:var(--text-dim);margin-bottom:10px;padding-bottom:8px;border-bottom:1px solid var(--border)}.table-wrap{overflow-x:auto;max-height:320px;overflow-y:auto}table{width:100%;border-collapse:collapse;font-size:13px}th{text-align:left;padding:6px 8px;color:var(--text-dim);font-weight:500;font-size:11px;text-transform:uppercase;letter-spacing:.5px;position:sticky;top:0;background:var(--card);border-bottom:1px solid var(--border)}td{padding:5px 8px;border-bottom:1px solid rgba(48,54,61,.5);white-space:nowrap}tr:hover td{background:#58a6ff0d}.loading{text-align:center;color:var(--text-dim);padding:20px!important}.text-green{color:var(--green)}.text-red{color:var(--red)}.text-yellow{color:var(--yellow)}.text-dim{color:var(--text-dim)}.text-right{text-align:right}.text-orange{color:var(--yellow)}::-webkit-scrollbar{width:6px;height:6px}::-webkit-scrollbar-track{background:transparent}::-webkit-scrollbar-thumb{background:var(--border);border-radius:3px}::-webkit-scrollbar-thumb:hover{background:#484f58}@media (max-width: 768px){.grid{grid-template-columns:1fr}header{flex-direction:column;gap:8px}}.tab-btn{background:none;border:1px solid var(--border);color:var(--text-dim);padding:3px 10px;border-radius:4px;cursor:pointer;font-size:12px;margin-left:6px}.tab-btn.active{background:var(--accent);color:#fff;border-color:var(--accent)}.tab-btn:hover:not(.active){color:var(--text);border-color:var(--text-dim)}#surge-card{grid-column:1 / -1}#surge-snapshot-table td,#surge-events-table td{font-variant-numeric:tabular-nums}.surge-active td{background:#3fb9500f}.surge-active:hover td{background:#3fb9501f!important}.surge-up td{background:#3fb9500a}.surge-up:hover td{background:#3fb9501a!important}.surge-down td{background:#f851490a}.surge-down:hover td{background:#f851491a!important}#spread-card #spread-table td{font-variant-numeric:tabular-nums}#momentum-card{grid-column:1 / -1}#momentum-table th{cursor:pointer;-webkit-user-select:none;user-select:none}#momentum-table th:hover{color:var(--accent)}#momentum-table td{font-variant-numeric:tabular-nums}#trend-card{grid-column:1 / -1}#trend-table th{-webkit-user-select:none;user-select:none}#trend-table td{font-variant-numeric:tabular-nums}.trend-state{font-weight:600;font-size:12px}.trend-alert{background:#d299220d}.trend-alert:hover td{background:#d299221a!important}.trend-confirmed{background:#3fb95014}.trend-confirmed:hover td{background:#3fb95026!important}.trend-exhausting{background:#8b949e0d}.trend-exhausting:hover td{background:#8b949e1a!important}#trend-filter-card{grid-column:1 / -1}#trend-filter-table td{font-variant-numeric:tabular-nums}.filter-pass td{background:#3fb9500f}.filter-pass:hover td{background:#3fb9501f!important}#trend-signal-card{grid-column:1 / -1}#trend-signal-table td{font-variant-numeric:tabular-nums}#high-score-card{grid-column:1 / -1}#high-score-table td{font-variant-numeric:tabular-nums}.signal-enter td{background:#3fb95014}.signal-enter:hover td{background:#3fb95026!important}.signal-exit td{background:#8b949e0d}.signal-exit:hover td{background:#8b949e1a!important}#cm-card,#cm-history-card{grid-column:1 / -1}#cm-table td,#cm-history-table td{font-variant-numeric:tabular-nums} diff --git a/frontend/dist/index.html b/frontend/dist/index.html index aafcf58..6d4c500 100644 --- a/frontend/dist/index.html +++ b/frontend/dist/index.html @@ -4,8 +4,8 @@ Exchange Monitor Dashboard - - + +
diff --git a/frontend/src/App.css b/frontend/src/App.css index 08d1378..5d12b1c 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -67,29 +67,6 @@ header h1 { font-size: 18px; font-weight: 600; } border-bottom: 1px solid var(--border); } -/* Stats row */ -.stats-row { - display: flex; - gap: 16px; - flex-wrap: wrap; -} -.stat { - display: flex; - flex-direction: column; - align-items: center; - min-width: 60px; -} -.stat label { font-size: 11px; color: var(--text-dim); margin-bottom: 2px; } -.stat span { font-size: 20px; font-weight: 700; } -.pct-green { color: var(--green); } -.pct-red { color: var(--red); } -.pct-gray { color: var(--text-dim); } -.pct-yellow { color: var(--yellow); } -.pct-blue { color: var(--blue); } - -/* Connection status dots */ -#conn-detail { font-size: 11px; white-space: nowrap; } - /* Tables */ .table-wrap { overflow-x: auto; @@ -120,7 +97,6 @@ td { white-space: nowrap; } tr:hover td { background: rgba(88, 166, 255, 0.05); } -.trade-row { cursor: pointer; } .loading { text-align: center; color: var(--text-dim); padding: 20px !important; } .text-green { color: var(--green); } @@ -128,6 +104,7 @@ tr:hover td { background: rgba(88, 166, 255, 0.05); } .text-yellow { color: var(--yellow); } .text-dim { color: var(--text-dim); } .text-right { text-align: right; } +.text-orange { color: var(--yellow); } /* Scrollbar */ ::-webkit-scrollbar { width: 6px; height: 6px; } @@ -139,91 +116,42 @@ tr:hover td { background: rgba(88, 166, 255, 0.05); } @media (max-width: 768px) { .grid { grid-template-columns: 1fr; } header { flex-direction: column; gap: 8px; } - .stats-row { justify-content: center; } } -/* Blacklist items */ -#bl-body { display: flex; gap: 8px; flex-wrap: wrap; } -.bl-item { - background: rgba(248, 81, 73, 0.1); - border: 1px solid rgba(248, 81, 73, 0.3); - border-radius: 4px; - padding: 4px 10px; - font-size: 12px; - color: var(--red); - cursor: default; -} - -/* Trade Detail Modal */ -.modal-overlay { - position: fixed; - top: 0; left: 0; right: 0; bottom: 0; - background: rgba(0,0,0,0.7); - z-index: 1000; - display: flex; - align-items: flex-start; - justify-content: center; - padding: 40px 16px; - overflow-y: auto; -} -.modal-content { - background: var(--card); - border: 1px solid var(--border); - border-radius: 12px; - max-width: 700px; - width: 100%; - box-shadow: 0 8px 32px rgba(0,0,0,0.5); -} -.modal-header { - display: flex; - justify-content: space-between; - align-items: center; - padding: 16px 20px; - border-bottom: 1px solid var(--border); -} -.modal-header h2 { font-size: 16px; margin: 0; padding: 0; border: none; color: var(--text); } -.modal-close { +/* Tab buttons (SurgeCard) */ +.tab-btn { background: none; - border: none; + border: 1px solid var(--border); color: var(--text-dim); - font-size: 20px; - cursor: pointer; - padding: 4px 8px; + padding: 3px 10px; border-radius: 4px; - line-height: 1; -} -.modal-close:hover { background: rgba(255,255,255,0.1); color: var(--text); } -#trade-detail-body { padding: 0; } -.detail-grid { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 0; -} -.detail-section { - padding: 14px 20px; - border-bottom: 1px solid rgba(48,54,61,0.4); -} -.detail-section:last-child { border-bottom: none; } -.detail-section-full { grid-column: 1 / -1; } -.detail-section h3 { + cursor: pointer; font-size: 12px; - color: var(--text-dim); - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.5px; - margin-bottom: 8px; + margin-left: 6px; } -.detail-row { - display: flex; - justify-content: space-between; - padding: 3px 0; - font-size: 13px; +.tab-btn.active { + background: var(--accent); + color: #fff; + border-color: var(--accent); } -.detail-row .label { color: var(--text-dim); } -.detail-row .value { font-weight: 500; } -.detail-orders { width: 100%; font-size: 12px; } -.detail-orders th { background: var(--bg); font-size: 10px; } -.detail-orders td { padding: 4px 6px; } +.tab-btn:hover:not(.active) { + color: var(--text); + border-color: var(--text-dim); +} + +/* Surge Card */ +#surge-card { grid-column: 1 / -1; } +#surge-snapshot-table td, +#surge-events-table td { font-variant-numeric: tabular-nums; } +.surge-active td { background: rgba(63, 185, 80, 0.06); } +.surge-active:hover td { background: rgba(63, 185, 80, 0.12) !important; } +.surge-up td { background: rgba(63, 185, 80, 0.04); } +.surge-up:hover td { background: rgba(63, 185, 80, 0.1) !important; } +.surge-down td { background: rgba(248, 81, 73, 0.04); } +.surge-down:hover td { background: rgba(248, 81, 73, 0.1) !important; } + +/* Spread Card */ +#spread-card #spread-table td { font-variant-numeric: tabular-nums; } /* Momentum Card */ #momentum-card { grid-column: 1 / -1; } @@ -244,15 +172,11 @@ tr:hover td { background: rgba(88, 166, 255, 0.05); } .trend-exhausting:hover td { background: rgba(139, 148, 158, 0.1) !important; } /* Trend Filter Card */ -.text-orange { color: var(--yellow); } #trend-filter-card { grid-column: 1 / -1; } #trend-filter-table td { font-variant-numeric: tabular-nums; } .filter-pass td { background: rgba(63, 185, 80, 0.06); } .filter-pass:hover td { background: rgba(63, 185, 80, 0.12) !important; } -/* blue text for categories */ -.text-blue { color: #58a6ff; } - /* Trend Signal Card */ #trend-signal-card { grid-column: 1 / -1; } #trend-signal-table td { font-variant-numeric: tabular-nums; } @@ -262,3 +186,8 @@ tr:hover td { background: rgba(88, 166, 255, 0.05); } .signal-enter:hover td { background: rgba(63, 185, 80, 0.15) !important; } .signal-exit td { background: rgba(139, 148, 158, 0.05); } .signal-exit:hover td { background: rgba(139, 148, 158, 0.1) !important; } + +/* Cumulative Change Cards */ +#cm-card { grid-column: 1 / -1; } +#cm-history-card { grid-column: 1 / -1; } +#cm-table td, #cm-history-table td { font-variant-numeric: tabular-nums; } diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index bda7589..c86c61d 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,6 +1,6 @@ import { useState, useEffect, useRef, useCallback } from 'react' -const EXCHANGES = ['HyperLiquid', 'Bitget', 'Binance', 'OKX'] +const EXCHANGES = ['Bitget', 'Binance', 'OKX'] function formatPrice(p) { if (p == null || p <= 0) return '-' @@ -9,29 +9,22 @@ function formatPrice(p) { return p.toFixed(6) } -function pnlClass(val) { - if (val == null) return '' - return val > 0 ? 'text-green' : val < 0 ? 'text-red' : '' -} - export default function App() { const [clock, setClock] = useState('--:--:--') const [connStatus, setConnStatus] = useState('● 未连接') const [connOnline, setConnOnline] = useState(false) - const [connDetail, setConnDetail] = useState('') + const [connInfo, setConnInfo] = useState({}) const [prices, setPrices] = useState([]) const [pricesAge, setPricesAge] = useState('') - const [opps, setOpps] = useState([]) - const [positions, setPositions] = useState([]) - const [blacklist, setBlacklist] = useState([]) - const [stats, setStats] = useState({}) - const [trades, setTrades] = useState([]) + const [spread3Ex, setSpread3Ex] = useState([]) const [momentum, setMomentum] = useState([]) const [trendData, setTrendData] = useState([]) const [cmData, setCmData] = useState([]) const [cmHistory, setCmHistory] = useState([]) const [trendFilter, setTrendFilter] = useState([]) const [trendSignals, setTrendSignals] = useState([]) + const [surgeSnapshots, setSurgeSnapshots] = useState([]) + const [surgeEvents, setSurgeEvents] = useState([]) const priceCacheRef = useRef({}) // Clock @@ -66,14 +59,8 @@ export default function App() { case 'prices': handlePrices(msg.data) break - case 'arb': - setOpps(msg.data || []) - break - case 'positions': - setPositions(msg.data || []) - break - case 'blacklist': - setBlacklist(msg.data || []) + case 'spread_3ex': + setSpread3Ex(msg.data || []) break case 'momentum': setMomentum(msg.data || []) @@ -88,17 +75,18 @@ export default function App() { setTrendFilter(msg.data || []) break case 'trend_signal': - // Prepend new signal to list setTrendSignals(prev => [msg.data, ...prev].slice(0, 100)) break - case 'stats': - setStats(msg.data || {}) - if (msg.data && msg.data.blacklist) { - setBlacklist(msg.data.blacklist) - } + case 'surge': + setSurgeSnapshots(msg.data || []) break - case 'trade_close': - loadTrades() + case 'surge_event': + setSurgeEvents(prev => [msg.data, ...prev].slice(0, 200)) + break + case 'status': + if (msg.data && msg.data.connections) { + setConnInfo(msg.data.connections) + } break } } catch (err) { @@ -109,23 +97,6 @@ export default function App() { return () => es.close() }, []) - // Load trades from API - const loadTrades = useCallback(async () => { - try { - const resp = await fetch('/api/trades') - const data = await resp.json() - setTrades(data.trades || []) - } catch (err) { - // ignore - } - }, []) - - useEffect(() => { - loadTrades() - const id = setInterval(loadTrades, 10000) - return () => clearInterval(id) - }, [loadTrades]) - // Load cumulative history const loadCmHistory = useCallback(async () => { try { @@ -160,13 +131,29 @@ export default function App() { return () => clearInterval(id) }, [loadTrendSignals]) + // Load surge events from API + const loadSurgeEvents = useCallback(async () => { + try { + const resp = await fetch('/api/surge-events?limit=100') + const data = await resp.json() + if (data.events) setSurgeEvents(data.events) + } catch (err) { + // ignore + } + }, []) + + useEffect(() => { + loadSurgeEvents() + const id = setInterval(loadSurgeEvents, 10000) + return () => clearInterval(id) + }, [loadSurgeEvents]) + // Handle prices function handlePrices(data) { if (!data || data.length === 0) return setPrices(data) setPricesAge(new Date().toLocaleTimeString('zh-CN', { hour12: false })) - // Update price cache for color changes const cache = priceCacheRef.current for (const row of data) { for (const ex of EXCHANGES) { @@ -206,19 +193,24 @@ export default function App() { const coins = getCoinList() + // Connection status detail + const connDetail = Object.entries(connInfo) + .map(([ex, st]) => `${ex}:${st}`).join(' ') + return (
-

⚡ 跨交易所套利监控

+

⚡ 三所价差异动监控

{clock} | {connStatus} + {connDetail && <>|{connDetail}}
- {/* Trend Filter (K-line quiet + EMA52) — 最优先 */} + {/* Trend Filter (K-line quiet + EMA52) */} {/* Full Signal Records (FreshAnomaly + Score >= 70) */} @@ -236,28 +228,14 @@ export default function App() { {/* Price Table */} - {/* Arbitrage Opportunities */} - + {/* 3-Exchange Spread Scan */} + + + {/* Surge Detection */} + {/* Momentum Scanner */} - - {/* ---- 交易相关 ---- */} - - {/* Stats Summary */} - - - {/* Open Positions */} - - - {/* PnL Growth Chart */} - - - {/* Recent Trades */} - - - {/* Blacklist */} -
) @@ -265,143 +243,6 @@ export default function App() { // ============ Components ============ -function StatsCard({ stats }) { - const d = stats.detail - const capital = stats.capital - // Format connection status - let connHtml = '' - if (stats.connections) { - connHtml = Object.entries(stats.connections) - .map(([ex, status]) => `${ex}:${status}`).join(' ') - } - // Format exchange funds - let exFundsHtml = '' - if (stats.exchange_funds) { - exFundsHtml = Object.entries(stats.exchange_funds) - .map(([ex, f]) => `${ex}: $${f.balance.toFixed(2)}`) - .join(' | ') - } - return ( -
-

📊 统计数据

-
-
{stats.total_trades || 0}
-
{stats.converged || 0}
-
{stats.diverged || 0}
-
{stats.flat || 0}
-
{stats.open_positions || 0} / 5
-
{stats.coins || 0}
-
{connHtml}
-
- {exFundsHtml && ( -
-
{exFundsHtml}
-
- )} - {d && ( -
-
{(d.total_pnl_usd != null ? '$' + d.total_pnl_usd.toFixed(2) : '—') + (d.capital_pnl != null ? ' (' + d.capital_pnl.toFixed(4) + '%)' : '')}
-
{capital != null ? '$' + capital.toFixed(0) : '—'}
-
{d.win_rate != null ? d.win_rate.toFixed(1) + '%' : '—'}
-
{d.max_profit != null ? d.max_profit.toFixed(4) + '%' : '—'}
-
{d.max_loss != null ? d.max_loss.toFixed(4) + '%' : '—'}
-
{d.avg_dur || '—'}
-
- )} -
- ) -} - -function PositionsCard({ positions }) { - const [modalOpen, setModalOpen] = useState(false) - const [modalTrade, setModalTrade] = useState(null) - const [modalOrders, setModalOrders] = useState([]) - - function openPositionDetail(id) { - if (!id) return - setModalOpen(true) - setModalTrade(null) - setModalOrders([]) - fetch('/api/trade/' + id) - .then(r => r.json()) - .then(data => { - setModalTrade(data.trade) - setModalOrders(data.orders || []) - }) - .catch(() => { - setModalTrade({ ID: id }) - }) - } - - function closeModal() { - setModalOpen(false) - } - - useEffect(() => { - if (!modalOpen) return - function handler(e) { - if (e.key === 'Escape') closeModal() - } - document.addEventListener('keydown', handler) - return () => document.removeEventListener('keydown', handler) - }, [modalOpen]) - - return ( - <> -
-

🔒 当前持仓

-
- - - - - - {positions.length === 0 ? ( - - ) : ( - [...positions].sort((a, b) => a.coin.localeCompare(b.coin)).map(p => ( - openPositionDetail(p.db_trade_id)}> - - - - - - - - - - )) - )} - -
币种方向规模入价差现价差估盈亏加仓时长
无持仓
{p.coin}{p.direction}${(p.amount_usd || 0).toFixed(0)}{(p.entry_spread || 0).toFixed(4)}%{p.current_spread != null ? p.current_spread.toFixed(4) + '%' : '-'}{p.pnl_est != null ? '$' + p.pnl_est.toFixed(4) : '-'}{p.scales || 0}{p.duration || '-'}
-
-
- {modalOpen && ( - - )} - - ) -} - -function BlacklistCard({ blacklist }) { - return ( -
-

⛔ 黑名单

-
- {!blacklist || blacklist.length === 0 ? ( - 暂无 - ) : ( - blacklist.map((item, i) => { - const sec = item.remaining_sec || 0 - const remaining = sec > 0 ? `${Math.floor(sec/60)}m${sec%60}s` : '' - return ⛔ {item.coin}{remaining ? ` (${remaining})` : ''} - }) - )} -
-
- ) -} - function PriceTable({ coins, prices, getPrevPrice, priceClass, pricesAge }) { return (
@@ -409,15 +250,15 @@ function PriceTable({ coins, prices, getPrevPrice, priceClass, pricesAge }) {
- + {coins.length === 0 ? ( - + ) : coins.map(coin => { const row = prices.find(p => p.coin === coin) if (!row) { - return + return } const cells = EXCHANGES.map(ex => { const p = row[ex] @@ -425,19 +266,13 @@ function PriceTable({ coins, prices, getPrevPrice, priceClass, pricesAge }) { const cls = prev ? priceClass(prev, p || 0) : '' return }) - const spread = row['bg_hl_spread'] - const spreadCls = spread > 0.2 ? 'text-green' : spread < -0.2 ? 'text-red' : '' - const nb = row['net_bg_to_hl'] - const nh = row['net_hl_to_bg'] - const nbCls = nb != null ? pnlClass(nb) : '' - const nhCls = nh != null ? pnlClass(nh) : '' + const spread = row['spread_3ex'] + const spreadCls = spread > 0.3 ? 'text-green' : spread > 0.1 ? 'text-yellow' : 'text-dim' return ( {cells} - - ) })} @@ -448,30 +283,37 @@ function PriceTable({ coins, prices, getPrevPrice, priceClass, pricesAge }) { ) } -function ArbTable({ opps }) { +// ============ 3-Exchange Spread Scan ============ +function SpreadCard({ data }) { + function spreadClass(v) { + if (v == null) return '' + if (v > 0.3) return 'text-green' + if (v > 0.1) return 'text-yellow' + return 'text-dim' + } + return ( -
-

🎯 套利机会 (BG↔HL)

+
+

📊 三所价差扫描 (BN/OKX/BG)

-
币种HyperLiquidBitgetBinanceOKX毛价差BG→HL净利HL→BG净利
币种BitgetBinanceOKX三所价差
等待数据...
等待数据...
{coin}-----
{coin}----
{formatPrice(p)}
{coin}{spread != null ? spread.toFixed(4) + '%' : '-'}{nb != null ? nb.toFixed(2) + '%' : '-'}{nh != null ? nh.toFixed(2) + '%' : '-'}
+
- + - - {!opps || opps.length === 0 ? ( - - ) : opps.map((opp, i) => { - const cls = opp.net_profit > 0.10 ? 'text-green' : opp.net_profit > 0.05 ? 'text-yellow' : '' - return ( - - - - - - - - ) - })} + + {!data || data.length === 0 ? ( + + ) : data.slice(0, 50).map((s, i) => ( + + + + + + + + + + ))}
币种方向买价卖价净利%
币种价差%BN价格OKX价格BG价格最高所最低所
暂无套利机会
{opp.coin}{opp.direction}{formatPrice(opp.buy_price)}{formatPrice(opp.sell_price)}{(opp.net_profit || 0).toFixed(4)}
等待扫描数据...
{s.coin}{s.spread_pct != null ? s.spread_pct.toFixed(4) + '%' : '-'}{s.bn_price ? formatPrice(s.bn_price) : '-'}{s.okx_price ? formatPrice(s.okx_price) : '-'}{s.bg_price ? formatPrice(s.bg_price) : '-'}{s.max_ex || '-'}{s.min_ex || '-'}
@@ -479,370 +321,91 @@ function ArbTable({ opps }) { ) } -function TradesCard({ trades, onRefresh }) { - const [modalTrade, setModalTrade] = useState(null) - const [modalOrders, setModalOrders] = useState([]) - const [modalOpen, setModalOpen] = useState(false) +// ============ Surge Detection Card ============ +function SurgeCard({ snapshots, events }) { + const [tab, setTab] = useState('snapshot') - function openTradeDetail(id) { - setModalOpen(true) - setModalTrade(null) - setModalOrders([]) - fetch('/api/trade/' + id) - .then(r => r.json()) - .then(data => { - setModalTrade(data.trade) - setModalOrders(data.orders || []) - }) - .catch(() => { - setModalTrade({ ID: id }) - }) + function spreadClass(v, threshold) { + if (v == null || threshold == null) return '' + return v >= threshold ? 'text-green' : 'text-dim' } - function closeTradeDetail() { - setModalOpen(false) - } - - // Close on Escape - useEffect(() => { - if (!modalOpen) return - function handler(e) { - if (e.key === 'Escape') closeTradeDetail() - } - document.addEventListener('keydown', handler) - return () => document.removeEventListener('keydown', handler) - }, [modalOpen]) - return ( - <> -
-

📋 历史交易

-
- +
+

+ 🚀 Surge异动检测 + + + + +

+ + {tab === 'snapshot' && ( +
+
- + - - {trades.length === 0 ? ( - - ) : trades.slice(0, 20).map(t => { - const pnlCls = t.NetPnl > 0 ? 'text-green' : t.NetPnl < 0 ? 'text-red' : '' - const convCls = t.Convergence === '价差收敛' ? 'text-green' : t.Convergence === '价差发散' ? 'text-red' : 'text-yellow' + + {snapshots.length === 0 ? ( + + ) : snapshots.slice(0, 50).map((s, i) => { + const surging = s.direction return ( - openTradeDetail(t.ID)}> - - - - - - - - + + + + + + ) })}
时间币种方向入价差出价差净利%结果原因
币种当前价差%基线%阈值%窗口样本
暂无交易记录
+ 收集基线数据中... (需要至少 10 个样本) +
{t.ClosedAt ? new Date(t.ClosedAt).toLocaleTimeString('zh-CN', { hour12: false }) : '-'}{t.Coin}{t.Direction}{t.EntrySpread != null ? t.EntrySpread.toFixed(4) : '-'}{t.ExitSpread != null ? t.ExitSpread.toFixed(4) : '-'}{t.NetPnl != null ? t.NetPnl.toFixed(4) + '%' : '-'}{t.Convergence || '-'}{t.ExitReason || '-'}
{s.coin} + {s.spread_pct != null ? s.spread_pct.toFixed(4) + '%' : '-'} + {surging ? ' ⚡' : ''} + {s.baseline_pct != null ? s.baseline_pct.toFixed(4) + '%' : '-'}{s.threshold_pct != null ? s.threshold_pct.toFixed(4) + '%' : '-'}{s.window_size || 0}
-
- - {/* Trade Detail Modal */} - {modalOpen && ( - )} - - ) -} -function TradeDetailModal({ trade, orders, onClose }) { - function handleOverlayClick(e) { - if (e.target === e.currentTarget) onClose() - } - - if (!trade) { - return ( -
-
-
-

📋 交易详情

- -
-
-
加载中...
-
+ {tab === 'events' && ( +
+ + + + + + {events.length === 0 ? ( + + ) : events.slice(0, 100).map((ev, i) => { + const ts = ev.timestamp ? new Date(ev.timestamp).toLocaleTimeString('zh-CN', { hour12: false }) : '-' + return ( + + + + + + + + + + + + + + ) + })} + +
时间币种方向价差%基线%阈值%比率BN价格OKX价格BG价格领先所
+ 暂无Surge事件记录 +
{ts}{ev.coin} + {ev.direction === 'up' ? '↑ 涨' : '↓ 跌'} + {ev.spread_pct != null ? ev.spread_pct.toFixed(4) + '%' : '-'}{ev.baseline_pct != null ? ev.baseline_pct.toFixed(4) + '%' : '-'}{ev.threshold_pct != null ? ev.threshold_pct.toFixed(4) + '%' : '-'}{ev.ratio != null ? ev.ratio.toFixed(1) + 'x' : '-'}{ev.bn_price ? formatPrice(ev.bn_price) : '-'}{ev.okx_price ? formatPrice(ev.okx_price) : '-'}{ev.bg_price ? formatPrice(ev.bg_price) : '-'}{ev.leading_exchange || '-'}
-
- ) - } - - const opened = new Date(trade.OpenedAt) - const closed = trade.ClosedAt ? new Date(trade.ClosedAt) : null - const dur = closed ? Math.round((closed - opened) / 1000) + 's' : '-' - const pnlCls = trade.NetPnl > 0 ? 'text-green' : trade.NetPnl < 0 ? 'text-red' : '' - - // Sort orders: by Exchange ascending, then by CreatedAt ascending - const sortedOrders = [...(orders || [])].sort((a, b) => { - const exCmp = (a.Exchange || '').localeCompare(b.Exchange || '') - if (exCmp !== 0) return exCmp - return new Date(a.CreatedAt) - new Date(b.CreatedAt) - }) - - // Compute USD PnL per leg: AmountUSD * Pnl% / 100 - const longPnlUSD = trade.AmountUSD && trade.LongPnl != null ? trade.AmountUSD * trade.LongPnl / 100 : null - const shortPnlUSD = trade.AmountUSD && trade.ShortPnl != null ? trade.AmountUSD * trade.ShortPnl / 100 : null - // Net PnL USD = total capital (both sides) * NetPnl% / 100 - const netPnlUSD = trade.AmountUSD && trade.NetPnl != null ? 2 * trade.AmountUSD * trade.NetPnl / 100 : null - - return ( -
-
-
-

📋 交易详情

- -
-
-
-
-

概览

-
币种{trade.Coin}/USDT
-
方向{trade.Direction || '-'}
-
状态{trade.Status === 'closed' ? '已平仓' : trade.Status}
-
加仓次数{trade.ScaleCount || 0} 次
-
总规模${(trade.AmountUSD || 0).toFixed(0)}
-
-
-

时间

-
开仓{opened.toLocaleString('zh-CN', { hour12: false })}
-
平仓{closed ? closed.toLocaleString('zh-CN', { hour12: false }) : '-'}
-
持仓时长{dur}
-
-
-

价差

-
入场价差{trade.EntrySpread != null ? trade.EntrySpread.toFixed(4) + '%' : '-'}
-
出场价差{trade.ExitSpread != null ? trade.ExitSpread.toFixed(4) + '%' : '-'}
-
收敛情况{trade.Convergence || '-'}
-
平仓原因{trade.ExitReason || '-'}
-
-
-

手续费

-
开仓费${(trade.FeeEntry || 0).toFixed(4)}
-
平仓费${(trade.FeeExit || 0).toFixed(4)}
-
总手续费${((trade.FeeEntry || 0) + (trade.FeeExit || 0)).toFixed(4)}
-
-
-

多仓 {trade.LongExchange || '-'}

-
入场价${(trade.LongEntry || 0).toFixed(6)}
-
出场价${(trade.LongExit || 0).toFixed(6)}
-
盈亏 0 ? 'text-green' : trade.LongPnl < 0 ? 'text-red' : '')}>{trade.LongPnl != null ? trade.LongPnl.toFixed(4) + '%' : '-'} {longPnlUSD != null ? (${longPnlUSD.toFixed(4)}) : null}
-
-
-

空仓 {trade.ShortExchange || '-'}

-
入场价${(trade.ShortEntry || 0).toFixed(6)}
-
出场价${(trade.ShortExit || 0).toFixed(6)}
-
盈亏 0 ? 'text-green' : trade.ShortPnl < 0 ? 'text-red' : '')}>{trade.ShortPnl != null ? trade.ShortPnl.toFixed(4) + '%' : '-'} {shortPnlUSD != null ? (${shortPnlUSD.toFixed(4)}) : null}
-
-
-

净收益

-
- 总计 - {trade.NetPnl != null ? trade.NetPnl.toFixed(4) + '%' : '-'} {netPnlUSD != null ? (${netPnlUSD.toFixed(4)}) : null} -
-
-
- {orders.length > 0 && ( -
-

订单明细 ({orders.length})

- - - - - - {sortedOrders.map((o, i) => ( - - - - - - - - - - ))} - -
交易所类型方向价格仓位手续费订单ID
{o.Exchange}{o.Type === 'entry' ? '开仓' : o.Type === 'exit' ? '平仓' : o.Type === 'scale' ? '加仓' : o.Type}{o.Side === 'buy' ? '买' : '卖'}${(o.Price || 0).toFixed(6)}{o.Size != null ? Number(o.Size).toFixed(4) : '-'}{o.Fee != null ? '$' + (o.Fee).toFixed(4) : '-'}{o.OrderID ? o.OrderID.substring(0, 12) + '...' : '-'}
-
- )} -
-
-
- ) -} - -// ============ PnL Growth Chart ============ -function PnlChart() { - const canvasRef = useRef(null) - const [data, setData] = useState([]) - const [totalPnl, setTotalPnl] = useState(0) - - // Fetch trades for chart - useEffect(() => { - async function fetchTrades() { - try { - const resp = await fetch('/api/trades?limit=1000') - const json = await resp.json() - const trades = (json.trades || []) - .filter(t => t.ClosedAt && t.NetPnl != null) - .sort((a, b) => new Date(a.ClosedAt) - new Date(b.ClosedAt)) - setData(trades) - const total = trades.reduce((sum, t) => sum + 2 * (t.AmountUSD || 0) * (t.NetPnl || 0) / 100, 0) - setTotalPnl(total) - } catch (e) {} - } - fetchTrades() - const id = setInterval(fetchTrades, 10000) - return () => clearInterval(id) - }, []) - - // Draw chart - useEffect(() => { - const canvas = canvasRef.current - if (!canvas || data.length < 2) return - - const rect = canvas.parentElement.getBoundingClientRect() - const dpr = window.devicePixelRatio || 1 - const W = rect.width - const H = rect.height - canvas.width = W * dpr - canvas.height = H * dpr - canvas.style.width = W + 'px' - canvas.style.height = H + 'px' - - const ctx = canvas.getContext('2d') - ctx.scale(dpr, dpr) - - const pad = { top: 20, right: 20, bottom: 35, left: 55 } - const plotW = W - pad.left - pad.right - const plotH = H - pad.top - pad.bottom - - // Compute cumulative PnL - const points = [] - let cum = 0 - // Prepend a zero point so single-trade chart still draws - if (data.length > 0) { - const t0 = new Date(data[0].ClosedAt).getTime() - 1000 - points.push({ x: t0, y: 0 }) - } - for (const t of data) { - cum += 2 * (t.AmountUSD || 0) * (t.NetPnl || 0) / 100 - points.push({ x: new Date(t.ClosedAt).getTime(), y: cum }) - } - - const minT = points[0].x - const maxT = points[points.length - 1].x - const yVals = points.map(p => p.y) - const minY = Math.min(0, ...yVals) - const maxY = Math.max(0, ...yVals) - const yRange = Math.max(maxY - minY, 0.01) - const yPad = yRange * 0.15 - - const toX = t => pad.left + (t - minT) / Math.max(maxT - minT, 1) * plotW - const toY = y => pad.top + plotH - (y - (minY - yPad)) / (yRange + 2 * yPad) * plotH - - // Clear - ctx.clearRect(0, 0, W, H) - - // Grid lines - ctx.strokeStyle = 'rgba(48,54,61,0.5)' - ctx.lineWidth = 1 - ctx.font = '11px sans-serif' - ctx.fillStyle = '#8b949e' - - const ySteps = 5 - for (let i = 0; i <= ySteps; i++) { - const yVal = (minY - yPad) + (yRange + 2 * yPad) * i / ySteps - const yPos = toY(yVal) - ctx.beginPath() - ctx.moveTo(pad.left, yPos) - ctx.lineTo(W - pad.right, yPos) - ctx.stroke() - ctx.fillText('$' + yVal.toFixed(2), 2, yPos + 4) - } - - // Zero line - if (minY < 0 && maxY > 0) { - const y0 = toY(0) - ctx.strokeStyle = 'rgba(248,81,73,0.3)' - ctx.lineWidth = 1 - ctx.setLineDash([4, 4]) - ctx.beginPath() - ctx.moveTo(pad.left, y0) - ctx.lineTo(W - pad.right, y0) - ctx.stroke() - ctx.setLineDash([]) - } - - // X axis labels - const xSteps = Math.min(6, points.length) - for (let i = 0; i < xSteps; i++) { - const idx = Math.floor(i * (points.length - 1) / (xSteps - 1)) - const xPos = toX(points[idx].x) - const date = new Date(points[idx].x) - ctx.fillStyle = '#8b949e' - ctx.textAlign = 'center' - ctx.fillText(date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }), xPos, H - 5) - } - - // Line - ctx.beginPath() - ctx.strokeStyle = '#58a6ff' - ctx.lineWidth = 2 - for (let i = 0; i < points.length; i++) { - const x = toX(points[i].x) - const y = toY(points[i].y) - if (i === 0) ctx.moveTo(x, y) - else ctx.lineTo(x, y) - } - ctx.stroke() - - // Fill gradient - const gradient = ctx.createLinearGradient(0, pad.top, 0, H - pad.bottom) - gradient.addColorStop(0, 'rgba(88,166,255,0.15)') - gradient.addColorStop(1, 'rgba(88,166,255,0.01)') - ctx.lineTo(toX(points[points.length - 1].x), toY(minY - yPad)) - ctx.lineTo(toX(points[0].x), toY(minY - yPad)) - ctx.closePath() - ctx.fillStyle = gradient - ctx.fill() - - // Latest value dot - const last = points[points.length - 1] - const lx = toX(last.x) - const ly = toY(last.y) - ctx.beginPath() - ctx.arc(lx, ly, 4, 0, Math.PI * 2) - ctx.fillStyle = last.y >= 0 ? '#3fb950' : '#f85149' - ctx.fill() - ctx.strokeStyle = '#0d1117' - ctx.lineWidth = 2 - ctx.stroke() - - // Latest value label - ctx.fillStyle = '#c9d1d9' - ctx.font = 'bold 13px sans-serif' - ctx.textAlign = 'center' - ctx.fillText('$' + last.y.toFixed(2), lx, ly - 12) - }, [data]) - - return ( -
-

📈 总PnL成长曲线 {data.length > 0 ? `$${totalPnl.toFixed(2)}` : ''}

-
- {data.length < 1 ? ( -
暂无数据...
- ) : ( - - )} -
+ )}
) } @@ -873,9 +436,6 @@ function MomentumCard({ momentum }) { case 'bg_1s': va = a.bg_1s || 0; vb = b.bg_1s || 0; break case 'bg_5s': va = a.bg_5s || 0; vb = b.bg_5s || 0; break case 'bg_15s': va = a.bg_15s || 0; vb = b.bg_15s || 0; break - case 'hl_1s': va = a.hl_1s || 0; vb = b.hl_1s || 0; break - case 'hl_5s': va = a.hl_5s || 0; vb = b.hl_5s || 0; break - case 'hl_15s': va = a.hl_15s || 0; vb = b.hl_15s || 0; break case 'bn_1s': va = a.bn_1s || 0; vb = b.bn_1s || 0; break case 'bn_5s': va = a.bn_5s || 0; vb = b.bn_5s || 0; break case 'bn_15s': va = a.bn_15s || 0; vb = b.bn_15s || 0; break @@ -892,10 +452,10 @@ function MomentumCard({ momentum }) { function dirIcon(dir) { switch (dir) { - case 'up': return '\u2191' - case 'down': return '\u2193' - case 'flat': return '\u2192' - case 'mixed': return '\u2195' + case 'up': return '↑' + case 'down': return '↓' + case 'flat': return '→' + case 'mixed': return '↕' default: return '-' } } @@ -927,9 +487,6 @@ function MomentumCard({ momentum }) { toggleSort('bg_1s')} style={{cursor:'pointer'}}>BG 1s{sortArrow('bg_1s')} toggleSort('bg_5s')} style={{cursor:'pointer'}}>BG 5s{sortArrow('bg_5s')} toggleSort('bg_15s')} style={{cursor:'pointer'}}>BG 15s{sortArrow('bg_15s')} - toggleSort('hl_1s')} style={{cursor:'pointer'}}>HL 1s{sortArrow('hl_1s')} - toggleSort('hl_5s')} style={{cursor:'pointer'}}>HL 5s{sortArrow('hl_5s')} - toggleSort('hl_15s')} style={{cursor:'pointer'}}>HL 15s{sortArrow('hl_15s')} toggleSort('bn_1s')} style={{cursor:'pointer'}}>BN 1s{sortArrow('bn_1s')} toggleSort('bn_5s')} style={{cursor:'pointer'}}>BN 5s{sortArrow('bn_5s')} toggleSort('bn_15s')} style={{cursor:'pointer'}}>BN 15s{sortArrow('bn_15s')} @@ -940,7 +497,7 @@ function MomentumCard({ momentum }) { {sorted.length === 0 ? ( - + 正在收集动量数据... (需要至少 15 秒数据) ) : sorted.slice(0, 50).map(entry => ( @@ -951,9 +508,6 @@ function MomentumCard({ momentum }) { {entry.bg_1s != null ? entry.bg_1s.toFixed(3) + '%' : '-'} {entry.bg_5s != null ? entry.bg_5s.toFixed(3) + '%' : '-'} {entry.bg_15s != null ? entry.bg_15s.toFixed(3) + '%' : '-'} - {entry.hl_1s != null ? entry.hl_1s.toFixed(3) + '%' : '-'} - {entry.hl_5s != null ? entry.hl_5s.toFixed(3) + '%' : '-'} - {entry.hl_15s != null ? entry.hl_15s.toFixed(3) + '%' : '-'} {entry.bn_1s != null ? entry.bn_1s.toFixed(3) + '%' : '-'} {entry.bn_5s != null ? entry.bn_5s.toFixed(3) + '%' : '-'} {entry.bn_15s != null ? entry.bn_15s.toFixed(3) + '%' : '-'} @@ -1010,23 +564,20 @@ function CmCard({ data }) { 均值% 一致 BG 1m - HL 1m BN 1m OKX 1m BG 5m - HL 5m BN 5m OKX 5m - 1h 趋势 + 1h 趋势 BG 1h - HL 1h BN 1h OKX 1h {!data || data.length === 0 ? ( - + 等待累积数据... (需要至少 1 分钟数据) ) : data.slice(0, 30).map(entry => ( @@ -1035,19 +586,16 @@ function CmCard({ data }) { {stateLabel(entry.state)} {entry.direction === 'up' ? '↑' : '↓'} {(entry.score || 0).toFixed(2)} - {(entry.avg_change || 0).toFixed(3)}% + {(entry.avg_1m || 0).toFixed(3)}% {entry.ex_agree || 0}/{entry.ex_total || 0} {entry.bg_1m != null ? entry.bg_1m.toFixed(3) + '%' : '-'} - {entry.hl_1m != null ? entry.hl_1m.toFixed(3) + '%' : '-'} {entry.bn_1m != null ? entry.bn_1m.toFixed(3) + '%' : '-'} {entry.okx_1m != null ? entry.okx_1m.toFixed(3) + '%' : '-'} {entry.bg_5m != null ? entry.bg_5m.toFixed(3) + '%' : '-'} - {entry.hl_5m != null ? entry.hl_5m.toFixed(3) + '%' : '-'} {entry.bn_5m != null ? entry.bn_5m.toFixed(3) + '%' : '-'} {entry.okx_5m != null ? entry.okx_5m.toFixed(3) + '%' : '-'} {(entry.avg_1h || 0).toFixed(2)}% {entry.bg_1h != null ? entry.bg_1h.toFixed(2) + '%' : '-'} - {entry.hl_1h != null ? entry.hl_1h.toFixed(2) + '%' : '-'} {entry.bn_1h != null ? entry.bn_1h.toFixed(2) + '%' : '-'} {entry.okx_1h != null ? entry.okx_1h.toFixed(2) + '%' : '-'} @@ -1101,18 +649,16 @@ function CmHistoryCard({ history }) { 均值% 一致 BG 1m - HL 1m BN 1m OKX 1m BG 5m - HL 5m BN 5m OKX 5m {history.length === 0 ? ( - + 暂无累积变动事件记录 ) : history.slice(0, 100).map((ev, i) => ( @@ -1125,11 +671,9 @@ function CmHistoryCard({ history }) { {(ev.avg_change || 0).toFixed(3)}% {ev.ex_agree || 0}/{ev.ex_total || 0} {ev.bg_1m != null ? ev.bg_1m.toFixed(3) + '%' : '-'} - {ev.hl_1m != null ? ev.hl_1m.toFixed(3) + '%' : '-'} {ev.bn_1m != null ? ev.bn_1m.toFixed(3) + '%' : '-'} {ev.okx_1m != null ? ev.okx_1m.toFixed(3) + '%' : '-'} {ev.bg_5m != null ? ev.bg_5m.toFixed(3) + '%' : '-'} - {ev.hl_5m != null ? ev.hl_5m.toFixed(3) + '%' : '-'} {ev.bn_5m != null ? ev.bn_5m.toFixed(3) + '%' : '-'} {ev.okx_5m != null ? ev.okx_5m.toFixed(3) + '%' : '-'} @@ -1147,7 +691,6 @@ function TrendFilterCard({ filterData }) { const midScore = filterData.filter(f => f.signal_score >= 50 && f.signal_score < 80).length const anomalyCount = filterData.filter(f => f.fresh_anomaly).length - // Build header description let desc = `高分${highScore} 中分${midScore}` if (anomalyCount > 0) { desc += ` | ${anomalyCount}币异动中` diff --git a/go.mod b/go.mod index a7b1aa2..302010b 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,7 @@ go 1.25.3 require ( github.com/ethereum/go-ethereum v1.17.2 github.com/gorilla/websocket v1.5.3 - github.com/sonirico/go-hyperliquid v0.36.0 + modernc.org/sqlite v1.50.0 ) diff --git a/ipc.go b/ipc.go deleted file mode 100644 index 3295462..0000000 --- a/ipc.go +++ /dev/null @@ -1,123 +0,0 @@ -package main - -import ( - "encoding/json" - "fmt" - "log" - "net" - "os" - "time" -) - -const sockPath = "/tmp/exchange-monitor.sock" - -// IPCCommand is sent from CLI client to daemon. -type IPCCommand struct { - Action string `json:"action"` // status, close-all, close, stop, start - Coin string `json:"coin,omitempty"` -} - -// IPCResponse is sent back from daemon to CLI client. -type IPCResponse struct { - Success bool `json:"success"` - Data interface{} `json:"data,omitempty"` - Error string `json:"error,omitempty"` -} - -// startIPCServer starts the Unix socket listener for CLI commands. -func (t *Trader) startIPCServer() { - os.Remove(sockPath) // clean up stale socket - - ln, err := net.Listen("unix", sockPath) - if err != nil { - log.Printf("[IPC] Failed to create socket: %v", err) - return - } - log.Printf("[IPC] Listening on %s", sockPath) - - go func() { - defer ln.Close() - for { - conn, err := ln.Accept() - if err != nil { - continue - } - go t.handleIPC(conn) - } - }() -} - -func (t *Trader) handleIPC(conn net.Conn) { - defer conn.Close() - conn.SetDeadline(time.Now().Add(5 * time.Second)) - - var cmd IPCCommand - if err := json.NewDecoder(conn).Decode(&cmd); err != nil { - json.NewEncoder(conn).Encode(IPCResponse{Success: false, Error: "invalid command: " + err.Error()}) - return - } - - var resp IPCResponse - switch cmd.Action { - case "status": - positions := t.ReadSnapshot() - c, d, f, tot := t.GetClosedStats() - resp = IPCResponse{Success: true, Data: map[string]interface{}{ - "positions": positions, - "converged": c, "diverged": d, "flat": f, "total": tot, - }} - case "close-all": - count := t.CloseAllPositions() - resp = IPCResponse{Success: true, Data: map[string]interface{}{ - "closed": count, "message": fmt.Sprintf("Closed %d positions", count), - }} - case "close": - if cmd.Coin == "" { - resp = IPCResponse{Success: false, Error: "missing coin name"} - } else if err := t.ClosePosition(cmd.Coin); err != nil { - resp = IPCResponse{Success: false, Error: err.Error()} - } else { - resp = IPCResponse{Success: true, Data: map[string]string{"closed": cmd.Coin}} - } - case "stop": - t.Stop() - resp = IPCResponse{Success: true, Data: map[string]string{"status": "stopped"}} - case "start": - t.Start() - resp = IPCResponse{Success: true, Data: map[string]string{"status": "started"}} - default: - resp = IPCResponse{Success: false, Error: "unknown action: " + cmd.Action} - } - json.NewEncoder(conn).Encode(resp) -} - -// runIPCClient sends a command to the running daemon and prints the response. -func runIPCClient(action, coin string) { - conn, err := net.DialTimeout("unix", sockPath, 2*time.Second) - if err != nil { - fmt.Fprintf(os.Stderr, "Error: daemon not running? (%v)\n", err) - os.Exit(1) - } - defer conn.Close() - - cmd := IPCCommand{Action: action, Coin: coin} - if err := json.NewEncoder(conn).Encode(cmd); err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) - os.Exit(1) - } - - var resp IPCResponse - if err := json.NewDecoder(conn).Decode(&resp); err != nil { - fmt.Fprintf(os.Stderr, "Error reading response: %v\n", err) - os.Exit(1) - } - - if !resp.Success { - fmt.Fprintf(os.Stderr, "Error: %s\n", resp.Error) - os.Exit(1) - } - - // Pretty-print response - data, _ := json.MarshalIndent(resp.Data, "", " ") - fmt.Println(string(data)) -} diff --git a/main.go b/main.go index cb0b872..c1954d6 100644 --- a/main.go +++ b/main.go @@ -3,7 +3,6 @@ package main import ( "bytes" "context" - "fmt" "io" "log" "math/rand" @@ -18,25 +17,6 @@ import ( ) func main() { - // CLI subcommand mode: talk to running daemon via IPC - if len(os.Args) > 1 { - switch os.Args[1] { - case "status", "close-all", "stop", "start": - runIPCClient(os.Args[1], "") - case "close": - if len(os.Args) < 3 { - fmt.Fprintln(os.Stderr, "Usage: exchange-monitor close ") - os.Exit(1) - } - runIPCClient("close", os.Args[2]) - default: - fmt.Fprintf(os.Stderr, "Unknown command: %s\n", os.Args[1]) - fmt.Fprintln(os.Stderr, "Commands: status, close-all, close , stop, start") - os.Exit(1) - } - return - } - log.SetFlags(log.Ldate | log.Ltime | log.Lshortfile) // Set up multi-writer: stdout + log file @@ -48,17 +28,12 @@ func main() { } else { log.SetOutput(os.Stdout) } - log.Println("[Exchange Monitor] Starting...") + log.Println("[Exchange Monitor] Starting surge detection mode...") loadDotEnv() cfg := LoadConfig() - // Populate package-level taker fees from config (so scanner/dashboard/trader all use it) - takerFees[ExBitget] = cfg.TakerFeeBitget - takerFees[ExHyperLiquid] = cfg.TakerFeeHyperLiquid - store := NewPriceStore() - notifier := NewNotifier(cfg.TelegramBotToken, cfg.TelegramChatID) // Initialize momentum tracker (for momentum scanning mode) momentumTracker := NewMomentumTracker() @@ -73,7 +48,6 @@ func main() { // Initialize cumulative tracker (1min/5min multi-exchange consensus change) cumulativeTracker := NewCumulativeTracker() - log.Printf("[CM] Cumulative change tracking enabled (1m >= %.1f%%, 3+ exchanges)", cumulativeTracker.surgePct1m) // Initialize trend filter (Binance K-line based quiet + EMA52 filter) trendFilter := NewTrendFilter(store, trendDetector) @@ -88,50 +62,40 @@ func main() { defer database.Close() } - // Initialize trader - trader := NewTrader(cfg, database) + // Initialize surge detector + surgeDetector := NewSurgeDetector() + if cfg.SurgeEnabled { + surgeDetector.Configure(cfg.SurgeWindowSize, cfg.SurgeBaselineMultiplier, cfg.SurgeMinAbsSpreadPct, cfg.SurgeCooldownSec) + log.Printf("[Surge] Adaptive detection enabled (window=%d ticks, multiplier=%.1fx, min_spread=%.2f%%, cooldown=%ds)", + cfg.SurgeWindowSize, cfg.SurgeBaselineMultiplier, cfg.SurgeMinAbsSpreadPct, cfg.SurgeCooldownSec) - // Start Unix socket IPC for CLI commands - trader.startIPCServer() - - // Initialize dashboard (web server + SSE) - dashboard := NewDashboard(store, trader, database, ":8888", cfg, momentumTracker, trendDetector, cumulativeTracker, trendFilter) - go dashboard.Run() - - // Spread window tracker — measures how long spreads stay above threshold - spreadTracker := NewSpreadWindowTracker() - - // P3-4: wire real-time trade event broadcast - trader.OnTradeEvent = dashboard.BroadcastEvent - if cfg.MomentumEnabled { - log.Printf("[Trader] MOMENTUM SCAN mode: arbitrage trading disabled, momentum detection active (threshold >= %.2f%%)", cfg.MomentumThresholdPct) - } else if trader.IsConfigured() { - log.Printf("[Trader] %s mode: automated trading ENABLED (threshold >= %.2f%%, $%.0f/leg, max %d positions, $%.0f capital)", - trader.ModeLabel(), cfg.TradeThreshold, cfg.TradeAmountUSD, cfg.MaxPositions, cfg.InitialCapital) - if cfg.TestMode { - log.Printf("[Trader] Using mock orders with %.3f%% slippage per leg", cfg.MockSlippagePct) + // Wire surge event persistence to SQLite + if database != nil { + surgeDetector.SetOnEvent(func(ev SurgeEvent) { + database.InsertSurgeEvent(ev.Coin, ev.Timestamp, ev.BnPrice, ev.OkxPrice, ev.BgPrice, + ev.SpreadPct, ev.BaselinePct, ev.ThresholdPct, ev.Ratio, + ev.Direction, ev.LeadingExchange, ev.MidPrice) + }) } - log.Printf("[Trader] Bitget+HL: BG->HL / HL->BG only") - } else { - log.Printf("[Trader] Automated trading DISABLED (set TRADE_ENABLED=1 or TEST_MODE=true in .env)") } - // Context for graceful shutdown — replaces shared sigCh (B#1) + // Initialize dashboard (web server + SSE) + dashboard := NewDashboard(store, database, ":8888", cfg, momentumTracker, trendDetector, cumulativeTracker, trendFilter, surgeDetector) + go dashboard.Run() + + // Context for graceful shutdown ctx, cancel := context.WithCancel(context.Background()) defer cancel() sigCh := make(chan os.Signal, 1) signal.Notify(sigCh, os.Interrupt, syscall.SIGUSR1) - // Collect symbols for all exchanges - var bgSymbols, hlSymbols, bnSymbols, okxSymbols []string + // Collect symbols for all exchanges + var bgSymbols, bnSymbols, okxSymbols []string for _, c := range TrackedCoins { if c.BG != "" { bgSymbols = append(bgSymbols, c.BG) } - if c.HL != "" { - hlSymbols = append(hlSymbols, c.HL) - } if c.BN != "" { bnSymbols = append(bnSymbols, c.BN) } @@ -147,7 +111,7 @@ func main() { err := runner(func(coin string, price, bid, ask float64) { store.SetWithSpread(coin, name, price, bid, ask) dashboard.RecordPrice(coin, name, price) - dashboard.RecordConnStatus(name) // P3-5 + dashboard.RecordConnStatus(name) }) log.Printf("[%s] WS error: %v (reconnecting...)", name, err) select { @@ -159,7 +123,6 @@ func main() { }() } - startExchange("HyperLiquid", exchange.NewHyperLiquidWS(hlSymbols).Run) startExchange("Bitget", exchange.NewBitgetWS(bgSymbols).Run) startExchange("Binance", exchange.NewBinanceWS(bnSymbols).Run) startExchange("OKX", exchange.NewOKXWS(okxSymbols).Run) @@ -167,10 +130,7 @@ func main() { log.Println("[Monitor] Waiting for initial data...") time.Sleep(10 * time.Second) - // Main loop - lastHour := -1 - - // Fixed 50ms scan interval + // Main loop — fixed 50ms scan interval jitterMin, jitterMax := 50, 50 randInterval := func() time.Duration { return time.Duration(jitterMin+rand.Intn(jitterMax-jitterMin+1)) * time.Millisecond @@ -185,29 +145,13 @@ func main() { select { case sig := <-sigCh: if sig == syscall.SIGUSR1 { - // Dump stats on request - converged, diverged, flat, total := trader.GetClosedStats() - stats := fmt.Sprintf("=== 收敛统计 === %s\n", time.Now().Format("2006-01-02 15:04")) - stats += fmt.Sprintf(" 总交易数: %d\n", total) - stats += fmt.Sprintf(" 价差收敛: %d\n", converged) - stats += fmt.Sprintf(" 价差持平: %d\n", flat) - stats += fmt.Sprintf(" 价差发散: %d\n", diverged) - if total > 0 { - stats += fmt.Sprintf(" 收敛率: %.1f%%\n", float64(converged)/float64(total)*100) - } - log.Printf("[Monitor] SIGUSR1 received — wrote stats to trade_stats.txt") - statsPath := os.ExpandEnv("$HOME/Project/exchange-monitor-go/trade_stats.txt") - os.WriteFile(statsPath, []byte(stats), 0644) + log.Printf("[Monitor] SIGUSR1 received — stats dump") continue } log.Println("[Monitor] Shutting down...") - cancel() // B#1: cancel context to stop all WS goroutines + cancel() runLoop = false - case <-trader.StopCh: - log.Println("[Monitor] 5 real trades completed — trading stopped. System still running (dashboard active)") - log.Println("[Monitor] Use POST /api/start to resume trading, POST /api/stop to stop manually") - case <-statusTick.C: snap := store.GetAll() count := 0 @@ -216,25 +160,16 @@ func main() { } log.Printf("[Status] %d prices / %d coins connected", count, len(snap)) - // Show open positions (read from decoupled snapshot) - if positions := trader.ReadSnapshot(); len(positions) > 0 { - for _, pos := range positions { - log.Printf(" [Position] %s %s open %d scales $%.0f since %s", - pos.Coin, pos.Direction, pos.ScaleLevels, pos.AmountUSD, - time.Since(pos.StartedAt).Round(time.Second).String()) + // Show surge events in last 30s + events := surgeDetector.GetRecentEvents(3) + for _, ev := range events { + if time.Since(ev.Timestamp) < 30*time.Second { + log.Printf(" [Surge] %s %s spread=%.4f%% leading=%s", ev.Coin, ev.Direction, ev.SpreadPct, ev.LeadingExchange) } } case <-scannerTick.C: now := time.Now() - t0 := now - - // Tick the trader (monitor open positions for exit) - trader.Tick(store, notifier) - trader.RefreshSnapshot() // decoupled snapshot for display - t1 := time.Now() - - // Scan for arbitrage entries using maker fees (limit orders) snap := store.GetAll() // Feed prices to momentum tracker (for momentum scanning or trend detection) @@ -255,43 +190,20 @@ func main() { cumulativeTracker.Record(tc.Name, exMap) } - makerOpps := ScanBGHL(snap) - dashboard.UpdateScan(makerOpps) - t2 := time.Now() + // Run 3-exchange spread scan + spreads := Scan3Ex(snap) + dashboard.UpdateScan(spreads) - // Track spread window durations (how long each opportunity stays alive) - spreadTracker.Tick(snap, cfg.TradeThreshold) - - // In momentum mode, arbitrage trading is disabled - if !cfg.MomentumEnabled { - for _, opp := range makerOpps { - if opp.NetProfit < cfg.ArbThreshold { - continue - } - if trader.TryEntry(opp, store, notifier) { - log.Printf("[Trader] %s: entry initiated for %.4f%%", opp.Coin, opp.NetProfit) - } + // Run surge detection + if cfg.SurgeEnabled { + newEvents := surgeDetector.Tick(snap) + for _, ev := range newEvents { + dashboard.BroadcastEvent("surge_event", ev) } } - t3 := time.Now() - - // Profile: warn if any step is slow - tickDur := t3.Sub(t0) - tickMs := tickDur.Milliseconds() - if tickMs > 100 || t1.Sub(t0) > 50*time.Millisecond || t2.Sub(t1) > 50*time.Millisecond || t3.Sub(t2) > 50*time.Millisecond { - log.Printf("[Profile] tick=%dms trader=%dms scan=%dms entry=%dms", - tickMs, t1.Sub(t0).Milliseconds(), t2.Sub(t1).Milliseconds(), t3.Sub(t2).Milliseconds()) - } - - // Hourly trade summary — use hour-based tracking (wider window than second-granularity) - hour := now.Hour() - if hour != lastHour && now.Minute() < 1 { - positions := trader.ReadSnapshot() - notifier.SendTradeSummary(positions, now.Format("2006-01-02 15:04")) - lastHour = hour - } scannerTick.Reset(randInterval()) + _ = now } } diff --git a/momentum.go b/momentum.go index 93ba021..f05d42a 100644 --- a/momentum.go +++ b/momentum.go @@ -37,10 +37,6 @@ type MomentumEntry struct { BG5s float64 `json:"bg_5s"` BG15s float64 `json:"bg_15s"` BG60s float64 `json:"bg_60s"` - HL1s float64 `json:"hl_1s"` - HL5s float64 `json:"hl_5s"` - HL15s float64 `json:"hl_15s"` - HL60s float64 `json:"hl_60s"` BN1s float64 `json:"bn_1s"` BN5s float64 `json:"bn_5s"` BN15s float64 `json:"bn_15s"` @@ -95,10 +91,9 @@ func (mt *MomentumTracker) Snapshot(thresholdPct float64) []MomentumEntry { var result []MomentumEntry for coin, exMap := range mt.buffers { bgBuf, hasBG := exMap[ExBitget] - hlBuf, hasHL := exMap[ExHyperLiquid] bnBuf, hasBN := exMap[ExBinance] okBuf, hasOK := exMap[ExOKX] - if !hasBG && !hasHL && !hasBN && !hasOK { + if !hasBG && !hasBN && !hasOK { continue } @@ -113,14 +108,6 @@ func (mt *MomentumTracker) Snapshot(thresholdPct float64) []MomentumEntry { entry.BG60s = changes[3] allChanges = append(allChanges, changes[:]...) } - if hasHL { - changes := calcWindows(hlBuf) - entry.HL1s = changes[0] - entry.HL5s = changes[1] - entry.HL15s = changes[2] - entry.HL60s = changes[3] - allChanges = append(allChanges, changes[:]...) - } if hasBN { changes := calcWindows(bnBuf) entry.BN1s = changes[0] diff --git a/notifier.go b/notifier.go deleted file mode 100644 index 02f54ed..0000000 --- a/notifier.go +++ /dev/null @@ -1,92 +0,0 @@ -package main - -import ( - "bytes" - "encoding/json" - "fmt" - "log" - "net/http" - "time" -) - -type Notifier struct { - BotToken string - ChatID string - client *http.Client -} - -func NewNotifier(botToken, chatID string) *Notifier { - return &Notifier{ - BotToken: botToken, - ChatID: chatID, - client: &http.Client{Timeout: 10 * time.Second}, - } -} - -// Send sends a text message to Telegram. -func (n *Notifier) Send(text string) error { - if n.BotToken == "" || n.ChatID == "" { - log.Printf("[Notifier] Skipped (not configured): %.80s", text) - return nil - } - - url := fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", n.BotToken) - payload := map[string]string{ - "chat_id": n.ChatID, - "text": text, - "parse_mode": "HTML", - } - - body, _ := json.Marshal(payload) - resp, err := n.client.Post(url, "application/json", bytes.NewReader(body)) - if err != nil { - return fmt.Errorf("telegram send error: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != 200 { - return fmt.Errorf("telegram status %d", resp.StatusCode) - } - - log.Printf("[Notifier] Sent (%d bytes)", len(text)) - return nil -} - -// SendAlert sends an arbitrage alert notification. -func (n *Notifier) SendAlert(opp *ArbOpportunity) { - msg := fmt.Sprintf( - "[套利信号] %s/USDT\n"+ - " %s %.4f -> %s %.4f\n"+ - " 净利: %+.4f%%\n", - opp.Coin, - opp.BuyEx, opp.BuyPrice, - opp.SellEx, opp.SellPrice, - opp.NetProfit, - ) - if opp.NetProfit > 0.10 { - msg += " 高价值机会!\n" - } - - if err := n.Send(msg); err != nil { - log.Printf("[Notifier] Alert error: %v", err) - } -} - -// SendTradeSummary sends a summary of open positions at each hour. -func (n *Notifier) SendTradeSummary(positions []ArbPosition, timeStr string) { - if n.BotToken == "" || n.ChatID == "" { - return - } - lines := fmt.Sprintf("=== 持仓汇总 === %s\n", timeStr) - if len(positions) == 0 { - lines += " 当前无持仓\n" - } else { - for i, p := range positions { - dur := time.Since(p.StartedAt).Round(time.Second).String() - lines += fmt.Sprintf("%d. %s %s %.0f %s\n", i+1, p.Coin, p.Direction, p.AmountUSD, dur) - } - } - if err := n.Send(lines); err != nil { - log.Printf("[Notifier] Hourly error: %v", err) - } -} diff --git a/persistence.md b/persistence.md deleted file mode 100644 index 2a4c434..0000000 --- a/persistence.md +++ /dev/null @@ -1,395 +0,0 @@ -# Dashboard Design - -## 1. 目录结构 - -``` -exchange-monitor-go/ -├── main.go # 入口:启动 engine + web server -├── config.go # 配置加载(不变) -├── types.go # 公共类型(不变) -│ -├── engine/ # 核心交易引擎(从 main.go 拆分) -│ ├── engine.go # Engine 结构体:组合所有模块 -│ ├── scanner.go # 价差扫描(从 scanner.go 移入) -│ ├── trader.go # 交易执行(从 trader.go 移入) -│ ├── notifier.go # Telegram 通知(从 notifier.go 移入) -│ └── portfolio.go # 资金管理 + PnL 聚合 -│ -├── exchange/ # 交易所连接(不变) -│ └── ... -│ -├── db/ # SQLite 持久化层(新增) -│ ├── db.go # DB 初始化、迁移 -│ ├── trade_repo.go # 交易记录 CRUD -│ ├── order_repo.go # 订单明细 CRUD -│ └── config_repo.go # 配置快照 -│ -├── web/ # Web 仪表盘(新增) -│ ├── server.go # HTTP 服务器 + 路由 -│ ├── handler_dashboard.go # 页面渲染 -│ ├── handler_api.go # REST API -│ ├── handler_sse.go # SSE 实时推送 -│ ├── static/ # 前端静态资源(go:embed) -│ │ ├── index.html -│ │ ├── app.js -│ │ └── style.css -│ └── ws_monitor.go # WS 状态监控 -│ -├── risk/ # 风控层(新增) -│ └── risk.go # 风控规则引擎 -│ -├── persistence.md # 本设计文档 -└── ... -``` - -## 2. 数据模型 (SQLite) - -``` -┌─────────────────────────────────────────────────────┐ -│ trades │ -├──────────────┬──────────┬───────────────────────────┤ -│ id │ INTEGER │ PRIMARY KEY AUTOINCREMENT │ -│ coin │ TEXT │ NOT NULL │ -│ direction │ TEXT │ BG->HL / HL->BG │ -│ status │ TEXT │ open / closed │ -│ entry_spread │ REAL │ 进场价差 % │ -│ exit_spread │ REAL │ 出场价差 % │ -│ long_ex │ TEXT │ 多腿交易所 │ -│ short_ex │ TEXT │ 空腿交易所 │ -│ long_entry │ REAL │ 多腿进场价 │ -│ long_exit │ REAL │ 多腿出场价 │ -│ short_entry │ REAL │ 空腿进场价 │ -│ short_exit │ REAL │ 空腿出场价 │ -│ long_pnl │ REAL │ 多腿 PnL % │ -│ short_pnl │ REAL │ 空腿 PnL % │ -│ fee_entry │ REAL │ 开仓手续费 % │ -│ fee_exit │ REAL │ 平仓手续费 % │ -│ net_pnl │ REAL │ 净利 % │ -│ amount_usd │ REAL │ 总金额 $ │ -│ scale_count │ INTEGER │ 加仓次数 │ -│ exit_reason │ TEXT │ 止盈/止损/超时 │ -│ convergence │ TEXT │ 收敛/发散/持平 │ -│ opened_at │ DATETIME │ │ -│ closed_at │ DATETIME │ │ -└──────────────┴──────────┴───────────────────────────┘ - -┌─────────────────────────────────────────────────────┐ -│ orders (每腿一条) │ -├──────────────┬──────────┬───────────────────────────┤ -│ id │ INTEGER │ │ -│ trade_id │ INTEGER │ FK → trades.id │ -│ leg │ TEXT │ long / short │ -│ type │ TEXT │ entry / exit / scale │ -│ exchange │ TEXT │ │ -│ side │ TEXT │ buy / sell │ -│ price │ REAL │ 成交价 │ -│ size │ REAL │ 数量 │ -│ fee │ REAL │ 手续费 │ -│ order_id │ TEXT │ 交易所订单 ID │ -│ status │ TEXT │ filled / cancelled │ -│ created_at │ DATETIME │ │ -└──────────────┴──────────┴───────────────────────────┘ - -┌─────────────────────────────────────────────────────┐ -│ price_snapshots │ -├──────────────┬──────────┬───────────────────────────┤ -│ id │ INTEGER │ │ -│ coin │ TEXT │ │ -│ exchange │ TEXT │ │ -│ price │ REAL │ │ -│ bid │ REAL │ │ -│ ask │ REAL │ │ -│ spread_basis │ REAL │ bid-ask spread % │ -│ recorded_at │ DATETIME │ │ -└──────────────┴──────────┴───────────────────────────┘ - -┌─────────────────────────────────────────────────────┐ -│ config_snapshots │ -├──────────────┬──────────┬───────────────────────────┤ -│ id │ INTEGER │ │ -│ key │ TEXT │ 参数名 │ -│ value │ TEXT │ 参数值 │ -│ changed_at │ DATETIME │ │ -│ changed_by │ TEXT │ web / cli │ -└──────────────┴──────────┴───────────────────────────┘ -``` - -## 3. REST API - -``` -Base URL: http://localhost:8080/api/v1 - -┌────────┬────────────────────────┬────────────────────────────┐ -│ Method │ Path │ 说明 │ -├────────┼────────────────────────┼────────────────────────────┤ -│ GET │ /api/v1/stats/summary │ 总览指标 │ -│ GET │ /api/v1/stats/coins │ 各币种明细 │ -│ GET │ /api/v1/stats/pnl │ PnL 曲线(按天/时) │ -│ GET │ /api/v1/stats/daily │ 每日统计 │ -├────────┼────────────────────────┼────────────────────────────┤ -│ GET │ /api/v1/trades │ 交易列表(分页) │ -│ GET │ /api/v1/trades/:id │ 单笔交易详情 + 订单明细 │ -│ GET │ /api/v1/trades/active │ 当前持仓 │ -├────────┼────────────────────────┼────────────────────────────┤ -│ GET │ /api/v1/exchanges │ 交易所连接状态 │ -│ GET │ /api/v1/prices │ 所有币种实时价差 │ -├────────┼────────────────────────┼────────────────────────────┤ -│ GET │ /api/v1/config │ 当前配置 │ -│ PUT │ /api/v1/config │ 更新配置 │ -├────────┼────────────────────────┼────────────────────────────┤ -│ GET │ /api/v1/status │ 系统运行状态(uptime等) │ -│ POST │ /api/v1/action/restart │ 重启扫描器 │ -└────────┴────────────────────────┴────────────────────────────┘ - -GET /api/v1/stats/summary 响应: -{ - "total_trades": 387, - "total_pnl_pct": 4.27, - "total_pnl_usd": 0.85, - "win_rate": 56.5, - "avg_pnl_pct": 0.011, - "max_drawdown": -2.1, - "active_positions": 3, - "running_time": "13h 22m", - "exchanges_connected": 4, - "mode": "simulation" -} - -GET /api/v1/stats/coins 响应: -[ - { - "coin": "ONDO", - "trades": 115, - "pnl_pct": 3.11, - "win_rate": 56.5, - "avg_pnl": 0.027, - "best_trade": 0.18, - "worst_trade": -0.05, - "long_pct": 94, - "short_pct": 6, - "active": true - }, - ... -] - -GET /api/v1/trades?page=1&limit=20&coin=ONDO 响应: -{ - "trades": [ - { - "id": 1, - "coin": "ONDO", - "direction": "BG->HL", - "entry_spread": 0.17, - "exit_spread": 0.01, - "net_pnl": 0.10, - "duration": "52s", - "opened_at": "2026-05-03T15:42:00+08:00", - "scale_count": 0 - } - ], - "total": 115, - "page": 1 -} -``` - -## 4. SSE (Server-Sent Events) 实时推送 - -``` -Endpoint: GET /api/v1/stream - -────────────── 连接建立 ──────────────→ - -←── event: snapshot ── 全量数据推送 ── - { prices: {...}, positions: [...], summary: {...} } - -←── event: price_update ── 价差变化 ── (每 500ms) - { coin: "ONDO", spread: 0.15, bg: 0.28, hl: 0.2815 } - -←── event: trade_opened ── 新开仓 ── - { id: 42, coin: "ONDO", direction: "BG->HL", spread: 0.17, ... } - -←── event: trade_closed ── 平仓 ── - { id: 42, net_pnl: 0.10, exit_spread: 0.01, ... } - -←── event: exchange_status ── WS 状态变化 ── - { exchange: "Bitget", connected: true, latency_ms: 120 } - -←── event: alert ── 系统告警 ── - { level: "warn", message: "WS reconnected", ... } -``` - -## 5. 前端页面布局 - -``` -┌──────────────────────────────────────────────────────┐ -│ [logo] 套利机器人仪表盘 [模拟/实盘] [设置] │ -├──────────────────────────────────────────────────────┤ -│ ┌───────┐ ┌───────┐ ┌───────┐ ┌───────┐ ┌────────┐ │ -│ │总交易 │ │总净利 │ │胜率 │ │当前持仓│ │运行时间 │ │ -│ │ 387 │ │+4.27% │ │56.5% │ │ 3 │ │ 13h22m │ │ -│ └───────┘ └───────┘ └───────┘ └───────┘ └────────┘ │ -├──────────────────────────────────────────────────────┤ -│ Tab: [📈 概览] [📋 交易记录] [⚙️ 配置] [🔌 连接] │ -├──────────────────────────────────────────────────────┤ -│ │ -│ Tab: 概览 │ -│ ┌──────────────────────────────────────────────────┐ │ -│ │ 价差实时折线图(可切换币种) │ │ -│ │ ╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲ │ │ -│ │ ──── 0.1% 阈值线 ──── │ │ -│ │ ╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱ │ │ -│ │ [ONDO] [WIF] [OP] [DOGE] [ARB] [LINK] │ │ -│ └──────────────────────────────────────────────────┘ │ -│ │ -│ ┌────┬──────┬──────┬──────┬──────┬──────┬───────┐ │ -│ │币种 │方向 │持仓$ │价差% │已持 │浮动% │ P&L$ │ │ -│ ├────┼──────┼──────┼──────┼──────┼──────┼───────┤ │ -│ │ONDO│BG→HL │$5 │0.15 │12s │+0.10 │+0.005 │ │ -│ │WIF │BG→HL │$10 │0.08 │2m30s │+0.22 │+0.022 │ │ -│ │OP │HL→BG │$5 │0.22 │1m │+0.05 │+0.003 │ │ -│ └────┴──────┴──────┴──────┴──────┴──────┴───────┘ │ -│ │ -│ 最近成交 │ -│ 15:42:23 ONDO BG→HL 入场0.17% 出场0.01% +0.10% │ -│ 15:41:55 WIF BG→HL 入场0.15% 出场0.02% +0.08% │ -│ 15:41:30 OP HL→BG 入场0.22% 出场0.01% +0.15% │ -│ │ -│ Tab: 交易记录 │ -│ ┌────┬──────┬──────┬──────┬──────┬──────┬──────┬───┐ │ -│ │时间│币种 │方向 │入场 │出场 │净利% │持仓 │详情│ │ -│ ├────┼──────┼──────┼──────┼──────┼──────┼──────┼───┤ │ -│ │... │ │ │ │ │ │ │ >│ │ -│ └────┴──────┴──────┴──────┴──────┴──────┴──────┴───┘ │ -│ [上一页] [1/23] [下一页] │ -│ │ -│ Tab: 配置 │ -│ 阈值: [0.1% ] 每腿金额: [$5 ] 模式: ○模拟 │ -│ 冷却时间: [30000]ms 最大持仓: [3 ] ●实盘 │ -│ [保存配置] │ -│ │ -│ Tab: 连接 │ -│ ┌──────────┬──────────┬───────┬──────────┐ │ -│ │交易所 │状态 │延迟 │最后更新 │ │ -│ ├──────────┼──────────┼───────┼──────────┤ │ -│ │Bitget │● 已连接 │120ms │15:42:23 │ │ -│ │HL │● 已连接 │85ms │15:42:23 │ │ -│ │Binance │● 已连接 │90ms │15:42:22 │ │ -│ │dYdX │⚠ 重连中 │-- │15:41:48 │ │ -│ └──────────┴──────────┴───────┴──────────┘ │ -└──────────────────────────────────────────────────────┘ -``` - -## 6. 前端技术选型 - -``` -框架: 无框架,纯 HTML + CSS + vanilla JS - 原因:零构建步骤,单文件嵌入 - -图表: Chart.js (CDN https://cdn.jsdelivr.net/npm/chart.js) - 原因:轻量、灵活、CDN 无需 npm - -实时通: EventSource (浏览器原生 SSE) - 原因:比 WebSocket 简单,自动重连 - -UI: 纯 CSS Grid + Flexbox - 深色主题(适合交易屏长时间看) - -体积: < 300KB 总大小(含 Chart.js CDN) -``` - -## 7. Web Server 设计 (Go) - -``` -// web/server.go - -package web - -type Server struct { - engine *engine.Engine - db *db.DB - mux *http.ServeMux - sse *SSEHub // SSE 连接管理器 -} - -// SSEHub 管理所有 SSE 客户端连接 -type SSEHub struct { - clients map[chan SSEEvent]struct{} - register chan chan SSEEvent - unregister chan chan SSEEvent - broadcast chan SSEEvent -} - -// 从 Engine 接收事件并广播 -func (h *SSEHub) Broadcast(eventType string, data interface{}) -``` - -## 8. 数据流 - -### 三层数据分层 - -``` -┌──────────────────────────────────────────────┐ -│ HOT (内存 only, 500ms) │ -│ PriceStore: 6币×4所 实时价 │ -│ Trader.positions: 当前持仓 │ -│ Scanner: 扫描结果 │ -│ 不落盘,重启丢失,但重连 WS 秒恢复 │ -├──────────────────────────────────────────────┤ -│ WARM (SQLite, 事件驱动) │ -│ 平仓 → INSERT trades │ -│ 每腿成交 → INSERT orders │ -│ 配置修改 → INSERT/UPSERT config_snapshots │ -│ WS 重连 → INSERT exchange_events(可选) │ -│ 写入频率: < 1次/秒 │ -├──────────────────────────────────────────────┤ -│ COLD (时序方案待定, 未来) │ -│ 价格存档:每分钟采样 × 6币 × 4所 │ -│ 日产量: ~34,560行 → 可存 SQLite 也可用 │ -│ TimescaleDB / InfluxDB (如果要做回测平台) │ -└──────────────────────────────────────────────┘ -``` - -### 实时数据 → 网页 - -``` -WS 数据流 (500ms): -┌─────────┐ price ┌──────────┐ SSE push ┌─────────┐ -│ Exchange│──────────►│ Engine │─────────────►│ Browser │ -│ WS │ │ (HOT层) │ │(实时更新)│ -└─────────┘ └────┬─────┘ └─────────┘ - │ 仅事件写入 - ┌─────▼──────┐ - │ SQLite │ - │ (WARM层) │ - └────────────┘ - -API 请求 (读 WARM 层): -┌─────────┐ GET /api/... ┌──────────┐ SQL ┌────────┐ -│ Browser │────────────────►│ Server │──────────►│ SQLite │ -│ (页面) │◄────────────────│(REST API)│◄──────────┘ │ -└─────────┘ JSON └──────────┘ -``` - -## 9. 实现顺序 - -``` -Phase 1 — 基础设施 - 1. db/ 包:SQLite 初始化 + schema 迁移 - 2. 程序启动时保存交易记录到 SQLite - 3. 重启时从 SQLite 恢复历史数据 - -Phase 2 — Web Server - 1. web/server.go:路由 + SSE Hub - 2. REST API:summary, trades, prices, config - 3. 前端 index.html:概览页(指标卡片 + 当前持仓 + 最近成交) - -Phase 3 — 实时 - 1. SSE stream:价格、持仓、交易实时推送 - 2. Chart.js 实时价差折线图 - -Phase 4 — 完善 - 1. 交易记录页(分页、筛选、详情弹窗) - 2. 配置页(在线修改参数) - 3. 连接状态页 - 4. PnL 曲线图 -``` diff --git a/scanner.go b/scanner.go index d29689e..ebe27f6 100644 --- a/scanner.go +++ b/scanner.go @@ -4,263 +4,257 @@ import ( "sort" ) -// Exchange names — Bitget and HyperLiquid are trading exchanges; Binance and OKX are for momentum/display +// Exchange names const ( - ExHyperLiquid = "HyperLiquid" - ExBitget = "Bitget" - ExBinance = "Binance" - ExOKX = "OKX" + ExBitget = "Bitget" + ExBinance = "Binance" + ExOKX = "OKX" ) -// Taker fee rates (%) — for IOC market orders on trading exchanges -var takerFees = map[string]float64{ - ExHyperLiquid: 0.045, - ExBitget: 0.060, -} - var TrackedCoins = []TrackedCoin{ - {Name: "DOGE", BN: "DOGEUSDT", BG: "DOGEUSDT", HL: "DOGE", OK: "DOGE-USDT-SWAP"}, - {Name: "LINK", BN: "LINKUSDT", BG: "LINKUSDT", HL: "LINK", OK: "LINK-USDT-SWAP"}, - {Name: "ONDO", BN: "ONDOUSDT", BG: "ONDOUSDT", HL: "ONDO", OK: "ONDO-USDT-SWAP"}, - {Name: "OP", BN: "OPUSDT", BG: "OPUSDT", HL: "OP", OK: "OP-USDT-SWAP"}, - {Name: "WIF", BN: "WIFUSDT", BG: "WIFUSDT", HL: "WIF", OK: "WIF-USDT-SWAP"}, - {Name: "ARB", BN: "ARBUSDT", BG: "ARBUSDT", HL: "ARB", OK: "ARB-USDT-SWAP"}, - {Name: "0G", BN: "0GUSDT", BG: "0GUSDT", HL: "0G", OK: "0G-USDT-SWAP"}, - {Name: "2Z", BN: "2ZUSDT", BG: "2ZUSDT", HL: "2Z", OK: "2Z-USDT-SWAP"}, - {Name: "AAVE", BN: "AAVEUSDT", BG: "AAVEUSDT", HL: "AAVE", OK: "AAVE-USDT-SWAP"}, - {Name: "ACE", BN: "ACEUSDT", BG: "ACEUSDT", HL: "ACE", OK: "ACE-USDT-SWAP"}, - {Name: "ADA", BN: "ADAUSDT", BG: "ADAUSDT", HL: "ADA", OK: "ADA-USDT-SWAP"}, - {Name: "AIXBT", BN: "AIXBTUSDT", BG: "AIXBTUSDT", HL: "AIXBT", OK: "AIXBT-USDT-SWAP"}, - {Name: "ALGO", BN: "ALGOUSDT", BG: "ALGOUSDT", HL: "ALGO", OK: "ALGO-USDT-SWAP"}, - {Name: "ALT", BN: "ALTUSDT", BG: "ALTUSDT", HL: "ALT", OK: "ALT-USDT-SWAP"}, - {Name: "ANIME", BN: "ANIMEUSDT", BG: "ANIMEUSDT", HL: "ANIME", OK: "ANIME-USDT-SWAP"}, - {Name: "APE", BN: "APEUSDT", BG: "APEUSDT", HL: "APE", OK: "APE-USDT-SWAP"}, - {Name: "APT", BN: "APTUSDT", BG: "APTUSDT", HL: "APT", OK: "APT-USDT-SWAP"}, - {Name: "AR", BN: "ARUSDT", BG: "ARUSDT", HL: "AR", OK: "AR-USDT-SWAP"}, - {Name: "ARK", BN: "ARKUSDT", BG: "ARKUSDT", HL: "ARK", OK: "ARK-USDT-SWAP"}, - {Name: "ASTER", BN: "ASTERUSDT", BG: "ASTERUSDT", HL: "ASTER", OK: "ASTER-USDT-SWAP"}, - {Name: "ATOM", BN: "ATOMUSDT", BG: "ATOMUSDT", HL: "ATOM", OK: "ATOM-USDT-SWAP"}, - {Name: "AVAX", BN: "AVAXUSDT", BG: "AVAXUSDT", HL: "AVAX", OK: "AVAX-USDT-SWAP"}, - {Name: "AVNT", BN: "AVNTUSDT", BG: "AVNTUSDT", HL: "AVNT", OK: "AVNT-USDT-SWAP"}, - {Name: "AXS", BN: "AXSUSDT", BG: "AXSUSDT", HL: "AXS", OK: "AXS-USDT-SWAP"}, - {Name: "AZTEC", BN: "AZTECUSDT", BG: "AZTECUSDT", HL: "AZTEC", OK: "AZTEC-USDT-SWAP"}, - {Name: "BABY", BN: "BABYUSDT", BG: "BABYUSDT", HL: "BABY", OK: "BABY-USDT-SWAP"}, - {Name: "BANANA", BN: "BANANAUSDT", BG: "BANANAUSDT", HL: "BANANA", OK: "BANANA-USDT-SWAP"}, - {Name: "BCH", BN: "BCHUSDT", BG: "BCHUSDT", HL: "BCH", OK: "BCH-USDT-SWAP"}, - {Name: "BERA", BN: "BERAUSDT", BG: "BERAUSDT", HL: "BERA", OK: "BERA-USDT-SWAP"}, - {Name: "BIGTIME", BN: "BIGTIMEUSDT", BG: "BIGTIMEUSDT", HL: "BIGTIME", OK: "BIGTIME-USDT-SWAP"}, - {Name: "BIO", BN: "BIOUSDT", BG: "BIOUSDT", HL: "BIO", OK: "BIO-USDT-SWAP"}, - {Name: "BLUR", BN: "BLURUSDT", BG: "BLURUSDT", HL: "BLUR", OK: "BLUR-USDT-SWAP"}, - {Name: "BNB", BN: "BNBUSDT", BG: "BNBUSDT", HL: "BNB", OK: "BNB-USDT-SWAP"}, - {Name: "BNT", BN: "BNTUSDT", BG: "BNTUSDT", HL: "", OK: "BNT-USDT-SWAP"}, - {Name: "BOME", BN: "BOMEUSDT", BG: "BOMEUSDT", HL: "BOME", OK: "BOME-USDT-SWAP"}, - {Name: "BRETT", BN: "BRETTUSDT", BG: "BRETTUSDT", HL: "BRETT", OK: "BRETT-USDT-SWAP"}, - {Name: "BSV", BN: "BSVUSDT", BG: "BSVUSDT", HL: "BSV", OK: "BSV-USDT-SWAP"}, - {Name: "BTC", BN: "BTCUSDT", BG: "BTCUSDT", HL: "BTC", OK: "BTC-USDT-SWAP"}, - {Name: "CAKE", BN: "CAKEUSDT", BG: "CAKEUSDT", HL: "CAKE", OK: "CAKE-USDT-SWAP"}, - {Name: "CATI", BN: "CATIUSDT", BG: "CATIUSDT", HL: "", OK: "CATI-USDT-SWAP"}, - {Name: "CC", BN: "CCUSDT", BG: "CCUSDT", HL: "CC", OK: "CC-USDT-SWAP"}, - {Name: "CELO", BN: "CELOUSDT", BG: "CELOUSDT", HL: "CELO", OK: "CELO-USDT-SWAP"}, - {Name: "CFX", BN: "CFXUSDT", BG: "CFXUSDT", HL: "CFX", OK: "CFX-USDT-SWAP"}, - {Name: "CHILLGUY", BN: "CHILLGUYUSDT", BG: "CHILLGUYUSDT", HL: "CHILLGUY", OK: "CHILLGUY-USDT-SWAP"}, - {Name: "CHIP", BN: "CHIPUSDT", BG: "CHIPUSDT", HL: "CHIP", OK: "CHIP-USDT-SWAP"}, - {Name: "COMP", BN: "COMPUSDT", BG: "COMPUSDT", HL: "COMP", OK: "COMP-USDT-SWAP"}, - {Name: "CRV", BN: "CRVUSDT", BG: "CRVUSDT", HL: "CRV", OK: "CRV-USDT-SWAP"}, - {Name: "CYBER", BN: "CYBERUSDT", BG: "CYBERUSDT", HL: "", OK: "CYBER-USDT-SWAP"}, - {Name: "DASH", BN: "DASHUSDT", BG: "DASHUSDT", HL: "DASH", OK: "DASH-USDT-SWAP"}, - {Name: "DOOD", BN: "DOODUSDT", BG: "DOODUSDT", HL: "DOOD", OK: "DOOD-USDT-SWAP"}, - {Name: "DOT", BN: "DOTUSDT", BG: "DOTUSDT", HL: "DOT", OK: "DOT-USDT-SWAP"}, - {Name: "DYDX", BN: "DYDXUSDT", BG: "DYDXUSDT", HL: "DYDX", OK: "DYDX-USDT-SWAP"}, - {Name: "DYM", BN: "DYMUSDT", BG: "DYMUSDT", HL: "DYM", OK: "DYM-USDT-SWAP"}, - {Name: "EIGEN", BN: "EIGENUSDT", BG: "EIGENUSDT", HL: "EIGEN", OK: "EIGEN-USDT-SWAP"}, - {Name: "ENA", BN: "ENAUSDT", BG: "ENAUSDT", HL: "ENA", OK: "ENA-USDT-SWAP"}, - {Name: "ENS", BN: "ENSUSDT", BG: "ENSUSDT", HL: "ENS", OK: "ENS-USDT-SWAP"}, - {Name: "ETC", BN: "ETCUSDT", BG: "ETCUSDT", HL: "ETC", OK: "ETC-USDT-SWAP"}, - {Name: "ETH", BN: "ETHUSDT", BG: "ETHUSDT", HL: "ETH", OK: "ETH-USDT-SWAP"}, - {Name: "ETHFI", BN: "ETHFIUSDT", BG: "ETHFIUSDT", HL: "ETHFI", OK: "ETHFI-USDT-SWAP"}, - {Name: "FARTCOIN", BN: "FARTCOINUSDT", BG: "FARTCOINUSDT", HL: "FARTCOIN", OK: "FARTCOIN-USDT-SWAP"}, - {Name: "FET", BN: "FETUSDT", BG: "FETUSDT", HL: "FET", OK: "FET-USDT-SWAP"}, - {Name: "FIL", BN: "FILUSDT", BG: "FILUSDT", HL: "FIL", OK: "FIL-USDT-SWAP"}, - {Name: "FOGO", BN: "FOGOUSDT", BG: "FOGOUSDT", HL: "FOGO", OK: "FOGO-USDT-SWAP"}, - {Name: "GALA", BN: "GALAUSDT", BG: "GALAUSDT", HL: "GALA", OK: "GALA-USDT-SWAP"}, - {Name: "GAS", BN: "GASUSDT", BG: "GASUSDT", HL: "GAS", OK: "GAS-USDT-SWAP"}, - {Name: "GMT", BN: "GMTUSDT", BG: "GMTUSDT", HL: "GMT", OK: "GMT-USDT-SWAP"}, - {Name: "GMX", BN: "GMXUSDT", BG: "GMXUSDT", HL: "GMX", OK: "GMX-USDT-SWAP"}, - {Name: "GOAT", BN: "GOATUSDT", BG: "GOATUSDT", HL: "GOAT", OK: "GOAT-USDT-SWAP"}, - {Name: "GRASS", BN: "GRASSUSDT", BG: "GRASSUSDT", HL: "GRASS", OK: "GRASS-USDT-SWAP"}, - {Name: "GRIFFAIN", BN: "GRIFFAINUSDT", BG: "GRIFFAINUSDT", HL: "GRIFFAIN", OK: "GRIFFAIN-USDT-SWAP"}, - {Name: "HBAR", BN: "HBARUSDT", BG: "HBARUSDT", HL: "HBAR", OK: "HBAR-USDT-SWAP"}, - {Name: "HYPE", BN: "HYPEUSDT", BG: "HYPEUSDT", HL: "HYPE", OK: "HYPE-USDT-SWAP"}, - {Name: "HYPER", BN: "HYPERUSDT", BG: "HYPERUSDT", HL: "HYPER", OK: "HYPER-USDT-SWAP"}, - {Name: "ICP", BN: "ICPUSDT", BG: "ICPUSDT", HL: "ICP", OK: "ICP-USDT-SWAP"}, - {Name: "ILV", BN: "ILVUSDT", BG: "ILVUSDT", HL: "", OK: "ILV-USDT-SWAP"}, - {Name: "IMX", BN: "IMXUSDT", BG: "IMXUSDT", HL: "IMX", OK: "IMX-USDT-SWAP"}, - {Name: "INIT", BN: "INITUSDT", BG: "INITUSDT", HL: "INIT", OK: "INIT-USDT-SWAP"}, - {Name: "INJ", BN: "INJUSDT", BG: "INJUSDT", HL: "INJ", OK: "INJ-USDT-SWAP"}, - {Name: "IO", BN: "IOUSDT", BG: "IOUSDT", HL: "IO", OK: "IO-USDT-SWAP"}, - {Name: "IOTA", BN: "IOTAUSDT", BG: "IOTAUSDT", HL: "IOTA", OK: "IOTA-USDT-SWAP"}, - {Name: "IP", BN: "IPUSDT", BG: "IPUSDT", HL: "IP", OK: "IP-USDT-SWAP"}, - {Name: "JTO", BN: "JTOUSDT", BG: "JTOUSDT", HL: "JTO", OK: "JTO-USDT-SWAP"}, - {Name: "JUP", BN: "JUPUSDT", BG: "JUPUSDT", HL: "JUP", OK: "JUP-USDT-SWAP"}, - {Name: "KAITO", BN: "KAITOUSDT", BG: "KAITOUSDT", HL: "KAITO", OK: "KAITO-USDT-SWAP"}, - {Name: "KAS", BN: "KASUSDT", BG: "KASUSDT", HL: "KAS", OK: "KAS-USDT-SWAP"}, - {Name: "LAYER", BN: "LAYERUSDT", BG: "LAYERUSDT", HL: "LAYER", OK: "LAYER-USDT-SWAP"}, - {Name: "LDO", BN: "LDOUSDT", BG: "LDOUSDT", HL: "LDO", OK: "LDO-USDT-SWAP"}, - {Name: "LINEA", BN: "LINEAUSDT", BG: "LINEAUSDT", HL: "LINEA", OK: "LINEA-USDT-SWAP"}, - {Name: "LISTA", BN: "LISTAUSDT", BG: "LISTAUSDT", HL: "", OK: "LISTA-USDT-SWAP"}, - {Name: "LIT", BN: "LITUSDT", BG: "LITUSDT", HL: "LIT", OK: "LIT-USDT-SWAP"}, - {Name: "LTC", BN: "LTCUSDT", BG: "LTCUSDT", HL: "LTC", OK: "LTC-USDT-SWAP"}, - {Name: "MANTA", BN: "MANTAUSDT", BG: "MANTAUSDT", HL: "MANTA", OK: "MANTA-USDT-SWAP"}, - {Name: "MAV", BN: "MAVUSDT", BG: "MAVUSDT", HL: "MAV", OK: "MAV-USDT-SWAP"}, - {Name: "ME", BN: "MEUSDT", BG: "MEUSDT", HL: "ME", OK: "ME-USDT-SWAP"}, - {Name: "MEGA", BN: "MEGAUSDT", BG: "MEGAUSDT", HL: "MEGA", OK: "MEGA-USDT-SWAP"}, - {Name: "MELANIA", BN: "MELANIAUSDT", BG: "MELANIAUSDT", HL: "MELANIA", OK: "MELANIA-USDT-SWAP"}, - {Name: "MEME", BN: "MEMEUSDT", BG: "MEMEUSDT", HL: "MEME", OK: "MEME-USDT-SWAP"}, - {Name: "MERL", BN: "MERLUSDT", BG: "MERLUSDT", HL: "MERL", OK: "MERL-USDT-SWAP"}, - {Name: "MET", BN: "METUSDT", BG: "METUSDT", HL: "MET", OK: "MET-USDT-SWAP"}, - {Name: "MINA", BN: "MINAUSDT", BG: "MINAUSDT", HL: "MINA", OK: "MINA-USDT-SWAP"}, - {Name: "MON", BN: "MONUSDT", BG: "MONUSDT", HL: "MON", OK: "MON-USDT-SWAP"}, - {Name: "MOODENG", BN: "MOODENGUSDT", BG: "MOODENGUSDT", HL: "MOODENG", OK: "MOODENG-USDT-SWAP"}, - {Name: "MORPHO", BN: "MORPHOUSDT", BG: "MORPHOUSDT", HL: "MORPHO", OK: "MORPHO-USDT-SWAP"}, - {Name: "MOVE", BN: "MOVEUSDT", BG: "MOVEUSDT", HL: "MOVE", OK: "MOVE-USDT-SWAP"}, - {Name: "NEAR", BN: "NEARUSDT", BG: "NEARUSDT", HL: "NEAR", OK: "NEAR-USDT-SWAP"}, - {Name: "NEO", BN: "NEOUSDT", BG: "NEOUSDT", HL: "NEO", OK: "NEO-USDT-SWAP"}, - {Name: "NIL", BN: "NILUSDT", BG: "NILUSDT", HL: "NIL", OK: "NIL-USDT-SWAP"}, - {Name: "NOT", BN: "NOTUSDT", BG: "NOTUSDT", HL: "NOT", OK: "NOT-USDT-SWAP"}, - {Name: "NXPC", BN: "NXPCUSDT", BG: "NXPCUSDT", HL: "NXPC", OK: "NXPC-USDT-SWAP"}, - {Name: "OGN", BN: "OGNUSDT", BG: "OGNUSDT", HL: "", OK: "OGN-USDT-SWAP"}, - {Name: "ORDI", BN: "ORDIUSDT", BG: "ORDIUSDT", HL: "ORDI", OK: "ORDI-USDT-SWAP"}, - {Name: "PAXG", BN: "PAXGUSDT", BG: "PAXGUSDT", HL: "PAXG", OK: "PAXG-USDT-SWAP"}, - {Name: "PENDLE", BN: "PENDLEUSDT", BG: "PENDLEUSDT", HL: "PENDLE", OK: "PENDLE-USDT-SWAP"}, - {Name: "PENGU", BN: "PENGUUSDT", BG: "PENGUUSDT", HL: "PENGU", OK: "PENGU-USDT-SWAP"}, - {Name: "PEOPLE", BN: "PEOPLEUSDT", BG: "PEOPLEUSDT", HL: "PEOPLE", OK: "PEOPLE-USDT-SWAP"}, - {Name: "PIXEL", BN: "PIXELUSDT", BG: "PIXELUSDT", HL: "", OK: "PIXEL-USDT-SWAP"}, - {Name: "PNUT", BN: "PNUTUSDT", BG: "PNUTUSDT", HL: "PNUT", OK: "PNUT-USDT-SWAP"}, - {Name: "POL", BN: "POLUSDT", BG: "POLUSDT", HL: "POL", OK: "POL-USDT-SWAP"}, - {Name: "POLYX", BN: "POLYXUSDT", BG: "POLYXUSDT", HL: "POLYX", OK: "POLYX-USDT-SWAP"}, - {Name: "POPCAT", BN: "POPCATUSDT", BG: "POPCATUSDT", HL: "POPCAT", OK: "POPCAT-USDT-SWAP"}, - {Name: "PROVE", BN: "PROVEUSDT", BG: "PROVEUSDT", HL: "PROVE", OK: "PROVE-USDT-SWAP"}, - {Name: "PUMP", BN: "PUMPUSDT", BG: "PUMPUSDT", HL: "PUMP", OK: "PUMP-USDT-SWAP"}, - {Name: "PYTH", BN: "PYTHUSDT", BG: "PYTHUSDT", HL: "PYTH", OK: "PYTH-USDT-SWAP"}, - {Name: "RENDER", BN: "RENDERUSDT", BG: "RENDERUSDT", HL: "RENDER", OK: "RENDER-USDT-SWAP"}, - {Name: "RESOLV", BN: "RESOLVUSDT", BG: "RESOLVUSDT", HL: "RESOLV", OK: "RESOLV-USDT-SWAP"}, - {Name: "REZ", BN: "REZUSDT", BG: "REZUSDT", HL: "REZ", OK: "REZ-USDT-SWAP"}, - {Name: "RSR", BN: "RSRUSDT", BG: "RSRUSDT", HL: "RSR", OK: "RSR-USDT-SWAP"}, - {Name: "RUNE", BN: "RUNEUSDT", BG: "RUNEUSDT", HL: "RUNE", OK: "RUNE-USDT-SWAP"}, - {Name: "S", BN: "SUSDT", BG: "SUSDT", HL: "S", OK: "S-USDT-SWAP"}, - {Name: "SAGA", BN: "SAGAUSDT", BG: "SAGAUSDT", HL: "SAGA", OK: "SAGA-USDT-SWAP"}, - {Name: "SAND", BN: "SANDUSDT", BG: "SANDUSDT", HL: "SAND", OK: "SAND-USDT-SWAP"}, - {Name: "SEI", BN: "SEIUSDT", BG: "SEIUSDT", HL: "SEI", OK: "SEI-USDT-SWAP"}, - {Name: "SKR", BN: "SKRUSDT", BG: "SKRUSDT", HL: "SKR", OK: "SKR-USDT-SWAP"}, - {Name: "SKY", BN: "SKYUSDT", BG: "SKYUSDT", HL: "SKY", OK: "SKY-USDT-SWAP"}, - {Name: "SNX", BN: "SNXUSDT", BG: "SNXUSDT", HL: "SNX", OK: "SNX-USDT-SWAP"}, - {Name: "SOL", BN: "SOLUSDT", BG: "SOLUSDT", HL: "SOL", OK: "SOL-USDT-SWAP"}, - {Name: "SOPH", BN: "SOPHUSDT", BG: "SOPHUSDT", HL: "SOPH", OK: "SOPH-USDT-SWAP"}, - {Name: "SPX", BN: "SPXUSDT", BG: "SPXUSDT", HL: "SPX", OK: "SPX-USDT-SWAP"}, - {Name: "STABLE", BN: "STABLEUSDT", BG: "STABLEUSDT", HL: "STABLE", OK: "STABLE-USDT-SWAP"}, - {Name: "STG", BN: "STGUSDT", BG: "STGUSDT", HL: "", OK: "STG-USDT-SWAP"}, - {Name: "STRK", BN: "STRKUSDT", BG: "STRKUSDT", HL: "STRK", OK: "STRK-USDT-SWAP"}, - {Name: "STX", BN: "STXUSDT", BG: "STXUSDT", HL: "STX", OK: "STX-USDT-SWAP"}, - {Name: "SUI", BN: "SUIUSDT", BG: "SUIUSDT", HL: "SUI", OK: "SUI-USDT-SWAP"}, - {Name: "SUPER", BN: "SUPERUSDT", BG: "SUPERUSDT", HL: "SUPER", OK: "SUPER-USDT-SWAP"}, - {Name: "SUSHI", BN: "SUSHIUSDT", BG: "SUSHIUSDT", HL: "SUSHI", OK: "SUSHI-USDT-SWAP"}, - {Name: "SYRUP", BN: "SYRUPUSDT", BG: "SYRUPUSDT", HL: "SYRUP", OK: "SYRUP-USDT-SWAP"}, - {Name: "TAO", BN: "TAOUSDT", BG: "TAOUSDT", HL: "TAO", OK: "TAO-USDT-SWAP"}, - {Name: "TIA", BN: "TIAUSDT", BG: "TIAUSDT", HL: "TIA", OK: "TIA-USDT-SWAP"}, - {Name: "TNSR", BN: "TNSRUSDT", BG: "TNSRUSDT", HL: "TNSR", OK: "TNSR-USDT-SWAP"}, - {Name: "TON", BN: "TONUSDT", BG: "TONUSDT", HL: "TON", OK: "TON-USDT-SWAP"}, - {Name: "TRB", BN: "TRBUSDT", BG: "TRBUSDT", HL: "TRB", OK: "TRB-USDT-SWAP"}, - {Name: "TRUMP", BN: "TRUMPUSDT", BG: "TRUMPUSDT", HL: "TRUMP", OK: "TRUMP-USDT-SWAP"}, - {Name: "TRX", BN: "TRXUSDT", BG: "TRXUSDT", HL: "TRX", OK: "TRX-USDT-SWAP"}, - {Name: "TURBO", BN: "TURBOUSDT", BG: "TURBOUSDT", HL: "TURBO", OK: "TURBO-USDT-SWAP"}, - {Name: "UMA", BN: "UMAUSDT", BG: "UMAUSDT", HL: "UMA", OK: "UMA-USDT-SWAP"}, - {Name: "UNI", BN: "UNIUSDT", BG: "UNIUSDT", HL: "UNI", OK: "UNI-USDT-SWAP"}, - {Name: "USUAL", BN: "USUALUSDT", BG: "USUALUSDT", HL: "USUAL", OK: "USUAL-USDT-SWAP"}, - {Name: "VIRTUAL", BN: "VIRTUALUSDT", BG: "VIRTUALUSDT", HL: "VIRTUAL", OK: "VIRTUAL-USDT-SWAP"}, - {Name: "VVV", BN: "VVVUSDT", BG: "VVVUSDT", HL: "VVV", OK: "VVV-USDT-SWAP"}, - {Name: "W", BN: "WUSDT", BG: "WUSDT", HL: "W", OK: "W-USDT-SWAP"}, - {Name: "WCT", BN: "WCTUSDT", BG: "WCTUSDT", HL: "WCT", OK: "WCT-USDT-SWAP"}, - {Name: "WLD", BN: "WLDUSDT", BG: "WLDUSDT", HL: "WLD", OK: "WLD-USDT-SWAP"}, - {Name: "WLFI", BN: "WLFIUSDT", BG: "WLFIUSDT", HL: "WLFI", OK: "WLFI-USDT-SWAP"}, - {Name: "XAI", BN: "XAIUSDT", BG: "XAIUSDT", HL: "XAI", OK: "XAI-USDT-SWAP"}, - {Name: "XLM", BN: "XLMUSDT", BG: "XLMUSDT", HL: "XLM", OK: "XLM-USDT-SWAP"}, - {Name: "XMR", BN: "XMRUSDT", BG: "XMRUSDT", HL: "XMR", OK: "XMR-USDT-SWAP"}, - {Name: "XPL", BN: "XPLUSDT", BG: "XPLUSDT", HL: "XPL", OK: "XPL-USDT-SWAP"}, - {Name: "XRP", BN: "XRPUSDT", BG: "XRPUSDT", HL: "XRP", OK: "XRP-USDT-SWAP"}, - {Name: "YGG", BN: "YGGUSDT", BG: "YGGUSDT", HL: "YGG", OK: "YGG-USDT-SWAP"}, - {Name: "ZEC", BN: "ZECUSDT", BG: "ZECUSDT", HL: "ZEC", OK: "ZEC-USDT-SWAP"}, - {Name: "ZEN", BN: "ZENUSDT", BG: "ZENUSDT", HL: "ZEN", OK: "ZEN-USDT-SWAP"}, - {Name: "ZETA", BN: "ZETAUSDT", BG: "ZETAUSDT", HL: "ZETA", OK: "ZETA-USDT-SWAP"}, - {Name: "ZK", BN: "ZKUSDT", BG: "ZKUSDT", HL: "ZK", OK: "ZK-USDT-SWAP"}, - {Name: "ZORA", BN: "ZORAUSDT", BG: "ZORAUSDT", HL: "ZORA", OK: "ZORA-USDT-SWAP"}, - {Name: "ZRO", BN: "ZROUSDT", BG: "ZROUSDT", HL: "ZRO", OK: "ZRO-USDT-SWAP"}, + {Name: "DOGE", BN: "DOGEUSDT", BG: "DOGEUSDT", OK: "DOGE-USDT-SWAP"}, + {Name: "LINK", BN: "LINKUSDT", BG: "LINKUSDT", OK: "LINK-USDT-SWAP"}, + {Name: "ONDO", BN: "ONDOUSDT", BG: "ONDOUSDT", OK: "ONDO-USDT-SWAP"}, + {Name: "OP", BN: "OPUSDT", BG: "OPUSDT", OK: "OP-USDT-SWAP"}, + {Name: "WIF", BN: "WIFUSDT", BG: "WIFUSDT", OK: "WIF-USDT-SWAP"}, + {Name: "ARB", BN: "ARBUSDT", BG: "ARBUSDT", OK: "ARB-USDT-SWAP"}, + {Name: "0G", BN: "0GUSDT", BG: "0GUSDT", OK: "0G-USDT-SWAP"}, + {Name: "2Z", BN: "2ZUSDT", BG: "2ZUSDT", OK: "2Z-USDT-SWAP"}, + {Name: "AAVE", BN: "AAVEUSDT", BG: "AAVEUSDT", OK: "AAVE-USDT-SWAP"}, + {Name: "ACE", BN: "ACEUSDT", BG: "ACEUSDT", OK: "ACE-USDT-SWAP"}, + {Name: "ADA", BN: "ADAUSDT", BG: "ADAUSDT", OK: "ADA-USDT-SWAP"}, + {Name: "AIXBT", BN: "AIXBTUSDT", BG: "AIXBTUSDT", OK: "AIXBT-USDT-SWAP"}, + {Name: "ALGO", BN: "ALGOUSDT", BG: "ALGOUSDT", OK: "ALGO-USDT-SWAP"}, + {Name: "ALT", BN: "ALTUSDT", BG: "ALTUSDT", OK: "ALT-USDT-SWAP"}, + {Name: "ANIME", BN: "ANIMEUSDT", BG: "ANIMEUSDT", OK: "ANIME-USDT-SWAP"}, + {Name: "APE", BN: "APEUSDT", BG: "APEUSDT", OK: "APE-USDT-SWAP"}, + {Name: "APT", BN: "APTUSDT", BG: "APTUSDT", OK: "APT-USDT-SWAP"}, + {Name: "AR", BN: "ARUSDT", BG: "ARUSDT", OK: "AR-USDT-SWAP"}, + {Name: "ARK", BN: "ARKUSDT", BG: "ARKUSDT", OK: "ARK-USDT-SWAP"}, + {Name: "ASTER", BN: "ASTERUSDT", BG: "ASTERUSDT", OK: "ASTER-USDT-SWAP"}, + {Name: "ATOM", BN: "ATOMUSDT", BG: "ATOMUSDT", OK: "ATOM-USDT-SWAP"}, + {Name: "AVAX", BN: "AVAXUSDT", BG: "AVAXUSDT", OK: "AVAX-USDT-SWAP"}, + {Name: "AVNT", BN: "AVNTUSDT", BG: "AVNTUSDT", OK: "AVNT-USDT-SWAP"}, + {Name: "AXS", BN: "AXSUSDT", BG: "AXSUSDT", OK: "AXS-USDT-SWAP"}, + {Name: "AZTEC", BN: "AZTECUSDT", BG: "AZTECUSDT", OK: "AZTEC-USDT-SWAP"}, + {Name: "BABY", BN: "BABYUSDT", BG: "BABYUSDT", OK: "BABY-USDT-SWAP"}, + {Name: "BANANA", BN: "BANANAUSDT", BG: "BANANAUSDT", OK: "BANANA-USDT-SWAP"}, + {Name: "BCH", BN: "BCHUSDT", BG: "BCHUSDT", OK: "BCH-USDT-SWAP"}, + {Name: "BERA", BN: "BERAUSDT", BG: "BERAUSDT", OK: "BERA-USDT-SWAP"}, + {Name: "BIGTIME", BN: "BIGTIMEUSDT", BG: "BIGTIMEUSDT", OK: "BIGTIME-USDT-SWAP"}, + {Name: "BIO", BN: "BIOUSDT", BG: "BIOUSDT", OK: "BIO-USDT-SWAP"}, + {Name: "BLUR", BN: "BLURUSDT", BG: "BLURUSDT", OK: "BLUR-USDT-SWAP"}, + {Name: "BNB", BN: "BNBUSDT", BG: "BNBUSDT", OK: "BNB-USDT-SWAP"}, + {Name: "BNT", BN: "BNTUSDT", BG: "BNTUSDT", OK: "BNT-USDT-SWAP"}, + {Name: "BOME", BN: "BOMEUSDT", BG: "BOMEUSDT", OK: "BOME-USDT-SWAP"}, + {Name: "BRETT", BN: "BRETTUSDT", BG: "BRETTUSDT", OK: "BRETT-USDT-SWAP"}, + {Name: "BSV", BN: "BSVUSDT", BG: "BSVUSDT", OK: "BSV-USDT-SWAP"}, + {Name: "BTC", BN: "BTCUSDT", BG: "BTCUSDT", OK: "BTC-USDT-SWAP"}, + {Name: "CAKE", BN: "CAKEUSDT", BG: "CAKEUSDT", OK: "CAKE-USDT-SWAP"}, + {Name: "CATI", BN: "CATIUSDT", BG: "CATIUSDT", OK: "CATI-USDT-SWAP"}, + {Name: "CC", BN: "CCUSDT", BG: "CCUSDT", OK: "CC-USDT-SWAP"}, + {Name: "CELO", BN: "CELOUSDT", BG: "CELOUSDT", OK: "CELO-USDT-SWAP"}, + {Name: "CFX", BN: "CFXUSDT", BG: "CFXUSDT", OK: "CFX-USDT-SWAP"}, + {Name: "CHILLGUY", BN: "CHILLGUYUSDT", BG: "CHILLGUYUSDT", OK: "CHILLGUY-USDT-SWAP"}, + {Name: "CHIP", BN: "CHIPUSDT", BG: "CHIPUSDT", OK: "CHIP-USDT-SWAP"}, + {Name: "COMP", BN: "COMPUSDT", BG: "COMPUSDT", OK: "COMP-USDT-SWAP"}, + {Name: "CRV", BN: "CRVUSDT", BG: "CRVUSDT", OK: "CRV-USDT-SWAP"}, + {Name: "CYBER", BN: "CYBERUSDT", BG: "CYBERUSDT", OK: "CYBER-USDT-SWAP"}, + {Name: "DASH", BN: "DASHUSDT", BG: "DASHUSDT", OK: "DASH-USDT-SWAP"}, + {Name: "DOOD", BN: "DOODUSDT", BG: "DOODUSDT", OK: "DOOD-USDT-SWAP"}, + {Name: "DOT", BN: "DOTUSDT", BG: "DOTUSDT", OK: "DOT-USDT-SWAP"}, + {Name: "DYDX", BN: "DYDXUSDT", BG: "DYDXUSDT", OK: "DYDX-USDT-SWAP"}, + {Name: "DYM", BN: "DYMUSDT", BG: "DYMUSDT", OK: "DYM-USDT-SWAP"}, + {Name: "EIGEN", BN: "EIGENUSDT", BG: "EIGENUSDT", OK: "EIGEN-USDT-SWAP"}, + {Name: "ENA", BN: "ENAUSDT", BG: "ENAUSDT", OK: "ENA-USDT-SWAP"}, + {Name: "ENS", BN: "ENSUSDT", BG: "ENSUSDT", OK: "ENS-USDT-SWAP"}, + {Name: "ETC", BN: "ETCUSDT", BG: "ETCUSDT", OK: "ETC-USDT-SWAP"}, + {Name: "ETH", BN: "ETHUSDT", BG: "ETHUSDT", OK: "ETH-USDT-SWAP"}, + {Name: "ETHFI", BN: "ETHFIUSDT", BG: "ETHFIUSDT", OK: "ETHFI-USDT-SWAP"}, + {Name: "FARTCOIN", BN: "FARTCOINUSDT", BG: "FARTCOINUSDT", OK: "FARTCOIN-USDT-SWAP"}, + {Name: "FET", BN: "FETUSDT", BG: "FETUSDT", OK: "FET-USDT-SWAP"}, + {Name: "FIL", BN: "FILUSDT", BG: "FILUSDT", OK: "FIL-USDT-SWAP"}, + {Name: "FOGO", BN: "FOGOUSDT", BG: "FOGOUSDT", OK: "FOGO-USDT-SWAP"}, + {Name: "GALA", BN: "GALAUSDT", BG: "GALAUSDT", OK: "GALA-USDT-SWAP"}, + {Name: "GAS", BN: "GASUSDT", BG: "GASUSDT", OK: "GAS-USDT-SWAP"}, + {Name: "GMT", BN: "GMTUSDT", BG: "GMTUSDT", OK: "GMT-USDT-SWAP"}, + {Name: "GMX", BN: "GMXUSDT", BG: "GMXUSDT", OK: "GMX-USDT-SWAP"}, + {Name: "GOAT", BN: "GOATUSDT", BG: "GOATUSDT", OK: "GOAT-USDT-SWAP"}, + {Name: "GRASS", BN: "GRASSUSDT", BG: "GRASSUSDT", OK: "GRASS-USDT-SWAP"}, + {Name: "GRIFFAIN", BN: "GRIFFAINUSDT", BG: "GRIFFAINUSDT", OK: "GRIFFAIN-USDT-SWAP"}, + {Name: "HBAR", BN: "HBARUSDT", BG: "HBARUSDT", OK: "HBAR-USDT-SWAP"}, + {Name: "HYPE", BN: "HYPEUSDT", BG: "HYPEUSDT", OK: "HYPE-USDT-SWAP"}, + {Name: "HYPER", BN: "HYPERUSDT", BG: "HYPERUSDT", OK: "HYPER-USDT-SWAP"}, + {Name: "ICP", BN: "ICPUSDT", BG: "ICPUSDT", OK: "ICP-USDT-SWAP"}, + {Name: "ILV", BN: "ILVUSDT", BG: "ILVUSDT", OK: "ILV-USDT-SWAP"}, + {Name: "IMX", BN: "IMXUSDT", BG: "IMXUSDT", OK: "IMX-USDT-SWAP"}, + {Name: "INIT", BN: "INITUSDT", BG: "INITUSDT", OK: "INIT-USDT-SWAP"}, + {Name: "INJ", BN: "INJUSDT", BG: "INJUSDT", OK: "INJ-USDT-SWAP"}, + {Name: "IO", BN: "IOUSDT", BG: "IOUSDT", OK: "IO-USDT-SWAP"}, + {Name: "IOTA", BN: "IOTAUSDT", BG: "IOTAUSDT", OK: "IOTA-USDT-SWAP"}, + {Name: "IP", BN: "IPUSDT", BG: "IPUSDT", OK: "IP-USDT-SWAP"}, + {Name: "JTO", BN: "JTOUSDT", BG: "JTOUSDT", OK: "JTO-USDT-SWAP"}, + {Name: "JUP", BN: "JUPUSDT", BG: "JUPUSDT", OK: "JUP-USDT-SWAP"}, + {Name: "KAITO", BN: "KAITOUSDT", BG: "KAITOUSDT", OK: "KAITO-USDT-SWAP"}, + {Name: "KAS", BN: "KASUSDT", BG: "KASUSDT", OK: "KAS-USDT-SWAP"}, + {Name: "LAYER", BN: "LAYERUSDT", BG: "LAYERUSDT", OK: "LAYER-USDT-SWAP"}, + {Name: "LDO", BN: "LDOUSDT", BG: "LDOUSDT", OK: "LDO-USDT-SWAP"}, + {Name: "LINEA", BN: "LINEAUSDT", BG: "LINEAUSDT", OK: "LINEA-USDT-SWAP"}, + {Name: "LISTA", BN: "LISTAUSDT", BG: "LISTAUSDT", OK: "LISTA-USDT-SWAP"}, + {Name: "LIT", BN: "LITUSDT", BG: "LITUSDT", OK: "LIT-USDT-SWAP"}, + {Name: "LTC", BN: "LTCUSDT", BG: "LTCUSDT", OK: "LTC-USDT-SWAP"}, + {Name: "MANTA", BN: "MANTAUSDT", BG: "MANTAUSDT", OK: "MANTA-USDT-SWAP"}, + {Name: "MAV", BN: "MAVUSDT", BG: "MAVUSDT", OK: "MAV-USDT-SWAP"}, + {Name: "ME", BN: "MEUSDT", BG: "MEUSDT", OK: "ME-USDT-SWAP"}, + {Name: "MEGA", BN: "MEGAUSDT", BG: "MEGAUSDT", OK: "MEGA-USDT-SWAP"}, + {Name: "MELANIA", BN: "MELANIAUSDT", BG: "MELANIAUSDT", OK: "MELANIA-USDT-SWAP"}, + {Name: "MEME", BN: "MEMEUSDT", BG: "MEMEUSDT", OK: "MEME-USDT-SWAP"}, + {Name: "MERL", BN: "MERLUSDT", BG: "MERLUSDT", OK: "MERL-USDT-SWAP"}, + {Name: "MET", BN: "METUSDT", BG: "METUSDT", OK: "MET-USDT-SWAP"}, + {Name: "MINA", BN: "MINAUSDT", BG: "MINAUSDT", OK: "MINA-USDT-SWAP"}, + {Name: "MON", BN: "MONUSDT", BG: "MONUSDT", OK: "MON-USDT-SWAP"}, + {Name: "MOODENG", BN: "MOODENGUSDT", BG: "MOODENGUSDT", OK: "MOODENG-USDT-SWAP"}, + {Name: "MORPHO", BN: "MORPHOUSDT", BG: "MORPHOUSDT", OK: "MORPHO-USDT-SWAP"}, + {Name: "MOVE", BN: "MOVEUSDT", BG: "MOVEUSDT", OK: "MOVE-USDT-SWAP"}, + {Name: "NEAR", BN: "NEARUSDT", BG: "NEARUSDT", OK: "NEAR-USDT-SWAP"}, + {Name: "NEO", BN: "NEOUSDT", BG: "NEOUSDT", OK: "NEO-USDT-SWAP"}, + {Name: "NIL", BN: "NILUSDT", BG: "NILUSDT", OK: "NIL-USDT-SWAP"}, + {Name: "NOT", BN: "NOTUSDT", BG: "NOTUSDT", OK: "NOT-USDT-SWAP"}, + {Name: "NXPC", BN: "NXPCUSDT", BG: "NXPCUSDT", OK: "NXPC-USDT-SWAP"}, + {Name: "OGN", BN: "OGNUSDT", BG: "OGNUSDT", OK: "OGN-USDT-SWAP"}, + {Name: "ORDI", BN: "ORDIUSDT", BG: "ORDIUSDT", OK: "ORDI-USDT-SWAP"}, + {Name: "PAXG", BN: "PAXGUSDT", BG: "PAXGUSDT", OK: "PAXG-USDT-SWAP"}, + {Name: "PENDLE", BN: "PENDLEUSDT", BG: "PENDLEUSDT", OK: "PENDLE-USDT-SWAP"}, + {Name: "PENGU", BN: "PENGUUSDT", BG: "PENGUUSDT", OK: "PENGU-USDT-SWAP"}, + {Name: "PEOPLE", BN: "PEOPLEUSDT", BG: "PEOPLEUSDT", OK: "PEOPLE-USDT-SWAP"}, + {Name: "PIXEL", BN: "PIXELUSDT", BG: "PIXELUSDT", OK: "PIXEL-USDT-SWAP"}, + {Name: "PNUT", BN: "PNUTUSDT", BG: "PNUTUSDT", OK: "PNUT-USDT-SWAP"}, + {Name: "POL", BN: "POLUSDT", BG: "POLUSDT", OK: "POL-USDT-SWAP"}, + {Name: "POLYX", BN: "POLYXUSDT", BG: "POLYXUSDT", OK: "POLYX-USDT-SWAP"}, + {Name: "POPCAT", BN: "POPCATUSDT", BG: "POPCATUSDT", OK: "POPCAT-USDT-SWAP"}, + {Name: "PROVE", BN: "PROVEUSDT", BG: "PROVEUSDT", OK: "PROVE-USDT-SWAP"}, + {Name: "PUMP", BN: "PUMPUSDT", BG: "PUMPUSDT", OK: "PUMP-USDT-SWAP"}, + {Name: "PYTH", BN: "PYTHUSDT", BG: "PYTHUSDT", OK: "PYTH-USDT-SWAP"}, + {Name: "RENDER", BN: "RENDERUSDT", BG: "RENDERUSDT", OK: "RENDER-USDT-SWAP"}, + {Name: "RESOLV", BN: "RESOLVUSDT", BG: "RESOLVUSDT", OK: "RESOLV-USDT-SWAP"}, + {Name: "REZ", BN: "REZUSDT", BG: "REZUSDT", OK: "REZ-USDT-SWAP"}, + {Name: "RSR", BN: "RSRUSDT", BG: "RSRUSDT", OK: "RSR-USDT-SWAP"}, + {Name: "RUNE", BN: "RUNEUSDT", BG: "RUNEUSDT", OK: "RUNE-USDT-SWAP"}, + {Name: "S", BN: "SUSDT", BG: "SUSDT", OK: "S-USDT-SWAP"}, + {Name: "SAGA", BN: "SAGAUSDT", BG: "SAGAUSDT", OK: "SAGA-USDT-SWAP"}, + {Name: "SAND", BN: "SANDUSDT", BG: "SANDUSDT", OK: "SAND-USDT-SWAP"}, + {Name: "SEI", BN: "SEIUSDT", BG: "SEIUSDT", OK: "SEI-USDT-SWAP"}, + {Name: "SKR", BN: "SKRUSDT", BG: "SKRUSDT", OK: "SKR-USDT-SWAP"}, + {Name: "SKY", BN: "SKYUSDT", BG: "SKYUSDT", OK: "SKY-USDT-SWAP"}, + {Name: "SNX", BN: "SNXUSDT", BG: "SNXUSDT", OK: "SNX-USDT-SWAP"}, + {Name: "SOL", BN: "SOLUSDT", BG: "SOLUSDT", OK: "SOL-USDT-SWAP"}, + {Name: "SOPH", BN: "SOPHUSDT", BG: "SOPHUSDT", OK: "SOPH-USDT-SWAP"}, + {Name: "SPX", BN: "SPXUSDT", BG: "SPXUSDT", OK: "SPX-USDT-SWAP"}, + {Name: "STABLE", BN: "STABLEUSDT", BG: "STABLEUSDT", OK: "STABLE-USDT-SWAP"}, + {Name: "STG", BN: "STGUSDT", BG: "STGUSDT", OK: "STG-USDT-SWAP"}, + {Name: "STRK", BN: "STRKUSDT", BG: "STRKUSDT", OK: "STRK-USDT-SWAP"}, + {Name: "STX", BN: "STXUSDT", BG: "STXUSDT", OK: "STX-USDT-SWAP"}, + {Name: "SUI", BN: "SUIUSDT", BG: "SUIUSDT", OK: "SUI-USDT-SWAP"}, + {Name: "SUPER", BN: "SUPERUSDT", BG: "SUPERUSDT", OK: "SUPER-USDT-SWAP"}, + {Name: "SUSHI", BN: "SUSHIUSDT", BG: "SUSHIUSDT", OK: "SUSHI-USDT-SWAP"}, + {Name: "SYRUP", BN: "SYRUPUSDT", BG: "SYRUPUSDT", OK: "SYRUP-USDT-SWAP"}, + {Name: "TAO", BN: "TAOUSDT", BG: "TAOUSDT", OK: "TAO-USDT-SWAP"}, + {Name: "TIA", BN: "TIAUSDT", BG: "TIAUSDT", OK: "TIA-USDT-SWAP"}, + {Name: "TNSR", BN: "TNSRUSDT", BG: "TNSRUSDT", OK: "TNSR-USDT-SWAP"}, + {Name: "TON", BN: "TONUSDT", BG: "TONUSDT", OK: "TON-USDT-SWAP"}, + {Name: "TRB", BN: "TRBUSDT", BG: "TRBUSDT", OK: "TRB-USDT-SWAP"}, + {Name: "TRUMP", BN: "TRUMPUSDT", BG: "TRUMPUSDT", OK: "TRUMP-USDT-SWAP"}, + {Name: "TRX", BN: "TRXUSDT", BG: "TRXUSDT", OK: "TRX-USDT-SWAP"}, + {Name: "TURBO", BN: "TURBOUSDT", BG: "TURBOUSDT", OK: "TURBO-USDT-SWAP"}, + {Name: "UMA", BN: "UMAUSDT", BG: "UMAUSDT", OK: "UMA-USDT-SWAP"}, + {Name: "UNI", BN: "UNIUSDT", BG: "UNIUSDT", OK: "UNI-USDT-SWAP"}, + {Name: "USUAL", BN: "USUALUSDT", BG: "USUALUSDT", OK: "USUAL-USDT-SWAP"}, + {Name: "VIRTUAL", BN: "VIRTUALUSDT", BG: "VIRTUALUSDT", OK: "VIRTUAL-USDT-SWAP"}, + {Name: "VVV", BN: "VVVUSDT", BG: "VVVUSDT", OK: "VVV-USDT-SWAP"}, + {Name: "W", BN: "WUSDT", BG: "WUSDT", OK: "W-USDT-SWAP"}, + {Name: "WCT", BN: "WCTUSDT", BG: "WCTUSDT", OK: "WCT-USDT-SWAP"}, + {Name: "WLD", BN: "WLDUSDT", BG: "WLDUSDT", OK: "WLD-USDT-SWAP"}, + {Name: "WLFI", BN: "WLFIUSDT", BG: "WLFIUSDT", OK: "WLFI-USDT-SWAP"}, + {Name: "XAI", BN: "XAIUSDT", BG: "XAIUSDT", OK: "XAI-USDT-SWAP"}, + {Name: "XLM", BN: "XLMUSDT", BG: "XLMUSDT", OK: "XLM-USDT-SWAP"}, + {Name: "XMR", BN: "XMRUSDT", BG: "XMRUSDT", OK: "XMR-USDT-SWAP"}, + {Name: "XPL", BN: "XPLUSDT", BG: "XPLUSDT", OK: "XPL-USDT-SWAP"}, + {Name: "XRP", BN: "XRPUSDT", BG: "XRPUSDT", OK: "XRP-USDT-SWAP"}, + {Name: "YGG", BN: "YGGUSDT", BG: "YGGUSDT", OK: "YGG-USDT-SWAP"}, + {Name: "ZEC", BN: "ZECUSDT", BG: "ZECUSDT", OK: "ZEC-USDT-SWAP"}, + {Name: "ZEN", BN: "ZENUSDT", BG: "ZENUSDT", OK: "ZEN-USDT-SWAP"}, + {Name: "ZETA", BN: "ZETAUSDT", BG: "ZETAUSDT", OK: "ZETA-USDT-SWAP"}, + {Name: "ZK", BN: "ZKUSDT", BG: "ZKUSDT", OK: "ZK-USDT-SWAP"}, + {Name: "ZORA", BN: "ZORAUSDT", BG: "ZORAUSDT", OK: "ZORA-USDT-SWAP"}, + {Name: "ZRO", BN: "ZROUSDT", BG: "ZROUSDT", OK: "ZRO-USDT-SWAP"}, } -// netProfit calculates net profit % after fees for a complete round trip (entry + exit). -// NOTE: Does NOT swap prices — callers (ScanArbWithFees) pass prices in explicit buy/sell order -// and try both directions via addPair. Using exchange.CalcNetProfit would double-swap (B#6). -func netProfit(buyPrice, sellPrice, buyFee, sellFee float64) float64 { - if buyPrice <= 0 || sellPrice <= 0 { - return 0 - } - // Entry: buy at buyPrice (pay buyFee), sell short at sellPrice (pay sellFee) - cost := buyPrice * (1 + buyFee/100) - revenue := sellPrice * (1 - sellFee/100) - // Exit: sell long (pay sellFee), buy back short (pay buyFee) - // Total fees = 2 * (buyFee + sellFee), first round already in formula above - return (revenue/cost - 1)*100 - 2*(buyFee + sellFee) +// ThreeExSpread holds the 3-exchange max spread for a single coin. +type ThreeExSpread struct { + Coin string + BnPrice float64 + OkxPrice float64 + BgPrice float64 + SpreadPct float64 // (max-min)/min*100 across 3 exchanges + MaxEx string // exchange with highest price + MinEx string // exchange with lowest price } -// ScanBGHL scans coins for arbitrage ONLY between Bitget and HyperLiquid (P3-1). -// Returns both directions (BG->HL and HL->BG) sorted by net profit descending. -func ScanBGHL(snap map[string]map[string]float64) []*ArbOpportunity { - var results []*ArbOpportunity +// Scan3Ex computes 3-exchange max spread for all tracked coins. +// Requires at least 2 of 3 exchanges to have a price. +func Scan3Ex(snap map[string]map[string]float64) []ThreeExSpread { + var results []ThreeExSpread for _, coin := range TrackedCoins { - if coin.BG == "" || coin.HL == "" { - continue // skip coins not available on both exchanges - } exMap := snap[coin.Name] if exMap == nil { continue } + + bnP := exMap[ExBinance] + okxP := exMap[ExOKX] bgP := exMap[ExBitget] - hlP := exMap[ExHyperLiquid] - if bgP <= 0 || hlP <= 0 { + + // Need at least 2 exchanges + count := 0 + if bnP > 0 { count++ } + if okxP > 0 { count++ } + if bgP > 0 { count++ } + if count < 2 { continue } - // BG->HL: buy cheap at Bitget, sell expensive at HyperLiquid - profitBG := netProfit(bgP, hlP, takerFees[ExBitget], takerFees[ExHyperLiquid]) - // HL->BG: buy cheap at HyperLiquid, sell expensive at Bitget - profitHL := netProfit(hlP, bgP, takerFees[ExHyperLiquid], takerFees[ExBitget]) + // Find min/max among available prices + prices := []struct { + ex string + p float64 + }{} + if bnP > 0 { prices = append(prices, struct{ ex string; p float64 }{ExBinance, bnP}) } + if okxP > 0 { prices = append(prices, struct{ ex string; p float64 }{ExOKX, okxP}) } + if bgP > 0 { prices = append(prices, struct{ ex string; p float64 }{ExBitget, bgP}) } - grossBG := (hlP - bgP) / bgP * 100 - grossHL := (bgP - hlP) / hlP * 100 + minP, maxP := prices[0], prices[0] + for _, pp := range prices[1:] { + if pp.p < minP.p { minP = pp } + if pp.p > maxP.p { maxP = pp } + } - results = append(results, &ArbOpportunity{ - Coin: coin.Name, - Direction: "BG->HL", - BuyEx: ExBitget, - SellEx: ExHyperLiquid, - BuyPrice: bgP, - SellPrice: hlP, - NetProfit: profitBG, - GrossBasis: grossBG, - }, &ArbOpportunity{ - Coin: coin.Name, - Direction: "HL->BG", - BuyEx: ExHyperLiquid, - SellEx: ExBitget, - BuyPrice: hlP, - SellPrice: bgP, - NetProfit: profitHL, - GrossBasis: grossHL, + spread := (maxP.p - minP.p) / minP.p * 100 + + results = append(results, ThreeExSpread{ + Coin: coin.Name, + BnPrice: bnP, + OkxPrice: okxP, + BgPrice: bgP, + SpreadPct: spread, + MaxEx: maxP.ex, + MinEx: minP.ex, }) } + // Sort by spread descending sort.Slice(results, func(i, j int) bool { - return results[i].NetProfit > results[j].NetProfit + return results[i].SpreadPct > results[j].SpreadPct }) return results diff --git a/start.sh b/start.sh index c294498..d1986e0 100755 --- a/start.sh +++ b/start.sh @@ -50,10 +50,9 @@ if [ "$CLEAN_DB" = true ]; then fi # 设置代理(Clash Verge 本地代理,用于 Binance/OKX/Bitget WS 连接) -# HyperLiquid 直连,无需代理 export HTTPS_PROXY=http://127.0.0.1:7897 export HTTP_PROXY=http://127.0.0.1:7897 -export NO_PROXY="api.hyperliquid.xyz,hyperliquid.xyz,localhost,127.0.0.1" +export NO_PROXY="localhost,127.0.0.1" # 编译 NEED_BUILD=false @@ -63,7 +62,7 @@ elif [ "$FORCE_REBUILD" = true ]; then NEED_BUILD=true elif [ -n "$(find . -name '*.go' -newer "$BIN" 2>/dev/null | head -1)" ]; then NEED_BUILD=true -elif [ -n "$(find web/static -newer "$BIN" 2>/dev/null | head -1)" ]; then +elif [ -n "$(find frontend/dist -newer "$BIN" 2>/dev/null | head -1)" ]; then NEED_BUILD=true fi diff --git a/surge_detector.go b/surge_detector.go new file mode 100644 index 0000000..4434399 --- /dev/null +++ b/surge_detector.go @@ -0,0 +1,336 @@ +package main + +import ( + "log" + "math" + "sort" + "sync" + "time" +) + +// SurgeDetector detects anomalous 3-exchange max spreads using per-coin adaptive baselines. +// Theory: when a coin starts moving sharply, different exchanges update at different speeds, +// creating a temporary spike in inter-exchange spread. This detector captures that moment. +type SurgeDetector struct { + mu sync.Mutex + coins map[string]*coinSurgeState + + // Config + enabled bool + windowSize int // rolling window samples (default: 600 = ~30s at 50ms tick) + baselineMul float64 // baseline * N = threshold (default: 3.0) + minAbsSpreadPct float64 // minimum absolute spread % to trigger (default: 0.05) + cooldownSec int // seconds between alerts for same coin (default: 60) + + // Recent events (ring buffer) + events []SurgeEvent + eventIdx int + maxEvents int + + // DB persistence callback + onEvent func(SurgeEvent) +} + +// coinSurgeState holds per-coin adaptive baseline data. +type coinSurgeState struct { + spreads []float64 // rolling window of recent spread values + lastAlertAt time.Time +} + +// SurgeEvent represents a detected surge anomaly. +type SurgeEvent struct { + Timestamp time.Time `json:"timestamp"` + Coin string `json:"coin"` + BnPrice float64 `json:"bn_price"` + OkxPrice float64 `json:"okx_price"` + BgPrice float64 `json:"bg_price"` + SpreadPct float64 `json:"spread_pct"` // current 3-exchange max spread + BaselinePct float64 `json:"baseline_pct"` // adaptive baseline at time of event + ThresholdPct float64 `json:"threshold_pct"` // trigger threshold + Ratio float64 `json:"ratio"` // spread / threshold + Direction string `json:"direction"` // "up" or "down" + LeadingExchange string `json:"leading_exchange"` // which exchange moved first/furthest + MidPrice float64 `json:"mid_price"` // median of 3 prices +} + +// SurgeSnapshot holds current spread/baseline state for a coin (SSE push). +type SurgeSnapshot struct { + Coin string `json:"coin"` + SpreadPct float64 `json:"spread_pct"` + BaselinePct float64 `json:"baseline_pct"` + ThresholdPct float64 `json:"threshold_pct"` + Direction string `json:"direction,omitempty"` // "up"/"down" if currently surging + WindowSize int `json:"window_size"` // current number of samples in window +} + +func NewSurgeDetector() *SurgeDetector { + return &SurgeDetector{ + coins: make(map[string]*coinSurgeState), + events: make([]SurgeEvent, 200), + maxEvents: 200, + } +} + +// Configure sets detection parameters. +func (sd *SurgeDetector) Configure(windowSize int, baselineMul, minAbsSpreadPct float64, cooldownSec int) { + sd.enabled = true + sd.windowSize = windowSize + sd.baselineMul = baselineMul + sd.minAbsSpreadPct = minAbsSpreadPct + sd.cooldownSec = cooldownSec +} + +// SetOnEvent sets the DB persistence callback. +func (sd *SurgeDetector) SetOnEvent(fn func(SurgeEvent)) { + sd.onEvent = fn +} + +// Tick processes one snapshot tick, detecting surges for all coins. +// Returns newly detected events for immediate SSE broadcast. +func (sd *SurgeDetector) Tick(snap map[string]map[string]float64) []SurgeEvent { + if !sd.enabled { + return nil + } + + sd.mu.Lock() + defer sd.mu.Unlock() + + var newEvents []SurgeEvent + now := time.Now() + + for _, coin := range TrackedCoins { + exMap := snap[coin.Name] + if exMap == nil { + continue + } + + bnP := exMap[ExBinance] + okxP := exMap[ExOKX] + bgP := exMap[ExBitget] + + // Need at least 2 exchanges + prices := []float64{} + if bnP > 0 { prices = append(prices, bnP) } + if okxP > 0 { prices = append(prices, okxP) } + if bgP > 0 { prices = append(prices, bgP) } + if len(prices) < 2 { + continue + } + + // Compute 3-exchange max spread + minP, maxP := prices[0], prices[0] + for _, p := range prices[1:] { + if p < minP { minP = p } + if p > maxP { maxP = p } + } + spread := (maxP - minP) / minP * 100 + + // Get or create coin state + state, exists := sd.coins[coin.Name] + if !exists { + state = &coinSurgeState{ + spreads: make([]float64, 0, sd.windowSize), + } + sd.coins[coin.Name] = state + } + + // Add spread to rolling window + state.spreads = append(state.spreads, spread) + if len(state.spreads) > sd.windowSize { + state.spreads = state.spreads[len(state.spreads)-sd.windowSize:] + } + + // Need minimum samples for baseline (at least 10) + if len(state.spreads) < 10 { + continue + } + + // Compute baseline = median of recent spreads + baseline := median(state.spreads) + + // Threshold = baseline * multiplier, but at least minAbsSpreadPct + threshold := baseline * sd.baselineMul + if threshold < sd.minAbsSpreadPct { + threshold = sd.minAbsSpreadPct + } + + // Check if spread exceeds threshold AND cooldown has passed + if spread < threshold { + continue + } + if !state.lastAlertAt.IsZero() && now.Sub(state.lastAlertAt).Seconds() < float64(sd.cooldownSec) { + continue + } + + // Surge detected — determine direction + midPrice := median(prices) + var direction, leadingEx string + + // Find max and min exchanges for reporting + exPrices := map[string]float64{} + if bnP > 0 { exPrices[ExBinance] = bnP } + if okxP > 0 { exPrices[ExOKX] = okxP } + if bgP > 0 { exPrices[ExBitget] = bgP } + + var maxEx, minEx string + var maxVal, minVal float64 = -1, math.MaxFloat64 + for ex, p := range exPrices { + if p > maxVal { maxVal = p; maxEx = ex } + if p < minVal { minVal = p; minEx = ex } + } + + // Direction: if highest is further from median than lowest → up, else down + if (maxVal - midPrice) > (midPrice - minVal) { + direction = "up" + leadingEx = maxEx + } else { + direction = "down" + leadingEx = minEx + } + + ratio := spread / threshold + + event := SurgeEvent{ + Timestamp: now, + Coin: coin.Name, + BnPrice: bnP, + OkxPrice: okxP, + BgPrice: bgP, + SpreadPct: math.Round(spread*10000) / 10000, + BaselinePct: math.Round(baseline*10000) / 10000, + ThresholdPct: math.Round(threshold*10000) / 10000, + Ratio: math.Round(ratio*100) / 100, + Direction: direction, + LeadingExchange: leadingEx, + MidPrice: math.Round(midPrice*10000) / 10000, + } + + state.lastAlertAt = now + newEvents = append(newEvents, event) + + // Store in ring buffer + sd.events[sd.eventIdx%sd.maxEvents] = event + sd.eventIdx++ + + log.Printf("[Surge] %s %s surge detected: spread=%.4f%% baseline=%.4f%% threshold=%.4f%% ratio=%.1fx leading=%s", + coin.Name, direction, event.SpreadPct, event.BaselinePct, event.ThresholdPct, event.Ratio, leadingEx) + + // Persist to DB if callback set + if sd.onEvent != nil { + sd.onEvent(event) + } + } + + return newEvents +} + +// GetRecentEvents returns the most recent N surge events. +func (sd *SurgeDetector) GetRecentEvents(n int) []SurgeEvent { + sd.mu.Lock() + defer sd.mu.Unlock() + + if n <= 0 || n > sd.maxEvents { + n = sd.maxEvents + } + + total := sd.eventIdx + if total > sd.maxEvents { + total = sd.maxEvents + } + + result := make([]SurgeEvent, 0, total) + for i := 0; i < total; i++ { + idx := (sd.eventIdx - total + i) % sd.maxEvents + if idx < 0 { + idx += sd.maxEvents + } + ev := sd.events[idx] + if ev.Coin != "" { + result = append(result, ev) + } + } + + // Return at most n, most recent first + if len(result) <= n { + // Reverse to get newest first + for i, j := 0, len(result)-1; i < j; i, j = i+1, j-1 { + result[i], result[j] = result[j], result[i] + } + return result + } + + // Take last n and reverse + out := make([]SurgeEvent, n) + for i := 0; i < n; i++ { + out[i] = result[len(result)-1-i] + } + return out +} + +// Snapshot returns current spread/baseline state for all coins (SSE push). +func (sd *SurgeDetector) Snapshot() []SurgeSnapshot { + sd.mu.Lock() + defer sd.mu.Unlock() + + var result []SurgeSnapshot + now := time.Now() + + for _, coin := range TrackedCoins { + state, exists := sd.coins[coin.Name] + if !exists || len(state.spreads) < 10 { + continue + } + + currentSpread := state.spreads[len(state.spreads)-1] + baseline := median(state.spreads) + threshold := baseline * sd.baselineMul + if threshold < sd.minAbsSpreadPct { + threshold = sd.minAbsSpreadPct + } + + snap := SurgeSnapshot{ + Coin: coin.Name, + SpreadPct: math.Round(currentSpread*10000) / 10000, + BaselinePct: math.Round(baseline*10000) / 10000, + ThresholdPct: math.Round(threshold*10000) / 10000, + WindowSize: len(state.spreads), + } + + // Check if currently surging (within cooldown) + if currentSpread >= threshold && !state.lastAlertAt.IsZero() && now.Sub(state.lastAlertAt).Seconds() < float64(sd.cooldownSec) { + if state.spreads[len(state.spreads)-1] >= threshold { + snap.Direction = "up" // placeholder, real direction calculated in Tick + } + } + + result = append(result, snap) + } + + // Sort by spread descending + sort.Slice(result, func(i, j int) bool { + return result[i].SpreadPct > result[j].SpreadPct + }) + + // Limit to top 50 + if len(result) > 50 { + result = result[:50] + } + + return result +} + +// median computes the median of a slice of float64s. +// The input slice is NOT modified. +func median(vals []float64) float64 { + if len(vals) == 0 { + return 0 + } + sorted := make([]float64, len(vals)) + copy(sorted, vals) + sort.Float64s(sorted) + n := len(sorted) + if n%2 == 1 { + return sorted[n/2] + } + return (sorted[n/2-1] + sorted[n/2]) / 2 +} diff --git a/trader.go b/trader.go deleted file mode 100644 index 2e8ca4e..0000000 --- a/trader.go +++ /dev/null @@ -1,2118 +0,0 @@ -package main - -import ( - "fmt" - "log" - "strings" - "sync" - "time" - - "exchange-monitor/db" - "exchange-monitor/exchange" -) - -// PositionSide indicates the direction of a position. -type PositionSide string - -const ( - Long PositionSide = "long" - Short PositionSide = "short" -) - -// PositionLeg represents one leg of an arbitrage position. -type PositionLeg struct { - Coin string - Exchange string - Side PositionSide - Size string // contract size - EntryTime time.Time - EntryPrice float64 - OrderID string - Closed bool - ExitPrice float64 - ExitTime time.Time -} - -// ArbPosition represents a scaled-in arbitrage position. -type ArbPosition struct { - Coin string - Direction string // "BG->HL" or "HL->BG" - LongLeg *PositionLeg - ShortLeg *PositionLeg - AmountUSD float64 // total amount deployed - - EntrySpread float64 // spread % at entry (high price - low price) / low * 100 - - // Scaling levels - ScaleLevels int // how many times we've scaled in (0 = initial) - LastScaleAt time.Time // when we last scaled in - StartedAt time.Time - ExitedAt time.Time - Status string // "entering", "open", "closed" - RealizedPnl float64 - ErrorLog string - - // Exit metadata — saved when close is first attempted; reused by retryClose - ExitDiffPct float64 // spread % at exit trigger - ExitNetPnl float64 // net PnL % at exit trigger - ExitLongPnl float64 // long leg PnL % - ExitShortPnl float64 // short leg PnL % - ExitTotalFees float64 // total fee % - ExitConvergence string // convergence label - ExitReasonText string // reason for exit - ExitLongPnlUSD float64 // per-exchange PnL in USD (for retryClose) - ExitShortPnlUSD float64 - ExitLongFeeUSD float64 // per-exchange fee in USD - ExitShortFeeUSD float64 - CloseRetryCount int // how many times retryClose has been attempted - - // Track all entry prices for weighted-average PnL across scale-ins (Issue #2) - LongEntryPrices []float64 // all long entry prices (initial + scale-ins) - ShortEntryPrices []float64 // all short entry prices (initial + scale-ins) - - // DB trade ID — set after first save, used for incremental order/scale/exit persists - DBTradeID int64 -} - -// DeepCopy returns a copy-safe snapshot of the position (no shared pointers). -func (p *ArbPosition) DeepCopy() ArbPosition { - c := *p - if p.LongLeg != nil { - lc := *p.LongLeg - c.LongLeg = &lc - } - if p.ShortLeg != nil { - sc := *p.ShortLeg - c.ShortLeg = &sc - } - if p.LongEntryPrices != nil { - c.LongEntryPrices = make([]float64, len(p.LongEntryPrices)) - copy(c.LongEntryPrices, p.LongEntryPrices) - } - if p.ShortEntryPrices != nil { - c.ShortEntryPrices = make([]float64, len(p.ShortEntryPrices)) - copy(c.ShortEntryPrices, p.ShortEntryPrices) - } - return c -} - -// GetPositionsCopy returns deep copies of all open positions — safe for concurrent read. -func (t *Trader) GetPositionsCopy() []ArbPosition { - t.mu.Lock() - defer t.mu.Unlock() - r := make([]ArbPosition, 0, len(t.positions)) - for _, p := range t.positions { - r = append(r, p.DeepCopy()) - } - return r -} - -// RefreshSnapshot takes a trading-lock snapshot of open positions for display use. -// Call this after each Tick() from the main loop — never during a trading operation. -// The display reads from this snapshot without blocking trading. -func (t *Trader) RefreshSnapshot() { - copy := t.GetPositionsCopy() // acquires t.mu briefly (not held during Tick call) - t.snapMu.Lock() - t.positionsSnapshot = copy - t.snapMu.Unlock() -} - -// ReadSnapshot returns a copy of the last display snapshot — never locks t.mu. -// Safe to call from any goroutine without impacting trading latency. -func (t *Trader) ReadSnapshot() []ArbPosition { - t.snapMu.RLock() - defer t.snapMu.RUnlock() - r := make([]ArbPosition, len(t.positionsSnapshot)) - copy(r, t.positionsSnapshot) - return r -} - -// Trader handles scalable arbitrage between Bitget and HyperLiquid. -type Trader struct { - cfg *Config - bitget *exchange.BitgetTrade - hyperliquid *exchange.HyperLiquidTrade - - db *db.DB - mu sync.Mutex - 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 - dbConverged, dbDiverged, dbFlat, dbTotal int - - // Per-exchange fund tracking - exchangeFunds map[string]*ExchangeFund - - OnTradeEvent func(event string, data interface{}) // P3-4: real-time SSE push - - // Decoupled snapshot for display — snapMu never contended by trading path - snapMu sync.RWMutex - positionsSnapshot []ArbPosition - - // Auto-stop after N real trades - StopCh chan struct{} - realTradesTarget int - realTradesDone int - shuttingDown bool -} - -// TradeRecord stores a finalized trade for stats tracking. -type TradeRecord struct { - Coin string - Direction string - EntrySpread float64 - ExitSpread float64 - PnlPct float64 - PnlUSD float64 // absolute PnL in USD - Convergence string // "收敛", "发散", "持平" - Reason string // exit reason - Duration string - OpenedAt time.Time - ClosedAt time.Time - ScaleLevels int - AmountUSD float64 - PnlLongUSD float64 // per-exchange PnL in USD - PnlShortUSD float64 - FeeLongUSD float64 // per-exchange total fee in USD (entry+exit) - FeeShortUSD float64 -} - -// ExchangeFund tracks balance and PnL for one exchange. -type ExchangeFund struct { - Balance float64 // current available balance - TotalFee float64 // cumulative fees paid - TotalPnl float64 // cumulative realized PnL -} - -func NewTrader(cfg *Config, database *db.DB) *Trader { - var bt *exchange.BitgetTrade - if cfg.BitgetAPIKey != "" { - bt = exchange.NewBitgetTrade(cfg.BitgetAPIKey, cfg.BitgetAPISecret, cfg.BitgetPassphrase) - } - hl, _ := exchange.NewHyperLiquidTrade(cfg.HLPrivateKey, cfg.HLAddress, cfg.HLAPIAddress) - if hl != nil { - if err := hl.InitExchange(); err != nil { - log.Printf("[HL] InitExchange warning: %v", err) - } - } - - t := &Trader{ - cfg: cfg, - db: database, - bitget: bt, - hyperliquid: hl, - positions: make(map[string]*ArbPosition), - entering: make(map[string]bool), - lastTradeTime: make(map[string]time.Time), - blacklist: make(map[string]time.Time), - StopCh: make(chan struct{}, 1), - realTradesTarget: 5, - exchangeFunds: map[string]*ExchangeFund{ - ExBitget: {Balance: cfg.InitialCapital / 2}, - ExHyperLiquid: {Balance: cfg.InitialCapital / 2}, - }, - } - - // Restore open positions from DB on restart - if database != nil { - t.restoreOpenPositions() - - // Load historical closed trades for PnL stats (so total PnL survives restart) - if closed, err := database.GetAllClosedTrades(); err == nil { - for i := range closed { - dbTr := &closed[i] - pnlPct := safeFloat(dbTr.NetPnl) - pnlUSD := 2 * dbTr.AmountUSD * pnlPct / 100 - closedAt := time.Time{} - if dbTr.ClosedAt != nil { - closedAt = *dbTr.ClosedAt - } - record := TradeRecord{ - Coin: dbTr.Coin, - Direction: dbTr.Direction, - EntrySpread: safeFloat(dbTr.EntrySpread), - ExitSpread: safeFloat(dbTr.ExitSpread), - PnlPct: pnlPct, - PnlUSD: pnlUSD, - Convergence: safeStr(dbTr.Convergence), - Reason: safeStr(dbTr.ExitReason), - Duration: closedAt.Sub(dbTr.OpenedAt).Round(time.Second).String(), - OpenedAt: dbTr.OpenedAt, - ClosedAt: closedAt, - ScaleLevels: dbTr.ScaleCount, - AmountUSD: dbTr.AmountUSD, - } - t.closedTrades = append(t.closedTrades, record) - } - } - // Load historical closed trade stats for convergence display - if c, d, f, tot, err := database.GetClosedStats(); err == nil { - t.dbConverged, t.dbDiverged, t.dbFlat, t.dbTotal = c, d, f, tot - } - } - - // Fetch real balances from exchanges - t.fetchBalances() - - return t -} - -func (t *Trader) fetchBalances() { - // Bitget - if t.bitget != nil { - if bal, err := t.bitget.GetBalance(); err == nil { - t.exchangeFunds[ExBitget] = &ExchangeFund{Balance: bal} - log.Printf("[Funds] Bitget balance: $%.2f", bal) - } else { - log.Printf("[Funds] Bitget balance fetch failed: %v (using default)", err) - } - } - - // HyperLiquid - if t.hyperliquid != nil && t.hyperliquid.IsConfigured() { - if bal, err := t.hyperliquid.GetBalance(); err == nil { - t.exchangeFunds[ExHyperLiquid] = &ExchangeFund{Balance: bal} - log.Printf("[Funds] HyperLiquid balance: $%.2f", bal) - } else { - log.Printf("[Funds] HyperLiquid balance fetch failed: %v (using default)", err) - } - } -} - -func (t *Trader) IsConfigured() bool { - switch { - case t.cfg.TestMode: - return true - case t.cfg.TradeEnabled && t.bitget != nil && t.hyperliquid != nil && t.hyperliquid.IsConfigured(): - return true - } - return false -} - -func (t *Trader) ModeLabel() string { - if t.cfg.TestMode { - return "SIMULATION" - } - return "LIVE" -} - -// IsShuttingDown returns whether trading is stopped. -func (t *Trader) IsShuttingDown() bool { - t.mu.Lock() - defer t.mu.Unlock() - return t.shuttingDown -} - -// Stop sets shuttingDown flag and force-closes all open positions. -func (t *Trader) Stop() { - t.mu.Lock() - t.shuttingDown = true - t.mu.Unlock() - log.Println("[Trader] ⏹ Trading STOPPED — no new entries, closing positions...") - - // Force-close all open positions immediately - t.mu.Lock() - positions := make([]*ArbPosition, 0, len(t.positions)) - for _, pos := range t.positions { - positions = append(positions, pos) - } - t.mu.Unlock() - - for _, pos := range positions { - if pos.Status == "open" || pos.Status == "close_failed" { - t.closeBothLegs(pos) - pos.Status = "closed" - pos.ExitedAt = time.Now() - t.mu.Lock() - delete(t.positions, pos.Coin) - t.mu.Unlock() - log.Printf("[Trader] ⏹ Force-closed %s %s (manual stop)", pos.Coin, pos.Direction) - } - } - log.Println("[Trader] ✅ All positions closed, trading stopped. POST /api/start to resume.") -} - -// Start clears shuttingDown flag and resumes trading. -func (t *Trader) Start() { - t.mu.Lock() - t.shuttingDown = false - t.mu.Unlock() - log.Println("[Trader] ▶ Trading RESUMED") -} - -// ClosePosition closes a single position by coin name. -func (t *Trader) ClosePosition(coin string) error { - t.mu.Lock() - pos, ok := t.positions[coin] - t.mu.Unlock() - if !ok { - return fmt.Errorf("no open position for %s", coin) - } - if pos.Status != "open" && pos.Status != "close_failed" { - return fmt.Errorf("position %s is in status %s, cannot close", coin, pos.Status) - } - - elapsed := time.Since(pos.StartedAt) - closeErr := t.closeBothLegs(pos) - if closeErr != "" { - pos.Status = "close_failed" - pos.ExitedAt = time.Now() - pos.ErrorLog = closeErr - log.Printf("[Trader] ❌ Manual close %s failed: %s", coin, closeErr) - return fmt.Errorf("close failed: %s", closeErr) - } - - // Use actual exit fill prices from exchange (captured by closeLeg), fall back to entry prices - longExitPx := pos.LongLeg.ExitPrice - if longExitPx <= 0 { - longExitPx = weightedAvgPrice(pos.LongEntryPrices, t.cfg.TradeAmountUSD) - } - shortExitPx := pos.ShortLeg.ExitPrice - if shortExitPx <= 0 { - shortExitPx = weightedAvgPrice(pos.ShortEntryPrices, t.cfg.TradeAmountUSD) - } - - t.finalizeClosedPosition(pos, longExitPx, shortExitPx, 0, 0, 0, 0, 0, longExitPx, shortExitPx, "手动", "手动平仓", elapsed) - log.Printf("[Trader] Manually closed %s %s — persisted to DB", coin, pos.Direction) - return nil -} - -// CloseAllPositions closes every open position. -func (t *Trader) CloseAllPositions() int { - t.mu.Lock() - positions := make([]*ArbPosition, 0, len(t.positions)) - for _, pos := range t.positions { - positions = append(positions, pos) - } - t.mu.Unlock() - count := 0 - for _, pos := range positions { - if pos.Status == "open" || pos.Status == "close_failed" { - elapsed := time.Since(pos.StartedAt) - closeErr := t.closeBothLegs(pos) - if closeErr != "" { - pos.Status = "close_failed" - pos.ExitedAt = time.Now() - pos.ErrorLog = closeErr - log.Printf("[Trader] ❌ Force-close %s failed: %s", pos.Coin, closeErr) - continue - } - longExitPx := pos.LongLeg.ExitPrice - if longExitPx <= 0 { - longExitPx = weightedAvgPrice(pos.LongEntryPrices, t.cfg.TradeAmountUSD) - } - shortExitPx := pos.ShortLeg.ExitPrice - if shortExitPx <= 0 { - shortExitPx = weightedAvgPrice(pos.ShortEntryPrices, t.cfg.TradeAmountUSD) - } - t.finalizeClosedPosition(pos, longExitPx, shortExitPx, 0, 0, 0, 0, 0, longExitPx, shortExitPx, "手动", "全部平仓", elapsed) - log.Printf("[Trader] Force-closed %s %s — persisted to DB", pos.Coin, pos.Direction) - count++ - } - } - return count -} - -// Tick is called every scanner cycle — checks scaling and exit. -func (t *Trader) Tick(store *PriceStore, notifier *Notifier) { - if !t.IsConfigured() { - return - } - snap := store.GetAll() - - t.mu.Lock() - positions := make([]*ArbPosition, 0, len(t.positions)) - for _, pos := range t.positions { - positions = append(positions, pos) - } - - // Force-close remaining positions when shutting down - if t.shuttingDown && len(positions) > 0 { - t.mu.Unlock() - for _, pos := range positions { - if pos.Status == "open" || pos.Status == "close_failed" { - t.closeBothLegs(pos) - pos.Status = "closed" - pos.ExitedAt = time.Now() - delete(t.positions, pos.Coin) - log.Printf("[Trader] ⏹ Force-closed %s %s (shutdown)", pos.Coin, pos.Direction) - } - } - // All force-closed — signal stop - select { - case t.StopCh <- struct{}{}: - default: - } - return - } - t.mu.Unlock() - - for _, pos := range positions { - exMap := snap[pos.Coin] - if exMap == nil { - continue - } - bgP := exMap[ExBitget] - hlP := exMap[ExHyperLiquid] - if bgP <= 0 || hlP <= 0 { - continue - } - - // Calc current spread - var lowP, highP float64 - if pos.Direction == "BG->HL" { - lowP, highP = bgP, hlP - } else { - 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" { - t.retryClose(pos, bgP, hlP, notifier) - continue - } - - // Check scale-in: if spread widened enough, add more - t.checkScaleIn(pos, bgP, hlP, diffPct, store) - - // 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, bgP, hlP, diffPct, notifier) - } - } -} - -// TryEntry opens initial position when threshold is met. -// Returns true if entry was accepted (async goroutine will place orders). -// Non-blocking — the main loop is not stalled by REST calls or the 300ms leg delay. -func (t *Trader) TryEntry(opp *ArbOpportunity, store *PriceStore, notifier *Notifier) bool { - if !t.IsConfigured() { - return false - } - if (opp.BuyEx != ExBitget && opp.BuyEx != ExHyperLiquid) || - (opp.SellEx != ExBitget && opp.SellEx != ExHyperLiquid) { - return false - } - if opp.NetProfit < t.cfg.TradeThreshold { - return false - } - - // Data quality: reject if either price is zero or negative (stale/fake data) - if opp.BuyPrice <= 0 || opp.SellPrice <= 0 { - log.Printf("[Trader] %s: skip entry (price=%v/%v <= 0), likely stale/delisted coin", opp.Coin, opp.BuyPrice, opp.SellPrice) - return false - } - - t.mu.Lock() - if t.shuttingDown { - t.mu.Unlock() - return false - } - if _, exists := t.positions[opp.Coin]; exists { - t.mu.Unlock() - return false - } - if t.entering[opp.Coin] { - t.mu.Unlock() - return false - } - 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) - } - // Skip excluded coins - for _, c := range t.cfg.ExcludedCoins { - if c == opp.Coin { - t.mu.Unlock() - return false - } - } - if lastTime, ok := t.lastTradeTime[opp.Coin]; ok && time.Since(lastTime) < time.Duration(t.cfg.TradeCooldownMs)*time.Millisecond { - t.mu.Unlock() - return false - } - - // Margin check: verify both exchanges have sufficient funds - reqAmt := t.cfg.TradeAmountUSD * (1 + takerFees[opp.BuyEx]/100 + takerFees[opp.SellEx]/100) - if t.exchangeFunds[opp.BuyEx].Balance < reqAmt { - t.mu.Unlock() - return false - } - if t.exchangeFunds[opp.SellEx].Balance < reqAmt { - t.mu.Unlock() - return false - } - - t.entering[opp.Coin] = true - t.mu.Unlock() - - // Persist an "entering" record to DB BEFORE the goroutine, - // so even if the process is killed mid-entry, the position survives restart. - var pendingTradeID int64 - if t.db != nil { - now := time.Now() - entrySpread := opp.NetProfit - dbTrade := &db.TradeRecord{ - Coin: opp.Coin, - Direction: opp.Direction, - Status: "entering", - EntrySpread: &entrySpread, - LongExchange: opp.BuyEx, - ShortExchange: opp.SellEx, - AmountUSD: t.cfg.TradeAmountUSD, - OpenedAt: now, - } - if id, err := t.db.SaveTrade(dbTrade); err == nil { - pendingTradeID = id - } else { - log.Printf("[Trader] Failed to save entering trade for %s: %v", opp.Coin, err) - } - } - - // Async goroutine — placeOrder calls (REST or mock) don't block the main loop - go func() { - t.executeEntry(opp, store, notifier, pendingTradeID) - t.mu.Lock() - delete(t.entering, opp.Coin) - t.mu.Unlock() - }() - return true -} - -// executeEntry places both legs using the scan-time prices from ArbOpportunity. -// Synchronous — runs in the scanner tick to avoid WS price movement between -// detection and execution. -func (t *Trader) executeEntry(opp *ArbOpportunity, store *PriceStore, notifier *Notifier, pendingTradeID int64) bool { - // Use scan-time prices directly to avoid WS jitter killing the entry - bgP, hlP := opp.BuyPrice, opp.SellPrice - if opp.BuyEx == ExHyperLiquid { - bgP, hlP = opp.SellPrice, opp.BuyPrice - } - if bgP <= 0 || hlP <= 0 { - return false - } - - // Quick sanity check: spread direction hasn't completely reversed - // Use a relaxed check (not full re-read) since WS prices move constantly - snap := store.GetAll() - exMap := snap[opp.Coin] - if exMap != nil { - currBg := exMap[ExBitget] - currHl := exMap[ExHyperLiquid] - if currBg > 0 && currHl > 0 { - 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*reversalMul { - return false - } - } - } - - pos := &ArbPosition{ - Coin: opp.Coin, - AmountUSD: t.cfg.TradeAmountUSD, - StartedAt: time.Now(), - Status: "entering", // prevent checkExit/checkScaleIn during leg placement - ScaleLevels: 0, - } - - entrySpread := (hlP - bgP) / bgP * 100 - if opp.BuyEx == ExBitget { - pos.Direction = "BG->HL" - pos.EntrySpread = entrySpread // positive when hlP > bgP - pos.LongLeg = &PositionLeg{ - Coin: opp.Coin, Exchange: ExBitget, Side: Long, - EntryPrice: bgP, EntryTime: time.Now(), - } - pos.ShortLeg = &PositionLeg{ - Coin: opp.Coin, Exchange: ExHyperLiquid, Side: Short, - EntryPrice: hlP, EntryTime: time.Now(), - } - pos.LongEntryPrices = []float64{bgP} - pos.ShortEntryPrices = []float64{hlP} - } else { - pos.Direction = "HL->BG" - pos.EntrySpread = (bgP - hlP) / hlP * 100 // positive when bgP > hlP - pos.LongLeg = &PositionLeg{ - Coin: opp.Coin, Exchange: ExHyperLiquid, Side: Long, - EntryPrice: hlP, EntryTime: time.Now(), - } - pos.ShortLeg = &PositionLeg{ - Coin: opp.Coin, Exchange: ExBitget, Side: Short, - EntryPrice: bgP, EntryTime: time.Now(), - } - pos.LongEntryPrices = []float64{hlP} - pos.ShortEntryPrices = []float64{bgP} - } - - t.mu.Lock() - t.positions[opp.Coin] = pos - t.mu.Unlock() - - // Execute both legs - var longFeeUSD, shortFeeUSD float64 - var errMsg string - errMsg, longFeeUSD = t.placeOrder(pos.LongLeg, "buy", store) - if errMsg != "" { - log.Printf("[Trader] %s: long leg placeOrder failed: %s", opp.Coin, errMsg) - t.cleanup(pos.Coin) - return false - } - time.Sleep(t.cfg.LegDelay) - errMsg, shortFeeUSD = t.placeOrder(pos.ShortLeg, "sell", store) - if errMsg != "" { - log.Printf("[Trader] %s: short leg placeOrder failed: %s", opp.Coin, errMsg) - // Leg1 placed successfully, leg2 failed — try to close leg1 - pos.Status = "failed" - if closeErr := t.closeLeg(pos.LongLeg); closeErr != "" { - // CRITICAL: leg1 is still open on the exchange! - // Record the orphan so we don't silently lose tracking - pos.ErrorLog = fmt.Sprintf("ORPHAN: leg1 %s %s placed OK, leg2 %s %s failed (%s), leg1 close also failed (%s)", - pos.LongLeg.Exchange, pos.LongLeg.Side, - pos.ShortLeg.Exchange, pos.ShortLeg.Side, - errMsg, closeErr) - log.Printf("[Trader] ⚠️ ORPHAN POSITION on %s: %s", pos.Coin, pos.ErrorLog) - } - t.cleanup(pos.Coin) - return false - } - - pos.LastScaleAt = time.Now() - pos.Status = "open" // both legs placed, ready for Tick/exit logic - - // Persist entry to DB immediately (incremental — not batch at close) - if t.db != nil { - if pendingTradeID > 0 { - // Trade was already saved with "entering" status — update to "open" - if err := t.db.SetTradeStatus(pendingTradeID, "open"); err == nil { - pos.DBTradeID = pendingTradeID - log.Printf("[Trader] %s: DB status entering->open (id=%d)", pos.Coin, pendingTradeID) - } - // Save entry prices, orders, and fees (not saved in TryEntry's pending record) - now := time.Now() - status := "filled" - tradeUnit := t.cfg.TradeAmountUSD - es := pos.EntrySpread - t.db.UpdateTradeEntry(pendingTradeID, &db.TradeRecord{ - LongEntry: &pos.LongLeg.EntryPrice, - ShortEntry: &pos.ShortLeg.EntryPrice, - LongExchange: pos.LongLeg.Exchange, - ShortExchange: pos.ShortLeg.Exchange, - EntrySpread: &es, - }) - if longFeeUSD <= 0 { - longFeeUSD = tradeUnit * takerFees[pos.LongLeg.Exchange] / 100 - } - if shortFeeUSD <= 0 { - shortFeeUSD = tradeUnit * takerFees[pos.ShortLeg.Exchange] / 100 - } - longShares := tradeUnit / pos.LongLeg.EntryPrice - shortShares := tradeUnit / pos.ShortLeg.EntryPrice - longOID, _ := t.db.SaveOrder(&db.OrderRecord{ - TradeID: pendingTradeID, Leg: "long", Type: "entry", - Exchange: pos.LongLeg.Exchange, Side: "buy", - Price: &pos.LongLeg.EntryPrice, Size: &longShares, - Fee: &longFeeUSD, Status: &status, CreatedAt: now, - OrderID: &pos.LongLeg.OrderID, - }) - shortOID, _ := t.db.SaveOrder(&db.OrderRecord{ - TradeID: pendingTradeID, Leg: "short", Type: "entry", - Exchange: pos.ShortLeg.Exchange, Side: "sell", - Price: &pos.ShortLeg.EntryPrice, Size: &shortShares, - Fee: &shortFeeUSD, Status: &status, CreatedAt: now, - OrderID: &pos.ShortLeg.OrderID, - }) - t.db.SaveSystemOrder(&db.SystemOrderRecord{ - TradeID: pendingTradeID, Type: "entry", Status: "filled", - Spread: &es, - LongPrice: &pos.LongLeg.EntryPrice, ShortPrice: &pos.ShortLeg.EntryPrice, - LongOrderID: &longOID, ShortOrderID: &shortOID, - CreatedAt: now, - }) - } else { - // Fallback: no pending record, insert fresh - now := time.Now() - status := "filled" - tradeUnit := t.cfg.TradeAmountUSD - es := pos.EntrySpread - - dbTrade := &db.TradeRecord{ - Coin: pos.Coin, - Direction: pos.Direction, - Status: "open", - EntrySpread: &es, - LongExchange: pos.LongLeg.Exchange, - ShortExchange: pos.ShortLeg.Exchange, - LongEntry: &pos.LongLeg.EntryPrice, - ShortEntry: &pos.ShortLeg.EntryPrice, - AmountUSD: t.cfg.TradeAmountUSD, - OpenedAt: now, - } - if tradeID, err := t.db.SaveTrade(dbTrade); err == nil { - pos.DBTradeID = tradeID - - // Use actual fee from exchange (fetched in placeOrder), fall back to estimate - if longFeeUSD <= 0 { - longFeeUSD = tradeUnit * takerFees[pos.LongLeg.Exchange] / 100 - } - if shortFeeUSD <= 0 { - shortFeeUSD = tradeUnit * takerFees[pos.ShortLeg.Exchange] / 100 - } - longShares := tradeUnit / pos.LongLeg.EntryPrice - shortShares := tradeUnit / pos.ShortLeg.EntryPrice - - longOID, _ := t.db.SaveOrder(&db.OrderRecord{ - TradeID: tradeID, Leg: "long", Type: "entry", - Exchange: pos.LongLeg.Exchange, Side: "buy", - Price: &pos.LongLeg.EntryPrice, Size: &longShares, - Fee: &longFeeUSD, Status: &status, CreatedAt: now, - OrderID: &pos.LongLeg.OrderID, - }) - shortOID, _ := t.db.SaveOrder(&db.OrderRecord{ - TradeID: tradeID, Leg: "short", Type: "entry", - Exchange: pos.ShortLeg.Exchange, Side: "sell", - Price: &pos.ShortLeg.EntryPrice, Size: &shortShares, - Fee: &shortFeeUSD, Status: &status, CreatedAt: now, - OrderID: &pos.ShortLeg.OrderID, - }) - t.db.SaveSystemOrder(&db.SystemOrderRecord{ - TradeID: tradeID, Type: "entry", Status: "filled", - Spread: &es, - LongPrice: &pos.LongLeg.EntryPrice, ShortPrice: &pos.ShortLeg.EntryPrice, - LongOrderID: &longOID, ShortOrderID: &shortOID, - CreatedAt: now, - }) - } - } - } - - log.Printf("[Trader] %s: Opened %s | Long %s @ %.2f Short %s @ %.2f | $%.0f", - pos.Coin, pos.Direction, pos.LongLeg.Exchange, pos.LongLeg.EntryPrice, - pos.ShortLeg.Exchange, pos.ShortLeg.EntryPrice, t.cfg.TradeAmountUSD) - - diff := (pos.ShortLeg.EntryPrice - pos.LongLeg.EntryPrice) / pos.LongLeg.EntryPrice * 100 - notifier.Send(fmt.Sprintf( - "[开仓] %s/USDT %s\n"+ - " 多 %s @ %.2f\n"+ - " 空 %s @ %.2f\n"+ - " 价差: %+.4f%%\n"+ - " 规模: $%.0f\n", - pos.Coin, pos.Direction, - pos.LongLeg.Exchange, pos.LongLeg.EntryPrice, - pos.ShortLeg.Exchange, pos.ShortLeg.EntryPrice, - diff, t.cfg.TradeAmountUSD)) - - // P3-4: real-time trade event push - if t.OnTradeEvent != nil { - t.OnTradeEvent("trade_open", map[string]interface{}{ - "coin": pos.Coin, - "direction": pos.Direction, - "entry_spread": diff, - "amount_usd": t.cfg.TradeAmountUSD, - "time": time.Now().Format("15:04:05"), - }) - } - return true -} - -// checkScaleIn adds more position when spread widens further. -// Issues actual orders on both legs to increase notional exposure (Issue #2). -func (t *Trader) checkScaleIn(pos *ArbPosition, bgP, hlP, diffPct float64, store *PriceStore) { - if pos.Status != "open" { - return - } - if pos.LongLeg == nil || pos.ShortLeg == nil { - return - } - - // Scale-in threshold: every +0.10% beyond entry - var entryDiff float64 - if pos.Direction == "BG->HL" { - entryDiff = (pos.ShortLeg.EntryPrice - pos.LongLeg.EntryPrice) / pos.LongLeg.EntryPrice * 100 - } else { - entryDiff = (pos.ShortLeg.EntryPrice - pos.LongLeg.EntryPrice) / pos.LongLeg.EntryPrice * 100 - if entryDiff < 0 { - entryDiff = -entryDiff - } - } - - scaleStep := t.cfg.ScaleStepPct // add every X% wider - nextLevel := float64(pos.ScaleLevels+1) * scaleStep - if diffPct < entryDiff+nextLevel { - return - } - - // Cooldown: use configured interval between scales - if time.Since(pos.LastScaleAt) < t.cfg.ScaleCooldown { - return - } - - // Place additional orders on both legs to increase position size - // Use the current (wider) prices for the new orders - longPrice := bgP - shortPrice := hlP - if pos.LongLeg.Exchange == ExHyperLiquid { - longPrice, shortPrice = hlP, bgP - } - - longErr, longFeeActual := t.placeOrderAt(pos.LongLeg, "buy", store, longPrice) - if longErr != "" { - log.Printf("[Trader] %s: Scale-in long failed: %s", pos.Coin, longErr) - return - } - time.Sleep(t.cfg.LegDelay) - shortErr, shortFeeActual := t.placeOrderAt(pos.ShortLeg, "sell", store, shortPrice) - if shortErr != "" { - log.Printf("[Trader] %s: Scale-in short failed: %s — position partially scaled (long only)", pos.Coin, shortErr) - // Don't close the long leg — the scale-in long order was placed but the - // short wasn't. The position has extra long exposure until the next Tick - // decides what to do. This is a partial fill scenario. - return - } - - pos.ScaleLevels++ - pos.LastScaleAt = time.Now() - pos.AmountUSD += t.cfg.TradeAmountUSD - pos.LongEntryPrices = append(pos.LongEntryPrices, longPrice) - pos.ShortEntryPrices = append(pos.ShortEntryPrices, shortPrice) - - // Update leg EntryPrice to reflect weighted average across all scale levels - pos.LongLeg.EntryPrice = weightedAvgPrice(pos.LongEntryPrices, t.cfg.TradeAmountUSD) - pos.ShortLeg.EntryPrice = weightedAvgPrice(pos.ShortEntryPrices, t.cfg.TradeAmountUSD) - - // Persist scale orders to DB immediately - if t.db != nil && pos.DBTradeID > 0 { - now := time.Now() - status := "filled" - tradeUnit := t.cfg.TradeAmountUSD - es := pos.EntrySpread - - longFee := longFeeActual - if longFee <= 0 { - longFee = tradeUnit * takerFees[pos.LongLeg.Exchange] / 100 - } - shortFee := shortFeeActual - if shortFee <= 0 { - shortFee = tradeUnit * takerFees[pos.ShortLeg.Exchange] / 100 - } - longShares := tradeUnit / longPrice - shortShares := tradeUnit / shortPrice - - longOID, _ := t.db.SaveOrder(&db.OrderRecord{ - TradeID: pos.DBTradeID, Leg: "long", Type: "scale", - Exchange: pos.LongLeg.Exchange, Side: "buy", - Price: &longPrice, Size: &longShares, - Fee: &longFee, Status: &status, CreatedAt: now, - OrderID: &pos.LongLeg.OrderID, - }) - shortOID, _ := t.db.SaveOrder(&db.OrderRecord{ - TradeID: pos.DBTradeID, Leg: "short", Type: "scale", - Exchange: pos.ShortLeg.Exchange, Side: "sell", - Price: &shortPrice, Size: &shortShares, - Fee: &shortFee, Status: &status, CreatedAt: now, - OrderID: &pos.ShortLeg.OrderID, - }) - t.db.SaveSystemOrder(&db.SystemOrderRecord{ - TradeID: pos.DBTradeID, Type: "scale", Status: "filled", - Spread: &es, - LongPrice: &longPrice, ShortPrice: &shortPrice, - LongOrderID: &longOID, ShortOrderID: &shortOID, - CreatedAt: now, - }) - // Persist updated amount_usd and scale_count immediately - t.db.UpdateTradeScale(pos.DBTradeID, pos.AmountUSD, pos.ScaleLevels) - } - - log.Printf("[Trader] %s: Scale-in #%d executed | spread=%.4f%% (entry=%.4f%%) | total=$%.0f", - pos.Coin, pos.ScaleLevels, diffPct, entryDiff, pos.AmountUSD) -} - -// 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 - } - if pos.LongLeg == nil || pos.ShortLeg == nil { - return - } - - // Current prices for P&L calculation - var longCurrent, shortCurrent float64 - if pos.LongLeg.Exchange == ExBitget { - longCurrent, shortCurrent = bgP, hlP - } else { - longCurrent, shortCurrent = hlP, bgP - } - - // Weighted average entry prices across all scale levels - longAvg := weightedAvgPrice(pos.LongEntryPrices, t.cfg.TradeAmountUSD) - shortAvg := weightedAvgPrice(pos.ShortEntryPrices, t.cfg.TradeAmountUSD) - - longPnl := (longCurrent - longAvg) / longAvg * 100 - shortPnl := (shortAvg - shortCurrent) / shortAvg * 100 - netPnl, totalFees := calcArbPnL(longPnl, shortPnl, pos.ScaleLevels, t.cfg.TradeAmountUSD) // 净利为总资本的百分比 - - elapsed := time.Since(pos.StartedAt) - - shouldExit := false - exitReason := "" - - // Take profit: net profit >= configured threshold - if netPnl >= t.cfg.TakeProfitPct { - shouldExit = true - exitReason = "利润止盈" - } - - // Convergence exit: spread narrowed to ≤ 0.02% (includes reversal) - if diffPct <= 0.02 { - 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 := "价差收敛" - if convergedPct < -10 { - convergenceLabel = "价差发散" - } else if convergedPct < 10 { - convergenceLabel = "价差持平" - } - - log.Printf("[Trader] %s: %s | entry=%.4f%% exit=%.4f%% conv=%.1f%% %s | long=%.4f%% short=%.4f%% net=%.4f%% | scales=%d held=%s", - pos.Coin, exitReason, pos.EntrySpread, diffPct, convergedPct, convergenceLabel, - longPnl, shortPnl, netPnl, pos.ScaleLevels, elapsed.Round(time.Second).String()) - - pos.LongLeg.ExitPrice = longCurrent - pos.ShortLeg.ExitPrice = shortCurrent - - // Save exit metadata for retryClose in case closeBothLegs fails - pos.ExitDiffPct = diffPct - pos.ExitNetPnl = netPnl - pos.ExitLongPnl = longPnl - pos.ExitShortPnl = shortPnl - pos.ExitTotalFees = totalFees - pos.ExitConvergence = convergenceLabel - pos.ExitReasonText = exitReason - // Pre-compute per-exchange PnL/fees for retryClose - numBatchesRetry := 1 + pos.ScaleLevels - pos.ExitLongPnlUSD = longPnl / 100 * float64(numBatchesRetry) * t.cfg.TradeAmountUSD - pos.ExitShortPnlUSD = shortPnl / 100 * float64(numBatchesRetry) * t.cfg.TradeAmountUSD - { - totalLongSharesRetry := 0.0 - for _, p := range pos.LongEntryPrices { - totalLongSharesRetry += t.cfg.TradeAmountUSD / p - } - totalshortSharesRetry := 0.0 - for _, p := range pos.ShortEntryPrices { - totalshortSharesRetry += t.cfg.TradeAmountUSD / p - } - pos.ExitLongFeeUSD = float64(numBatchesRetry)*t.cfg.TradeAmountUSD*takerFees[pos.LongLeg.Exchange]/100 + - totalLongSharesRetry*longCurrent*takerFees[pos.LongLeg.Exchange]/100 - pos.ExitShortFeeUSD = float64(numBatchesRetry)*t.cfg.TradeAmountUSD*takerFees[pos.ShortLeg.Exchange]/100 + - totalshortSharesRetry*shortCurrent*takerFees[pos.ShortLeg.Exchange]/100 - } - - closeErr := t.closeBothLegs(pos) - - if closeErr != "" { - // Close failed — keep the position for retry on next Tick - pos.Status = "close_failed" - pos.ErrorLog = closeErr - pos.ExitedAt = time.Now() - log.Printf("[Trader] ❌ %s: Close failed: %s — will retry on next tick", pos.Coin, closeErr) - notifier.Send(fmt.Sprintf( - "[平仓失败] %s/USDT %s\n"+ - " 状态: close_failed\n"+ - " 错误: %s\n"+ - " 下一轮将重试关掉剩余的腿\n", pos.Coin, pos.Direction, closeErr)) - return - } - - pos.RealizedPnl = netPnl - pos.ExitedAt = time.Now() - pos.Status = "closed" - - // Compute per-leg PnL and fees in USD - numBatches := 1 + pos.ScaleLevels - legCapital := t.cfg.TradeAmountUSD - longPnlUSD := longPnl / 100 * float64(numBatches) * legCapital - shortPnlUSD := shortPnl / 100 * float64(numBatches) * legCapital - - totalLongShares := 0.0 - for _, p := range pos.LongEntryPrices { - totalLongShares += legCapital / p - } - totalshortShares := 0.0 - for _, p := range pos.ShortEntryPrices { - totalshortShares += legCapital / p - } - - longEntryFeeSum := float64(numBatches) * legCapital * takerFees[pos.LongLeg.Exchange] / 100 - shortEntryFeeSum := float64(numBatches) * legCapital * takerFees[pos.ShortLeg.Exchange] / 100 - longExitFeeAmt := totalLongShares * pos.LongLeg.ExitPrice * takerFees[pos.LongLeg.Exchange] / 100 - shortExitFeeAmt := totalshortShares * pos.ShortLeg.ExitPrice * takerFees[pos.ShortLeg.Exchange] / 100 - longFeeUSD := longEntryFeeSum + longExitFeeAmt - shortFeeUSD := shortEntryFeeSum + shortExitFeeAmt - - // Update per-exchange fund tracking - t.mu.Lock() - if ef, ok := t.exchangeFunds[pos.LongLeg.Exchange]; ok { - ef.Balance -= longFeeUSD - ef.Balance += longPnlUSD - ef.TotalFee += longFeeUSD - ef.TotalPnl += longPnlUSD - } - if ef, ok := t.exchangeFunds[pos.ShortLeg.Exchange]; ok { - ef.Balance -= shortFeeUSD - ef.Balance += shortPnlUSD - ef.TotalFee += shortFeeUSD - ef.TotalPnl += shortPnlUSD - } - t.mu.Unlock() - - // Save trade record for stats - record := TradeRecord{ - Coin: pos.Coin, - Direction: pos.Direction, - EntrySpread: pos.EntrySpread, - ExitSpread: diffPct, - PnlPct: netPnl, - PnlUSD: 2 * pos.AmountUSD * netPnl / 100, - Convergence: convergenceLabel, - Reason: exitReason, - Duration: elapsed.Round(time.Second).String(), - OpenedAt: pos.StartedAt, - ClosedAt: pos.ExitedAt, - ScaleLevels: pos.ScaleLevels, - AmountUSD: pos.AmountUSD, - PnlLongUSD: longPnlUSD, - PnlShortUSD: shortPnlUSD, - FeeLongUSD: longFeeUSD, - FeeShortUSD: shortFeeUSD, - } - - t.mu.Lock() - delete(t.positions, pos.Coin) - t.lastTradeTime[pos.Coin] = time.Now() - t.closedTrades = append(t.closedTrades, record) - t.realTradesDone++ - t.mu.Unlock() - - // Auto-stop: after 5 real trades, signal shutdown - if t.realTradesTarget > 0 && t.realTradesDone >= t.realTradesTarget { - log.Printf("[Trader] ✅ %d real trades completed — shutting down...", t.realTradesDone) - t.shuttingDown = true - select { - case t.StopCh <- struct{}{}: - default: - } - } - - // Persist exit orders + close trade in DB - if t.db != nil && pos.DBTradeID > 0 { - now := time.Now() - status := "filled" - - // Save exit orders - longExitShares := totalLongShares - longOID, _ := t.db.SaveOrder(&db.OrderRecord{ - TradeID: pos.DBTradeID, Leg: "long", Type: "exit", - Exchange: pos.LongLeg.Exchange, Side: "sell", - Price: &pos.LongLeg.ExitPrice, Size: &longExitShares, - Fee: &longExitFeeAmt, Status: &status, CreatedAt: now, - OrderID: &pos.LongLeg.OrderID, - }) - shortExitShares := totalshortShares - shortOID, _ := t.db.SaveOrder(&db.OrderRecord{ - TradeID: pos.DBTradeID, Leg: "short", Type: "exit", - Exchange: pos.ShortLeg.Exchange, Side: "buy", - Price: &pos.ShortLeg.ExitPrice, Size: &shortExitShares, - Fee: &shortExitFeeAmt, Status: &status, CreatedAt: now, - OrderID: &pos.ShortLeg.OrderID, - }) - // Save exit system order - t.db.SaveSystemOrder(&db.SystemOrderRecord{ - TradeID: pos.DBTradeID, Type: "exit", Status: "filled", - Spread: &diffPct, - LongPrice: &pos.LongLeg.ExitPrice, ShortPrice: &pos.ShortLeg.ExitPrice, - LongOrderID: &longOID, ShortOrderID: &shortOID, - CreatedAt: now, - }) - - // Close trade with per-exchange fee/pnl - feeEntrySum := longEntryFeeSum + shortEntryFeeSum - feeExitSum := longExitFeeAmt + shortExitFeeAmt - - t.db.UpdateTradeStatus(pos.DBTradeID, &db.TradeRecord{ - Status: "closed", - ExitSpread: &diffPct, - LongExit: &pos.LongLeg.ExitPrice, - ShortExit: &pos.ShortLeg.ExitPrice, - LongPnl: &longPnl, - ShortPnl: &shortPnl, - FeeEntry: &feeEntrySum, - FeeExit: &feeExitSum, - NetPnl: &netPnl, - AmountUSD: pos.AmountUSD, - ScaleCount: pos.ScaleLevels, - ExitReason: &exitReason, - Convergence: &convergenceLabel, - ClosedAt: &now, - PnlLongUSD: &longPnlUSD, - PnlShortUSD: &shortPnlUSD, - FeeLongUSD: &longFeeUSD, - FeeShortUSD: &shortFeeUSD, - }) - } - - msg := fmt.Sprintf( - "[平仓] %s/USDT %s\n"+ - " 持仓: %s 加仓: %d次\n"+ - " 总规模: $%.0f\n"+ - " 价差: %.4f%% → %.4f%% (%s)\n"+ - " 多: %+.4f%% (%s %.2f → %.2f)\n"+ - " 空: %+.4f%% (%s %.2f → %.2f)\n"+ - " 手续费: %.4f%%\n"+ - " 净收益: %+.4f%%\n"+ - " 原因: %s\n", - pos.Coin, pos.Direction, - elapsed.Round(time.Second).String(), pos.ScaleLevels, - pos.AmountUSD, - pos.EntrySpread, diffPct, convergenceLabel, - longPnl, pos.LongLeg.Exchange, pos.LongLeg.EntryPrice, longCurrent, - shortPnl, pos.ShortLeg.Exchange, pos.ShortLeg.EntryPrice, shortCurrent, - totalFees, netPnl, exitReason, - ) - notifier.Send(msg) - - // P3-4: real-time trade event push - if t.OnTradeEvent != nil { - t.OnTradeEvent("trade_close", map[string]interface{}{ - "coin": pos.Coin, - "direction": pos.Direction, - "entry_spread": pos.EntrySpread, - "exit_spread": diffPct, - "pnl_pct": netPnl, - "convergence": convergenceLabel, - "duration": elapsed.Round(time.Second).String(), - "time": time.Now().Format("15:04:05"), - }) - } -} - -func (t *Trader) placeOrder(leg *PositionLeg, side string, store *PriceStore) (string, float64) { - if t.cfg.TestMode { - return t.mockFill(leg, side, store), 0 - } - if leg.Exchange == ExBitget { - szStr := exchange.GetBitgetSize(leg.Coin+"USDT", t.cfg.TradeAmountUSD, leg.EntryPrice) - log.Printf("[Order] BG %s %s | amountUSD=%.2f entryPrice=%.6f size=%s", side, leg.Coin+"USDT", t.cfg.TradeAmountUSD, leg.EntryPrice, szStr) - oid, err := t.bitget.PlaceMarketOrder(side, leg.Coin+"USDT", szStr, "open", "") - if err != nil { - return fmt.Sprintf("BG %s error: %v", side, err), 0 - } - leg.Size = szStr - leg.OrderID = oid - log.Printf("[ExRes] BG %s %s: size=%s oid=%s", side, leg.Coin+"USDT", szStr, oid) - - // Fetch actual fee and fill price from exchange - fillPrice, fee, fetchErr := t.bitget.GetTradeFee(leg.Coin+"USDT", oid) - if fetchErr != nil { - log.Printf("[Fee] BG GetTradeFee warning: %v", fetchErr) - fee = 0 - } else { - log.Printf("[Fee] BG %s %s: actual fee=$%.6f fillPrice=%.6f (filled)", side, leg.Coin+"USDT", fee, fillPrice) - if fillPrice > 0 { - leg.EntryPrice = fillPrice - } - } - - // Verify position actually exists on BG (IOC can succeed without fills) - time.Sleep(500 * time.Millisecond) - if posSize, _ := t.bitget.CheckPosition(leg.Coin + "USDT"); posSize <= 0 { - log.Printf("[Trader] BG %s %s: IOC order accepted but no position created (zero fill)", side, leg.Coin+"USDT") - return fmt.Sprintf("BG %s zero fill (no position)", side), 0 - } - return "", fee - } else { - szStr := t.hyperliquid.GetSize(leg.Coin, t.cfg.TradeAmountUSD, leg.EntryPrice) - log.Printf("[Order] HL %s %s | amountUSD=%.2f entryPrice=%.6f size=%s", side, leg.Coin, t.cfg.TradeAmountUSD, leg.EntryPrice, szStr) - resp, err := t.hyperliquid.PlaceMarketOrder(leg.Coin, side, szStr) - if err != nil { - return fmt.Sprintf("HL %s error: %v", side, err), 0 - } - leg.Size = szStr - leg.OrderID = resp - log.Printf("[ExRes] HL %s %s: size=%s", side, leg.Coin, szStr) - - // Parse actual fill price from HL response - fillPrice, _, parseErr := t.hyperliquid.ParseFillFromResponse(resp) - if parseErr == nil && fillPrice > 0 { - leg.EntryPrice = fillPrice - log.Printf("[Fill] HL %s %s: actual fillPrice=%.6f", side, leg.Coin, fillPrice) - } - fee, fetchErr := t.hyperliquid.EstimateFeeFromResponse(resp, takerFees[ExHyperLiquid]) - if fetchErr != nil { - log.Printf("[Fee] HL EstimateFeeFromResponse warning: %v", fetchErr) - } else { - log.Printf("[Fee] HL %s %s: actual fee=$%.6f", side, leg.Coin, fee) - } - return "", fee - } -} - -// finalizeClosedPosition persists a closed position: computes PnL (if not given), -// records to closedTrades, updates exchangeFunds, persists exit orders + trade to DB. -// longPct/shortPct/totalFeesPct are % values; pass the price snapshot used at close trigger. -// longPrice/shortPrice are the exit prices for each leg. Use entry prices if unknown. -func (t *Trader) finalizeClosedPosition(pos *ArbPosition, longPrice, shortPrice, diffPct, netPnlPct, longPnlPct, shortPnlPct, totalFeesPct, longCurrent, shortCurrent float64, convergence, exitReason string, elapsed time.Duration) { - pos.ExitedAt = time.Now() - pos.Status = "closed" - pos.RealizedPnl = netPnlPct - - // Per-leg PnL and fees in USD - numBatches := 1 + pos.ScaleLevels - legCapital := t.cfg.TradeAmountUSD - longPnlUSD := longPnlPct / 100 * float64(numBatches) * legCapital - shortPnlUSD := shortPnlPct / 100 * float64(numBatches) * legCapital - - totalLongShares := 0.0 - for _, p := range pos.LongEntryPrices { - totalLongShares += legCapital / p - } - totalShortShares := 0.0 - for _, p := range pos.ShortEntryPrices { - totalShortShares += legCapital / p - } - - longEntryFeeSum := float64(numBatches) * legCapital * takerFees[pos.LongLeg.Exchange] / 100 - shortEntryFeeSum := float64(numBatches) * legCapital * takerFees[pos.ShortLeg.Exchange] / 100 - longExitFeeAmt := totalLongShares * longPrice * takerFees[pos.LongLeg.Exchange] / 100 - shortExitFeeAmt := totalShortShares * shortPrice * takerFees[pos.ShortLeg.Exchange] / 100 - longFeeUSD := longEntryFeeSum + longExitFeeAmt - shortFeeUSD := shortEntryFeeSum + shortExitFeeAmt - - pos.LongLeg.ExitPrice = longPrice - pos.ShortLeg.ExitPrice = shortPrice - - // Update exchange funds - t.mu.Lock() - if ef, ok := t.exchangeFunds[pos.LongLeg.Exchange]; ok { - ef.Balance -= longFeeUSD - ef.Balance += longPnlUSD - ef.TotalFee += longFeeUSD - ef.TotalPnl += longPnlUSD - } - if ef, ok := t.exchangeFunds[pos.ShortLeg.Exchange]; ok { - ef.Balance -= shortFeeUSD - ef.Balance += shortPnlUSD - ef.TotalFee += shortFeeUSD - ef.TotalPnl += shortPnlUSD - } - t.mu.Unlock() - - // Build trade record - record := TradeRecord{ - Coin: pos.Coin, - Direction: pos.Direction, - EntrySpread: pos.EntrySpread, - ExitSpread: diffPct, - PnlPct: netPnlPct, - PnlUSD: 2 * pos.AmountUSD * netPnlPct / 100, - Convergence: convergence, - Reason: exitReason, - Duration: elapsed.Round(time.Second).String(), - OpenedAt: pos.StartedAt, - ClosedAt: pos.ExitedAt, - ScaleLevels: pos.ScaleLevels, - AmountUSD: pos.AmountUSD, - PnlLongUSD: longPnlUSD, - PnlShortUSD: shortPnlUSD, - FeeLongUSD: longFeeUSD, - FeeShortUSD: shortFeeUSD, - } - - t.mu.Lock() - delete(t.positions, pos.Coin) - t.lastTradeTime[pos.Coin] = time.Now() - t.closedTrades = append(t.closedTrades, record) - t.realTradesDone++ - t.mu.Unlock() - - // Auto-stop after target real trades - if t.realTradesTarget > 0 && t.realTradesDone >= t.realTradesTarget { - log.Printf("[Trader] ✅ %d real trades completed — shutting down...", t.realTradesDone) - t.shuttingDown = true - select { - case t.StopCh <- struct{}{}: - default: - } - } - - // Persist to DB - if t.db != nil && pos.DBTradeID > 0 { - now := time.Now() - status := "filled" - - longOID, _ := t.db.SaveOrder(&db.OrderRecord{ - TradeID: pos.DBTradeID, Leg: "long", Type: "exit", - Exchange: pos.LongLeg.Exchange, Side: "sell", - Price: &longPrice, Size: &totalLongShares, - Fee: &longExitFeeAmt, Status: &status, CreatedAt: now, - OrderID: &pos.LongLeg.OrderID, - }) - shortOID, _ := t.db.SaveOrder(&db.OrderRecord{ - TradeID: pos.DBTradeID, Leg: "short", Type: "exit", - Exchange: pos.ShortLeg.Exchange, Side: "buy", - Price: &shortPrice, Size: &totalShortShares, - Fee: &shortExitFeeAmt, Status: &status, CreatedAt: now, - OrderID: &pos.ShortLeg.OrderID, - }) - t.db.SaveSystemOrder(&db.SystemOrderRecord{ - TradeID: pos.DBTradeID, Type: "exit", Status: "filled", - Spread: &diffPct, - LongPrice: &longPrice, ShortPrice: &shortPrice, - LongOrderID: &longOID, ShortOrderID: &shortOID, - CreatedAt: now, - }) - - feeEntrySum := longEntryFeeSum + shortEntryFeeSum - feeExitSum := longExitFeeAmt + shortExitFeeAmt - t.db.UpdateTradeStatus(pos.DBTradeID, &db.TradeRecord{ - Status: "closed", - ExitSpread: &diffPct, - LongExit: &longPrice, - ShortExit: &shortPrice, - LongPnl: &longPnlPct, - ShortPnl: &shortPnlPct, - FeeEntry: &feeEntrySum, - FeeExit: &feeExitSum, - NetPnl: &netPnlPct, - AmountUSD: pos.AmountUSD, - ScaleCount: pos.ScaleLevels, - ExitReason: &exitReason, - Convergence: &convergence, - ClosedAt: &now, - PnlLongUSD: &longPnlUSD, - PnlShortUSD: &shortPnlUSD, - FeeLongUSD: &longFeeUSD, - FeeShortUSD: &shortFeeUSD, - }) - } - - // SSE trade event - if t.OnTradeEvent != nil { - t.OnTradeEvent("trade_close", map[string]interface{}{ - "coin": pos.Coin, - "direction": pos.Direction, - "entry_spread": pos.EntrySpread, - "exit_spread": diffPct, - "pnl_pct": netPnlPct, - "pnl_usd": record.PnlUSD, - "convergence": convergence, - "reason": exitReason, - "duration": record.Duration, - "scale_levels": pos.ScaleLevels, - "amount_usd": pos.AmountUSD, - "long_pnl_usd": record.PnlLongUSD, - "short_pnl_usd": record.PnlShortUSD, - "long_fee_usd": record.FeeLongUSD, - "short_fee_usd": record.FeeShortUSD, - }) - } -} - -func (t *Trader) closeBothLegs(pos *ArbPosition) string { - errs := "" - if !pos.LongLeg.Closed { - if e := t.closeLeg(pos.LongLeg); e != "" { - errs += "long:" + e + "; " - } - } - if !pos.ShortLeg.Closed { - if e := t.closeLeg(pos.ShortLeg); e != "" { - errs += "short:" + e + "; " - } - } - return errs -} - -func (t *Trader) closeLeg(leg *PositionLeg) string { - if leg.Closed { - return "" - } - side := "sell" - if leg.Side == Short { - side = "buy" - } - - if t.cfg.TestMode { - leg.Closed = true - leg.ExitTime = time.Now() - return "" - } - - if leg.Exchange == ExBitget { - // Query actual position from exchange before closing — handles partial fills - // during IOC entry and scale-ins where leg.Size is stale. - posSize, posSizeStr := t.bitget.CheckPosition(leg.Coin + "USDT") - if posSize <= 0 { - log.Printf("[Trader] BG %s %s: no position to close (already closed)", side, leg.Coin+"USDT") - leg.Closed = true - leg.ExitTime = time.Now() - return "" - } - // Bitget v2 hedge mode: side must match holdSide, not order direction - // close long → side=buy, holdSide=long - // close short → side=sell, holdSide=short - holdSide := "long" - if leg.Side == Short { - holdSide = "short" - } - closeSide := "buy" - if leg.Side == Short { - closeSide = "sell" - } - log.Printf("[Order] BG close %s %s | leg.Size=%s actualSize=%s | holdSide=%s", closeSide, leg.Coin+"USDT", leg.Size, posSizeStr, holdSide) - resp, err := t.bitget.PlaceMarketOrder(closeSide, leg.Coin+"USDT", posSizeStr, "close", holdSide) - if err != nil { - // 22002 = no position on exchange (already closed manually or previously) - if strings.Contains(err.Error(), "22002") { - log.Printf("[Trader] BG %s %s: already closed (22002)", closeSide, leg.Coin+"USDT") - } else { - return fmt.Sprintf("%v", err) - } - } else { - log.Printf("[ExRes] BG close %s %s: size=%s → response=%s", closeSide, leg.Coin+"USDT", posSizeStr, resp) - leg.OrderID = resp - - // Fetch actual exit fill price - fillPrice, _, fetchErr := t.bitget.GetTradeFee(leg.Coin+"USDT", resp) - if fetchErr == nil && fillPrice > 0 { - leg.ExitPrice = fillPrice - log.Printf("[Fill] BG close %s: actual exitPrice=%.6f", leg.Coin+"USDT", fillPrice) - } - } - } else { - log.Printf("[Order] HL close %s %s | size=%s", side, leg.Coin, leg.Size) - resp, err := t.hyperliquid.PlaceMarketCloseOrder(leg.Coin, leg.Size) - if err != nil { - return fmt.Sprintf("%v", err) - } - log.Printf("[ExRes] HL close %s %s: size=%s → response=%s", side, leg.Coin, leg.Size, resp) - leg.OrderID = resp - - // Parse actual fill price from HL close response - fillPrice, _, parseErr := t.hyperliquid.ParseFillFromResponse(resp) - if parseErr == nil && fillPrice > 0 { - leg.ExitPrice = fillPrice - log.Printf("[Fill] HL close %s: actual exitPrice=%.6f", leg.Coin, fillPrice) - } - } - leg.Closed = true - leg.ExitTime = time.Now() - return "" -} - -// retryClose retries closing a position that previously failed. -// Only closes legs not already marked Closed. Notifies periodically. -// Gives up after 30 failed attempts to avoid infinite log loops. -func (t *Trader) retryClose(pos *ArbPosition, bgP, hlP float64, notifier *Notifier) { - pos.CloseRetryCount++ - if pos.CloseRetryCount > 30 { - log.Printf("[Trader] %s: Retry close abandoned after %d attempts (last: %s)", - pos.Coin, pos.CloseRetryCount, pos.ErrorLog) - pos.Status = "failed" - t.mu.Lock() - delete(t.positions, pos.Coin) - t.mu.Unlock() - if t.db != nil && pos.DBTradeID > 0 { - t.db.SetTradeStatus(pos.DBTradeID, "failed") - } - return - } - log.Printf("[Trader] %s: Retrying close #%d (previous err: %s)", pos.Coin, pos.CloseRetryCount, pos.ErrorLog) - - closeErr := t.closeBothLegs(pos) - if closeErr == "" { - // All legs finally closed — record + update DB - pos.Status = "closed" - pos.ExitedAt = time.Now() - - elapsed := time.Since(pos.StartedAt) - record := TradeRecord{ - Coin: pos.Coin, - Direction: pos.Direction, - EntrySpread: pos.EntrySpread, - ExitSpread: pos.ExitDiffPct, - PnlPct: pos.ExitNetPnl, - PnlUSD: 2 * pos.AmountUSD * pos.ExitNetPnl / 100, - Convergence: pos.ExitConvergence, - Reason: pos.ExitReasonText, - Duration: elapsed.Round(time.Second).String(), - OpenedAt: pos.StartedAt, - ClosedAt: pos.ExitedAt, - ScaleLevels: pos.ScaleLevels, - AmountUSD: pos.AmountUSD, - PnlLongUSD: pos.ExitLongPnlUSD, - PnlShortUSD: pos.ExitShortPnlUSD, - FeeLongUSD: pos.ExitLongFeeUSD, - FeeShortUSD: pos.ExitShortFeeUSD, - } - - t.mu.Lock() - delete(t.positions, pos.Coin) - t.lastTradeTime[pos.Coin] = time.Now() - t.closedTrades = append(t.closedTrades, record) - // Update exchange funds - if ef, ok := t.exchangeFunds[pos.LongLeg.Exchange]; ok { - ef.Balance -= pos.ExitLongFeeUSD - ef.Balance += pos.ExitLongPnlUSD - ef.TotalFee += pos.ExitLongFeeUSD - ef.TotalPnl += pos.ExitLongPnlUSD - } - if ef, ok := t.exchangeFunds[pos.ShortLeg.Exchange]; ok { - ef.Balance -= pos.ExitShortFeeUSD - ef.Balance += pos.ExitShortPnlUSD - ef.TotalFee += pos.ExitShortFeeUSD - ef.TotalPnl += pos.ExitShortPnlUSD - } - t.mu.Unlock() - - // Persist exit orders + close trade in DB (only for legs that weren't already closed) - if t.db != nil && pos.DBTradeID > 0 { - now := time.Now() - status := "filled" - tradeUnit := t.cfg.TradeAmountUSD - - totalLongShares := 0.0 - for _, p := range pos.LongEntryPrices { - totalLongShares += tradeUnit / p - } - totalshortShares := 0.0 - for _, p := range pos.ShortEntryPrices { - totalshortShares += tradeUnit / p - } - - // Save exit orders for legs that were just now closed - if pos.LongLeg.Closed { - longExitFee := totalLongShares * pos.LongLeg.ExitPrice * takerFees[pos.LongLeg.Exchange] / 100 - longExitShares := totalLongShares - _, _ = t.db.SaveOrder(&db.OrderRecord{ - TradeID: pos.DBTradeID, Leg: "long", Type: "exit", - Exchange: pos.LongLeg.Exchange, Side: "sell", - Price: &pos.LongLeg.ExitPrice, Size: &longExitShares, - Fee: &longExitFee, Status: &status, CreatedAt: now, - OrderID: &pos.LongLeg.OrderID, - }) - } - if pos.ShortLeg.Closed { - shortExitFee := totalshortShares * pos.ShortLeg.ExitPrice * takerFees[pos.ShortLeg.Exchange] / 100 - shortExitShares := totalshortShares - _, _ = t.db.SaveOrder(&db.OrderRecord{ - TradeID: pos.DBTradeID, Leg: "short", Type: "exit", - Exchange: pos.ShortLeg.Exchange, Side: "buy", - Price: &pos.ShortLeg.ExitPrice, Size: &shortExitShares, - Fee: &shortExitFee, Status: &status, CreatedAt: now, - OrderID: &pos.ShortLeg.OrderID, - }) - } - // Save exit system order (idempotent-safe since we always overwrite on retry) - t.db.SaveSystemOrder(&db.SystemOrderRecord{ - TradeID: pos.DBTradeID, Type: "exit", Status: "filled", - Spread: &pos.ExitDiffPct, - LongPrice: &pos.LongLeg.ExitPrice, ShortPrice: &pos.ShortLeg.ExitPrice, - CreatedAt: now, - }) - - // Close trade using previously saved exit metadata - numBatches := 1 + pos.ScaleLevels - longEntryFeeSum := float64(numBatches) * tradeUnit * takerFees[pos.LongLeg.Exchange] / 100 - shortEntryFeeSum := float64(numBatches) * tradeUnit * takerFees[pos.ShortLeg.Exchange] / 100 - longExitFeeAmt := totalLongShares * pos.LongLeg.ExitPrice * takerFees[pos.LongLeg.Exchange] / 100 - shortExitFeeAmt := totalshortShares * pos.ShortLeg.ExitPrice * takerFees[pos.ShortLeg.Exchange] / 100 - feeEntrySum := longEntryFeeSum + shortEntryFeeSum - feeExitSum := longExitFeeAmt + shortExitFeeAmt - t.db.UpdateTradeStatus(pos.DBTradeID, &db.TradeRecord{ - Status: "closed", - ExitSpread: &pos.ExitDiffPct, - LongExit: &pos.LongLeg.ExitPrice, - ShortExit: &pos.ShortLeg.ExitPrice, - LongPnl: &pos.ExitLongPnl, - ShortPnl: &pos.ExitShortPnl, - FeeEntry: &feeEntrySum, - FeeExit: &feeExitSum, - NetPnl: &pos.ExitNetPnl, - AmountUSD: pos.AmountUSD, - ScaleCount: pos.ScaleLevels, - ExitReason: &pos.ExitReasonText, - Convergence: &pos.ExitConvergence, - ClosedAt: &now, - PnlLongUSD: &pos.ExitLongPnlUSD, - PnlShortUSD: &pos.ExitShortPnlUSD, - FeeLongUSD: &pos.ExitLongFeeUSD, - FeeShortUSD: &pos.ExitShortFeeUSD, - }) - } - - notifier.Send(fmt.Sprintf( - "[平仓重试成功] %s/USDT %s\n"+ - " 之前失败: %s\n"+ - " 已成功关掉所有腿 | 盈亏: %+.4f%%\n", pos.Coin, pos.Direction, pos.ErrorLog, pos.ExitNetPnl)) - return - } - - // Still failing — update log and notify periodically - pos.ErrorLog = closeErr - log.Printf("[Trader] ❌ %s: Retry close still failing: %s", pos.Coin, closeErr) - if time.Since(pos.ExitedAt) > 30*time.Second { - notifier.Send(fmt.Sprintf( - "[平仓仍失败] %s/USDT %s\n"+ - " 已重试 %s, 仍失败: %s\n"+ - " 请手动检查交易所\n", pos.Coin, pos.Direction, - time.Since(pos.ExitedAt).Round(time.Second).String(), closeErr)) - pos.ExitedAt = time.Now() - } -} - -// placeOrderAt places an order at a specified price (used for scale-in, Issue #2). -// Unlike placeOrder, this doesn't modify the leg's EntryPrice — it places -// an additional order at the current market price for the same trade amount. -// Returns (error string, actual fee in USD). -func (t *Trader) placeOrderAt(leg *PositionLeg, side string, store *PriceStore, price float64) (string, float64) { - if t.cfg.TestMode { - // Mock fill using specified price instead of leg's original entry - origPrice := leg.EntryPrice - leg.EntryPrice = price - err := t.mockFill(leg, side, store) - leg.EntryPrice = origPrice // restore original (entry tracking is per-position, not per-order) - return err, 0 - } - if leg.Exchange == ExBitget { - szStr := exchange.GetBitgetSize(leg.Coin+"USDT", t.cfg.TradeAmountUSD, price) - log.Printf("[Order] BG scale %s %s | amountUSD=%.2f price=%.6f size=%s", side, leg.Coin+"USDT", t.cfg.TradeAmountUSD, price, szStr) - oid, err := t.bitget.PlaceMarketOrder(side, leg.Coin+"USDT", szStr, "open", "") - if err != nil { - return fmt.Sprintf("BG %s error: %v", side, err), 0 - } - leg.OrderID = oid - - // Fetch actual fee and fill price from exchange - fillPrice, fee, fetchErr := t.bitget.GetTradeFee(leg.Coin+"USDT", oid) - if fetchErr != nil { - log.Printf("[Fee] BG scale GetTradeFee warning: %v", fetchErr) - fee = 0 - } else { - log.Printf("[Fee] BG scale %s %s: actual fee=$%.6f fillPrice=%.6f", side, leg.Coin+"USDT", fee, fillPrice) - if fillPrice > 0 { - leg.EntryPrice = fillPrice - } - } - - // Verify position actually exists (IOC can succeed without fills) - time.Sleep(500 * time.Millisecond) - if posSize, _ := t.bitget.CheckPosition(leg.Coin + "USDT"); posSize <= 0 { - log.Printf("[Trader] BG scale %s %s: IOC accepted but no position created (zero fill)", side, leg.Coin+"USDT") - return fmt.Sprintf("BG scale %s zero fill (no position)", side), fee - } - return "", fee - } else { - szStr := t.hyperliquid.GetSize(leg.Coin, t.cfg.TradeAmountUSD, price) - log.Printf("[Order] HL scale %s %s | amountUSD=%.2f price=%.6f size=%s", side, leg.Coin, t.cfg.TradeAmountUSD, price, szStr) - oid, err := t.hyperliquid.PlaceMarketOrder(leg.Coin, side, szStr) - if err != nil { - return fmt.Sprintf("HL %s error: %v", side, err), 0 - } - leg.OrderID = oid - - // Parse actual fill price from HL response - fillPrice, _, parseErr := t.hyperliquid.ParseFillFromResponse(oid) - if parseErr == nil && fillPrice > 0 { - leg.EntryPrice = fillPrice - log.Printf("[Fill] HL scale %s %s: actual fillPrice=%.6f", side, leg.Coin, fillPrice) - } - - // Estimate fee from HL response - fee, fetchErr := t.hyperliquid.EstimateFeeFromResponse(oid, takerFees[ExHyperLiquid]) - if fetchErr != nil { - log.Printf("[Fee] HL scale EstimateFeeFromResponse warning: %v", fetchErr) - fee = 0 - } else { - log.Printf("[Fee] HL scale %s %s: fee=$%.6f", side, leg.Coin, fee) - } - return "", fee - } -} - -// mockFill simulates order execution. Uses stored bid/ask spread for realistic slippage. -// Falls back to fixed MOCK_SLIPPAGE_PCT if no spread data available. -func (t *Trader) mockFill(leg *PositionLeg, side string, store *PriceStore) string { - spreadPct := t.cfg.MockSlippagePct // default fallback - - // Try to get actual spread from store - if s := store.GetSpread(leg.Coin, leg.Exchange); s > 0 { - spreadPct = s - } - - slippage := spreadPct * 0.01 * leg.EntryPrice - fillPrice := leg.EntryPrice - if side == "buy" { - fillPrice += slippage - } else { - fillPrice -= slippage - } - - leg.EntryPrice = fillPrice - leg.Size = "mock" - leg.OrderID = "mock-" + fmt.Sprintf("%d", time.Now().UnixNano()) - leg.Closed = false - return "" -} - -func (t *Trader) cleanup(coin string) { - t.mu.Lock() - delete(t.positions, coin) - t.lastTradeTime[coin] = time.Now() - t.mu.Unlock() -} - -func (t *Trader) GetOpenPositions() []*ArbPosition { - t.mu.Lock() - defer t.mu.Unlock() - r := make([]*ArbPosition, 0, len(t.positions)) - for _, p := range t.positions { - r = append(r, p) - } - return r -} - -// calcArbPnL computes net PnL and total fees in USD, then normalizes to % of total deployed capital. -// This correctly handles scale-ins where the old formula (longPnl+shortPnl - (2+N)*0.105) -// double-counted fees because it didn't divide by (1+N) batches. -func calcArbPnL(longPnl, shortPnl float64, scaleLevels int, tradeAmountUSD float64) (netPnlPct, feePct float64) { - numBatches := 1 + scaleLevels - legCapital := tradeAmountUSD - totalCapital := float64(numBatches) * 2 * legCapital - - // Gross PnL in USD - longPnlUSD := longPnl / 100 * float64(numBatches) * legCapital - shortPnlUSD := shortPnl / 100 * float64(numBatches) * legCapital - grossPnLUSD := longPnlUSD + shortPnlUSD - - // Fee in USD (entry+exit per order-pair) - feeUSD := float64(2+scaleLevels) * legCapital * (takerFees[ExBitget] + takerFees[ExHyperLiquid]) / 100 - - netPnlPct = (grossPnLUSD - feeUSD) / totalCapital * 100 - feePct = feeUSD / totalCapital * 100 - return -} - -// weightedAvgPrice computes the weighted average entry price across multiple scale levels. -// Each level trades the same USD amount, so the result is the harmonic mean of prices. -func weightedAvgPrice(prices []float64, amountPerTrade float64) float64 { - if len(prices) == 0 { - return 0 - } - totalShares := 0.0 - totalCost := 0.0 - for _, p := range prices { - if p <= 0 { - continue - } - totalShares += amountPerTrade / p - totalCost += amountPerTrade - } - if totalShares <= 0 { - return prices[0] // fallback - } - return totalCost / totalShares -} - -// GetClosedStats returns convergence stats from all closed trades (DB history + current session). -func (t *Trader) GetClosedStats() (converged, diverged, flat, total int) { - t.mu.Lock() - defer t.mu.Unlock() - // Start with DB historical counts - converged, diverged, flat, total = t.dbConverged, t.dbDiverged, t.dbFlat, t.dbTotal - // Add in-memory session trades - for _, tr := range t.closedTrades { - total++ - switch tr.Convergence { - case "价差收敛": - converged++ - case "价差发散": - diverged++ - default: - flat++ - } - } - return -} - -// GetClosedTrades returns the full closed trade history. -func (t *Trader) GetClosedTrades() []TradeRecord { - t.mu.Lock() - defer t.mu.Unlock() - r := make([]TradeRecord, len(t.closedTrades)) - copy(r, t.closedTrades) - return r -} - -// GetExchangeFunds returns a copy of per-exchange fund states. -func (t *Trader) GetExchangeFunds() map[string]ExchangeFund { - t.mu.Lock() - defer t.mu.Unlock() - r := make(map[string]ExchangeFund, len(t.exchangeFunds)) - for ex, ef := range t.exchangeFunds { - r[ex] = *ef - } - return r -} - -// persistTrade saves a completed trade to SQLite, with per-leg orders and system_orders. -// restoreOpenPositions loads open trades from DB and recreates their positions. -func (t *Trader) restoreOpenPositions() { - openTrades, err := t.db.GetOpenTrades() - if err != nil { - log.Printf("[Trader] Failed to load open trades: %v", err) - 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] - // Skip "entering" trades — process was killed mid-entry, orders not confirmed - if tr.Status == "entering" { - log.Printf("[Trader] Skipping incomplete trade %d (%s status='entering'), marking as failed", tr.ID, tr.Coin) - t.db.SetTradeStatus(tr.ID, "failed") - continue - } - // Recreate position structure from DB record - pos := &ArbPosition{ - Coin: tr.Coin, - Direction: tr.Direction, - AmountUSD: tr.AmountUSD, - EntrySpread: *tr.EntrySpread, - ScaleLevels: tr.ScaleCount, - LastScaleAt: tr.OpenedAt, // B#3: prevent immediate scale-in bypass - StartedAt: tr.OpenedAt, - Status: "open", - } - if tr.LongEntry != nil { - pos.LongLeg = &PositionLeg{ - Coin: tr.Coin, Exchange: tr.LongExchange, Side: Long, - EntryPrice: *tr.LongEntry, EntryTime: tr.OpenedAt, - } - pos.LongEntryPrices = []float64{*tr.LongEntry} - } - if tr.ShortEntry != nil { - pos.ShortLeg = &PositionLeg{ - Coin: tr.Coin, Exchange: tr.ShortExchange, Side: Short, - EntryPrice: *tr.ShortEntry, EntryTime: tr.OpenedAt, - } - pos.ShortEntryPrices = []float64{*tr.ShortEntry} - } - - // Skip if either leg is missing (incomplete DB record) - if pos.LongLeg == nil || pos.ShortLeg == nil { - log.Printf("[Trader] Skipping trade %d (%s): incomplete leg data (long=%v short=%v)", - tr.ID, tr.Coin, tr.LongEntry, tr.ShortEntry) - t.db.SetTradeStatus(tr.ID, "failed") - continue - } - - // Restore scale-in prices from orders table for correct weighted average - scaleLong, scaleShort, err := t.db.GetScalePrices(tr.ID) - if err == nil { - pos.LongEntryPrices = append(pos.LongEntryPrices, scaleLong...) - pos.ShortEntryPrices = append(pos.ShortEntryPrices, scaleShort...) - // Restore ScaleLevels from actual scale order count - if len(scaleLong) > 0 { - pos.ScaleLevels = len(scaleLong) - pos.AmountUSD = tr.AmountUSD * (1 + float64(pos.ScaleLevels)) - } - // Refresh leg EntryPrice to reflect all scale levels - if len(pos.LongEntryPrices) > 1 { - pos.LongLeg.EntryPrice = weightedAvgPrice(pos.LongEntryPrices, t.cfg.TradeAmountUSD) - } - if len(pos.ShortEntryPrices) > 1 { - pos.ShortLeg.EntryPrice = weightedAvgPrice(pos.ShortEntryPrices, t.cfg.TradeAmountUSD) - } - } - pos.DBTradeID = tr.ID - - // Verify position still exists on exchange — if not, mark as closed - posMissing := false - if t.bitget != nil && (pos.LongLeg.Exchange == ExBitget || pos.ShortLeg.Exchange == ExBitget) { - bgSymbol := tr.Coin + "USDT" - if bgSize, _ := t.bitget.CheckPosition(bgSymbol); bgSize <= 0 { - log.Printf("[Trader] Trade %d (%s): Bitget position not found on exchange, marking as closed", tr.ID, tr.Coin) - posMissing = true - } - } - if posMissing { - t.db.SetTradeStatus(tr.ID, "closed") - continue - } - - t.positions[tr.Coin] = pos - // Prevent immediate re-trading of the same coin - t.lastTradeTime[tr.Coin] = tr.OpenedAt - } - if len(openTrades) > 0 { - log.Printf("[Trader] Restored %d open positions from DB", len(t.positions)) - } -} - -// blacklistCoin adds a coin to the blacklist and force-closes its position. -// Calculates exit PnL fields so retryClose writes correct data to DB. -func (t *Trader) blacklistCoin(pos *ArbPosition, bgP, hlP, diffPct float64, notifier *Notifier) { - // Compute exit PnL the same way checkExit does - var longCurrent, shortCurrent float64 - if pos.LongLeg.Exchange == ExBitget { - longCurrent, shortCurrent = bgP, hlP - } else { - longCurrent, shortCurrent = hlP, bgP - } - longAvg := weightedAvgPrice(pos.LongEntryPrices, t.cfg.TradeAmountUSD) - shortAvg := weightedAvgPrice(pos.ShortEntryPrices, t.cfg.TradeAmountUSD) - longPnl := (longCurrent - longAvg) / longAvg * 100 - shortPnl := (shortAvg - shortCurrent) / shortAvg * 100 - netPnl, totalFees := calcArbPnL(longPnl, shortPnl, pos.ScaleLevels, t.cfg.TradeAmountUSD) - - pos.ExitDiffPct = diffPct - pos.ExitNetPnl = netPnl - pos.ExitLongPnl = longPnl - pos.ExitShortPnl = shortPnl - pos.ExitTotalFees = totalFees - pos.LongLeg.ExitPrice = longCurrent - pos.ShortLeg.ExitPrice = shortCurrent - pos.ExitReasonText = "黑名单强平" - - // Pre-compute per-exchange PnL/fees for retryClose - numBatchesBlack := 1 + pos.ScaleLevels - pos.ExitLongPnlUSD = longPnl / 100 * float64(numBatchesBlack) * t.cfg.TradeAmountUSD - pos.ExitShortPnlUSD = shortPnl / 100 * float64(numBatchesBlack) * t.cfg.TradeAmountUSD - { - totalLongSharesBlack := 0.0 - for _, p := range pos.LongEntryPrices { - totalLongSharesBlack += t.cfg.TradeAmountUSD / p - } - totalShortSharesBlack := 0.0 - for _, p := range pos.ShortEntryPrices { - totalShortSharesBlack += t.cfg.TradeAmountUSD / p - } - pos.ExitLongFeeUSD = float64(numBatchesBlack)*t.cfg.TradeAmountUSD*takerFees[pos.LongLeg.Exchange]/100 + - totalLongSharesBlack*longCurrent*takerFees[pos.LongLeg.Exchange]/100 - pos.ExitShortFeeUSD = float64(numBatchesBlack)*t.cfg.TradeAmountUSD*takerFees[pos.ShortLeg.Exchange]/100 + - totalShortSharesBlack*shortCurrent*takerFees[pos.ShortLeg.Exchange]/100 - } - - // Convergence label - convergedPct := (pos.EntrySpread - diffPct) / pos.EntrySpread * 100 - if convergedPct < -10 { - pos.ExitConvergence = "价差发散" - } else if convergedPct < 10 { - pos.ExitConvergence = "价差持平" - } else { - pos.ExitConvergence = "价差收敛" - } - - t.mu.Lock() - t.blacklist[pos.Coin] = time.Now() - t.mu.Unlock() - - log.Printf("[Trader] ⛔ %s: Blacklisted — position open %.0fm without convergence | spread=%.4f%% netPnl=%.4f%%", pos.Coin, time.Since(pos.StartedAt).Minutes(), diffPct, netPnl) - notifier.Send(fmt.Sprintf( - "[黑名单] %s/USDT\n"+ - " 开仓 %.0f 分钟未收敛\n"+ - " 价差: %.4f%% 净利: %.4f%%\n"+ - " 已加入黑名单观察\n", - pos.Coin, time.Since(pos.StartedAt).Minutes(), diffPct, netPnl)) - - // 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) -} - -// safeFloat returns 0 for nil float64 pointers (DB nullable fields). -func safeFloat(f *float64) float64 { - if f == nil { - return 0 - } - return *f -} - -// safeStr returns empty string for nil string pointers (DB nullable fields). -func safeStr(s *string) string { - if s == nil { - return "" - } - return *s -} \ No newline at end of file diff --git a/trend.go b/trend.go index fda57d5..bdc6c84 100644 --- a/trend.go +++ b/trend.go @@ -199,14 +199,11 @@ func (td *TrendDetector) Tick() { defer td.mu.Unlock() for _, entry := range entries { - // Collect 60s changes from all 4 exchanges + // Collect 60s changes from all 3 exchanges var changes []exchangeChange if entry.BG60s != 0 { changes = append(changes, exchangeChange{name: ExBitget, change: entry.BG60s}) } - if entry.HL60s != 0 { - changes = append(changes, exchangeChange{name: ExHyperLiquid, change: entry.HL60s}) - } if entry.BN60s != 0 { changes = append(changes, exchangeChange{name: ExBinance, change: entry.BN60s}) } @@ -214,8 +211,8 @@ func (td *TrendDetector) Tick() { changes = append(changes, exchangeChange{name: ExOKX, change: entry.OKX60s}) } - if len(changes) < 3 { - continue // need at least 3 exchanges for reliable detection + if len(changes) < 2 { + continue // need at least 2 exchanges for reliable detection } // Compute aggregate stats @@ -287,7 +284,7 @@ func (td *TrendDetector) Tick() { cs.confirmCount = 1 cs.misalignCount = 0 td.recordEvent(entry.Coin, "idle", "alert", string(majorityDir), - zScore, cs.volatility, entry.BG60s, entry.HL60s, entry.BN60s, entry.OKX60s, + zScore, cs.volatility, entry.BG60s, 0, entry.BN60s, entry.OKX60s, majorityCount, len(changes)) } @@ -301,7 +298,7 @@ func (td *TrendDetector) Tick() { cs.confirmedAt = now cs.stateSince = now td.recordEvent(entry.Coin, "alert", "confirmed", string(cs.direction), - zScore, cs.volatility, entry.BG60s, entry.HL60s, entry.BN60s, entry.OKX60s, + zScore, cs.volatility, entry.BG60s, 0, entry.BN60s, entry.OKX60s, majorityCount, len(changes)) } } else { @@ -313,7 +310,7 @@ func (td *TrendDetector) Tick() { cs.confirmCount = 0 cs.misalignCount = 0 td.recordEvent(entry.Coin, "alert", "idle", string(cs.direction), - zScore, cs.volatility, entry.BG60s, entry.HL60s, entry.BN60s, entry.OKX60s, + zScore, cs.volatility, entry.BG60s, 0, entry.BN60s, entry.OKX60s, majorityCount, len(changes)) } } @@ -325,7 +322,7 @@ func (td *TrendDetector) Tick() { cs.state = TrendExhausting cs.stateSince = now td.recordEvent(entry.Coin, "confirmed", "exhausting", string(cs.direction), - zScore, cs.volatility, entry.BG60s, entry.HL60s, entry.BN60s, entry.OKX60s, + zScore, cs.volatility, entry.BG60s, 0, entry.BN60s, entry.OKX60s, majorityCount, len(changes)) } @@ -337,7 +334,7 @@ func (td *TrendDetector) Tick() { cs.confirmCount = 0 cs.misalignCount = 0 td.recordEvent(entry.Coin, "exhausting", "idle", string(cs.direction), - zScore, cs.volatility, entry.BG60s, entry.HL60s, entry.BN60s, entry.OKX60s, + zScore, cs.volatility, entry.BG60s, 0, entry.BN60s, entry.OKX60s, majorityCount, len(changes)) } // Also immediately go to idle if below threshold @@ -347,7 +344,7 @@ func (td *TrendDetector) Tick() { cs.confirmCount = 0 cs.misalignCount = 0 td.recordEvent(entry.Coin, "exhausting", "idle", string(cs.direction), - zScore, cs.volatility, entry.BG60s, entry.HL60s, entry.BN60s, entry.OKX60s, + zScore, cs.volatility, entry.BG60s, 0, entry.BN60s, entry.OKX60s, majorityCount, len(changes)) } } @@ -391,13 +388,13 @@ func (td *TrendDetector) Snapshot() []TrendEntry { if me, ok := entryMap[coin]; ok { entry.BGChange = me.BG15s - entry.HLChange = me.HL15s + entry.HLChange = 0 entry.BNChange = me.BN15s entry.OKXChange = me.OKX15s // Count how many exchanges agree with the trend direction agree := 0 - changes := []float64{entry.BGChange, entry.HLChange, entry.BNChange, entry.OKXChange} + changes := []float64{entry.BGChange, entry.BNChange, entry.OKXChange} for _, c := range changes { if cs.direction == TrendUp && c > 0.001 { agree++ diff --git a/trend_filter.go b/trend_filter.go index 24b1905..26575f2 100644 --- a/trend_filter.go +++ b/trend_filter.go @@ -484,21 +484,6 @@ func (tf *TrendFilter) computeFilterState(coin string, k1h, k5m []klineData, now return fs } -// median returns the median value of a sorted copy of the slice. -func median(values []float64) float64 { - if len(values) == 0 { - return 0 - } - sorted := make([]float64, len(values)) - copy(sorted, values) - sort.Float64s(sorted) - mid := len(sorted) / 2 - if len(sorted)%2 == 0 { - return (sorted[mid-1] + sorted[mid]) / 2 - } - return sorted[mid] -} - // computeSignalScore calculates the composite signal score (0-100) from a FilterState. // Must be called after FreshAnomaly, PriceAboveEMA, and volume fields are set. func computeSignalScore(fs *FilterState) float64 { diff --git a/types.go b/types.go index 44b693d..912e211 100644 --- a/types.go +++ b/types.go @@ -1,7 +1,6 @@ package main import ( - "log" "sync" "time" ) @@ -11,7 +10,6 @@ type TrackedCoin struct { Name string // Display name (BTC, ETH, etc.) BN string // Binance symbol (BTCUSDT) BG string // Bitget symbol (BTCUSDT) - HL string // HyperLiquid symbol (BTC) OK string // OKX symbol (BTC-USDT-SWAP) } @@ -31,9 +29,9 @@ type Spread struct { // PriceStore holds the latest prices from all exchanges, thread-safe. type PriceStore struct { - mu sync.RWMutex - prices map[string]map[string]float64 // coin -> exchange -> price - spreads map[string]map[string]*Spread // coin -> exchange -> spread + mu sync.RWMutex + prices map[string]map[string]float64 // coin -> exchange -> price + spreads map[string]map[string]*Spread // coin -> exchange -> spread } func NewPriceStore() *PriceStore { @@ -109,92 +107,3 @@ func (s *PriceStore) GetAll() map[string]map[string]float64 { } return snap } - -// ArbOpportunity represents a profitable arbitrage route. -type ArbOpportunity struct { - Coin string - Direction string // e.g. "BN->HL" - BuyEx string - SellEx string - BuyPrice float64 - SellPrice float64 - NetProfit float64 // percentage after fees - GrossBasis float64 // raw price difference % -} - -// SpreadWindow tracks how long each coin's spread stays above threshold. -// Used to measure the window of opportunity between threshold-crossing and -// convergence — helps diagnose whether entry latency is a problem. -type SpreadWindow struct { - Coin string - Direction string // "BG->HL" or "HL->BG" - Since time.Time - PeakNet float64 // highest netProfit % observed during this window -} - -type SpreadWindowTracker struct { - windows map[string]*SpreadWindow // key: "COIN:DIRECTION" -} - -func NewSpreadWindowTracker() *SpreadWindowTracker { - return &SpreadWindowTracker{windows: make(map[string]*SpreadWindow)} -} - -func (swt *SpreadWindowTracker) Tick(snap map[string]map[string]float64, threshold float64) { - now := time.Now() - for _, coin := range TrackedCoins { - if coin.BG == "" || coin.HL == "" { - continue - } - exMap := snap[coin.Name] - if exMap == nil { - continue - } - bgP := exMap[ExBitget] - hlP := exMap[ExHyperLiquid] - if bgP <= 0 || hlP <= 0 { - continue - } - - // Check both directions — use netProfit() for exact fee model match - // BG→HL: buy BG (Bitget 0.020%), sell HL (HL 0.015%) - // HL→BG: buy HL (HL 0.015%), sell BG (Bitget 0.020%) - type dirCheck struct { - name string - buyPrice float64 - sellPrice float64 - buyFee float64 - sellFee float64 - } - for _, dir := range []dirCheck{ - {"BG->HL", bgP, hlP, takerFees[ExBitget], takerFees[ExHyperLiquid]}, - {"HL->BG", hlP, bgP, takerFees[ExHyperLiquid], takerFees[ExBitget]}, - } { - key := coin.Name + ":" + dir.name - netSpr := netProfit(dir.buyPrice, dir.sellPrice, dir.buyFee, dir.sellFee) - - w, exists := swt.windows[key] - if netSpr >= threshold { - if !exists { - swt.windows[key] = &SpreadWindow{ - Coin: coin.Name, - Direction: dir.name, - Since: now, - PeakNet: netSpr, - } - } else if netSpr > w.PeakNet { - w.PeakNet = netSpr - } - } else { - if exists { - dur := now.Sub(w.Since) - if dur > 100*time.Millisecond { - log.Printf("[SpreadWindow] %s %s exceeded threshold for %v (peak net=%+.4f%%)", - w.Coin, w.Direction, dur.Round(time.Millisecond), w.PeakNet) - } - delete(swt.windows, key) - } - } - } - } -} diff --git a/web/static/app.js b/web/static/app.js deleted file mode 100644 index cf9f6fd..0000000 --- a/web/static/app.js +++ /dev/null @@ -1,420 +0,0 @@ -/* ============================================================ - Exchange Monitor Dashboard — Frontend Logic v3 (P3) - ============================================================ */ - -(function() { -'use strict'; - -// ---- DOM refs ---- -const $ = id => document.getElementById(id); - -const els = { - clock: $('clock'), - connStatus: $('conn-status'), - connDetail: $('conn-detail'), - pricesAge: $('prices-age'), - priceBody: $('price-body'), - arbBody: $('arb-body'), - posBody: $('positions-body'), - tradesBody: $('trades-body'), - statTotal: $('stat-total'), - statConv: $('stat-converged'), - statDiv: $('stat-diverged'), - statFlat: $('stat-flat'), - statPos: $('stat-positions'), - statCoins: $('stat-coins'), -}; - -// ---- Clock ---- -function updateClock() { - const now = new Date(); - els.clock.textContent = now.toLocaleTimeString('zh-CN', { hour12: false }); -} -setInterval(updateClock, 1000); -updateClock(); - -const EXCHANGES = ['HyperLiquid', 'Bitget']; -const COINS = []; // populated dynamically from SSE data - -function formatPrice(p) { - if (p == null || p <= 0) return '-'; - if (p >= 100) return p.toFixed(2); - if (p >= 1) return p.toFixed(4); - return p.toFixed(6); -} - -function priceClass(last, cur) { - if (last == null || cur == null) return ''; - return cur > last ? 'text-green' : cur < last ? 'text-red' : ''; -} - -function pnlClass(val) { - if (val == null) return ''; - return val > 0 ? 'text-green' : val < 0 ? 'text-red' : ''; -} - -const priceCache = {}; - -// ---- SSE Connection ---- -let eventSource = null; - -function connectSSE() { - if (eventSource) eventSource.close(); - - eventSource = new EventSource('/events'); - - eventSource.addEventListener('connected', () => { - els.connStatus.textContent = '● 已连接'; - els.connStatus.className = 'status-online'; - }); - - eventSource.onerror = () => { - els.connStatus.textContent = '● 已断开 (重连中...)'; - els.connStatus.className = 'status-offline'; - setTimeout(connectSSE, 3000); - }; - - eventSource.onmessage = (e) => { - try { - const msg = JSON.parse(e.data); - const handler = eventHandlers[msg.event]; - if (handler) handler(msg.data); - } catch(err) { - // ignore parse errors - } - }; -} - -// ---- Event Handlers ---- -const eventHandlers = {}; - -eventHandlers.prices = (prices) => { - if (!prices || prices.length === 0) return; - - // Dynamically populate COINS list on first data - if (COINS.length === 0) { - for (const row of prices) { - COINS.push(row.coin); - } - } - - let html = ''; - let coinsOnline = 0; - - for (const coin of COINS) { - const row = prices.find(p => p.coin === coin); - if (!row) { - html += `${coin}${EXCHANGES.map(() => '-').join('')}-`; - continue; - } - coinsOnline++; - - const cells = EXCHANGES.map(ex => { - const p = row[ex]; - const key = coin + '.' + ex; - const prev = priceCache[key]; - const curP = p || 0; - const cls = prev ? priceClass(prev.last, curP) : ''; - - if (prev) { - prev.last = curP; - } else { - priceCache[key] = { last: curP }; - } - - let display = formatPrice(p); - return `${display}`; - }); - - // P3-2: Add spread column - const spread = row['bg_hl_spread']; - const spreadCls = spread > 0.2 ? 'text-green' : spread < -0.2 ? 'text-red' : ''; - const spreadStr = spread != null ? spread.toFixed(4) + '%' : '-'; - - html += `${coin}${cells.join('')}${spreadStr}`; - } - - els.priceBody.innerHTML = html; - els.pricesAge.textContent = new Date().toLocaleTimeString('zh-CN', { hour12: false }); -}; - -eventHandlers.arb = (opps) => { - if (!opps || opps.length === 0) { - els.arbBody.innerHTML = '暂无套利机会'; - return; - } - - const html = opps.slice(0, 10).map(opp => { - const cls = opp.net_profit > 0.10 ? 'text-green' : opp.net_profit > 0.05 ? 'text-yellow' : ''; - return ` - ${opp.coin} - ${opp.direction} - ${formatPrice(opp.buy_price)} - ${formatPrice(opp.sell_price)} - ${opp.net_profit.toFixed(4)} - `; - }).join(''); - - els.arbBody.innerHTML = html; -}; - -// P3-3: Positions with live PnL — sorted by time (oldest first) -eventHandlers.positions = (positions) => { - if (!positions || positions.length === 0) { - els.posBody.innerHTML = '无持仓'; - return; - } - - // Sort by coin name (stable, deterministic) - const sorted = [...positions].sort((a, b) => a.coin.localeCompare(b.coin)); - - const html = sorted.map(p => { - const pnl = p.pnl_est; - const pnlStr = pnl != null ? pnl.toFixed(4) + '%' : '-'; - const curSpread = p.current_spread != null ? p.current_spread.toFixed(4) + '%' : '-'; - return ` - ${p.coin} - ${p.direction} - $${p.amount_usd.toFixed(0)} - ${p.entry_spread.toFixed(4)}% - ${curSpread} - ${pnlStr} - ${p.scales} - ${p.duration} - `; - }).join(''); - - els.posBody.innerHTML = html; -}; - -// P3-5: Connection status in stats -eventHandlers.stats = (stats) => { - els.statTotal.textContent = stats.total_trades || 0; - els.statConv.textContent = stats.converged || 0; - els.statDiv.textContent = stats.diverged || 0; - els.statFlat.textContent = stats.flat || 0; - els.statPos.textContent = stats.open_positions || 0; - els.statCoins.textContent = stats.coins || 0; - - // Detailed PnL stats - if (stats.detail) { - const d = stats.detail; - // Total PnL: show both USD and percentage of capital - const usdStr = (d.total_pnl_usd != null) ? '$' + d.total_pnl_usd.toFixed(2) : '—'; - const pctStr = (d.capital_pnl != null) ? d.capital_pnl.toFixed(4) + '%' : '—'; - $('stat-total-pnl').textContent = usdStr + ' (' + pctStr + ')'; - $('stat-total-pnl').className = pnlClass(d.capital_pnl); - $('stat-capital').textContent = (stats.capital != null) ? '$' + stats.capital.toFixed(0) : '—'; - $('stat-win-rate').textContent = (d.win_rate != null) ? d.win_rate.toFixed(1) + '%' : '—'; - $('stat-max-profit').textContent = (d.max_profit != null) ? '+' + d.max_profit.toFixed(2) + '%' : '—'; - $('stat-max-loss').textContent = (d.max_loss != null) ? d.max_loss.toFixed(2) + '%' : '—'; - $('stat-avg-dur').textContent = d.avg_dur || '—'; - } - - // Connection status dots - if (stats.connections) { - const dots = Object.entries(stats.connections).map(([ex, status]) => { - const color = status === 'online' ? '#3fb950' : status === 'stale' ? '#d29922' : '#f85149'; - return ` ${ex}`; - }).join(' '); - els.connDetail.innerHTML = dots; - } - - // Blacklist — stale spread coins - if (stats.blacklist && stats.blacklist.length > 0) { - const html = stats.blacklist.map(b => { - const minLeft = Math.floor(b.remaining_sec / 60); - const secLeft = b.remaining_sec % 60; - return `⛔ ${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 -eventHandlers.trade_open = (trade) => { - // Flash the positions card to draw attention - const card = $('positions-card'); - card.style.transition = 'border-color 0.3s'; - card.style.borderColor = '#3fb950'; - setTimeout(() => { card.style.borderColor = ''; }, 2000); - // Refresh trades table - setTimeout(loadTrades, 500); -}; - -eventHandlers.trade_close = (trade) => { - const card = $('trades-card'); - card.style.transition = 'border-color 0.3s'; - card.style.borderColor = trade.pnl_pct > 0 ? '#3fb950' : '#f85149'; - setTimeout(() => { card.style.borderColor = ''; }, 2000); - setTimeout(loadTrades, 500); -}; - -// ---- Trades from API ---- -async function loadTrades() { - try { - const resp = await fetch('/api/trades'); - const data = await resp.json(); - const trades = data.trades || []; - - if (trades.length === 0) { - els.tradesBody.innerHTML = '暂无交易记录'; - return; - } - - const html = trades.slice(0, 20).map(t => { - const pnlCls = t.NetPnl > 0 ? 'text-green' : t.NetPnl < 0 ? 'text-red' : ''; - const convCls = t.Convergence === '价差收敛' ? 'text-green' : - t.Convergence === '价差发散' ? 'text-red' : 'text-yellow'; - return ` - ${t.ClosedAt ? new Date(t.ClosedAt).toLocaleTimeString('zh-CN', { hour12: false }) : '-'} - ${t.Coin} - ${t.Direction} - ${t.EntrySpread != null ? t.EntrySpread.toFixed(4) : '-'} - ${t.ExitSpread != null ? t.ExitSpread.toFixed(4) : '-'} - ${t.NetPnl != null ? t.NetPnl.toFixed(4) : '-'} - ${t.Convergence || '-'} - ${t.ExitReason || '-'} - `; - }).join(''); - - els.tradesBody.innerHTML = html; - } catch (err) { - els.tradesBody.innerHTML = '加载失败'; - } -} - -// ---- Trade Detail Modal ---- -function openTradeDetail(id) { - const modal = document.getElementById('trade-modal'); - const body = document.getElementById('trade-detail-body'); - modal.style.display = 'flex'; - body.innerHTML = '
加载中...
'; - - fetch('/api/trade/' + id) - .then(r => r.json()) - .then(data => { - const t = data.trade; - if (!t || !t.ID) { - body.innerHTML = '
交易数据加载失败
'; - return; - } - - const opened = new Date(t.OpenedAt); - const closed = t.ClosedAt ? new Date(t.ClosedAt) : null; - const dur = closed ? Math.round((closed - opened) / 1000) + 's' : '-'; - const pnlCls = t.NetPnl > 0 ? 'text-green' : t.NetPnl < 0 ? 'text-red' : ''; - - const feeEntry = t.FeeEntry != null ? t.FeeEntry.toFixed(3) + '%' : '-'; - const feeExit = t.FeeExit != null ? t.FeeExit.toFixed(3) + '%' : '-'; - const totalFee = t.FeeEntry != null && t.FeeExit != null - ? (t.FeeEntry + t.FeeExit).toFixed(3) + '%' : '-'; - - const le = t.LongEntry != null ? t.LongEntry.toFixed(6) : '-'; - const lx = t.LongExit != null ? t.LongExit.toFixed(6) : '-'; - const se = t.ShortEntry != null ? t.ShortEntry.toFixed(6) : '-'; - const sx = t.ShortExit != null ? t.ShortExit.toFixed(6) : '-'; - const lpnl = t.LongPnl != null ? t.LongPnl.toFixed(4) + '%' : '-'; - const spnl = t.ShortPnl != null ? t.ShortPnl.toFixed(4) + '%' : '-'; - - body.innerHTML = `
-
-

概览

-
币种${t.Coin}/USDT
-
方向${t.Direction || '-'}
-
状态${t.Status === 'closed' ? '已平仓' : t.Status}
-
加仓次数${t.ScaleCount || 0} 次
-
总规模$${(t.AmountUSD || 0).toFixed(0)}
-
-
-

时间

-
开仓${opened.toLocaleString('zh-CN', { hour12: false })}
-
平仓${closed ? closed.toLocaleString('zh-CN', { hour12: false }) : '-'}
-
持仓时长${dur}
-
-
-

价差

-
入场价差${t.EntrySpread != null ? t.EntrySpread.toFixed(4) + '%' : '-'}
-
出场价差${t.ExitSpread != null ? t.ExitSpread.toFixed(4) + '%' : '-'}
-
收敛情况${t.Convergence || '-'}
-
平仓原因${t.ExitReason || '-'}
-
-
-

手续费

-
开仓费${feeEntry}
-
平仓费${feeExit}
-
总手续费${totalFee}
-
-
-

多仓 ${t.LongExchange || '-'}

-
入场价$${le}
-
出场价$${lx}
-
盈亏${lpnl}
-
-
-

空仓 ${t.ShortExchange || '-'}

-
入场价$${se}
-
出场价$${sx}
-
盈亏${spnl}
-
-
-

净收益

-
总计${t.NetPnl != null ? t.NetPnl.toFixed(4) + '%' : '-'}
-
-
`; - - // Append orders table if available - if (data.orders && data.orders.length > 0) { - const ordersHtml = data.orders.map(o => { - const typeLabel = o.Type === 'entry' ? '开仓' : o.Type === 'exit' ? '平仓' : o.Type === 'scale' ? '加仓' : o.Type; - return `${typeLabel}${o.Side === 'buy' ? '买' : '卖'}${o.Exchange}$${o.Price ? o.Price.toFixed(6) : '-'}${o.Size || '-'}${o.Fee ? o.Fee.toFixed(4) + '%' : '-'}${o.OrderID ? o.OrderID.substring(0, 12) + '...' : '-'}`; - }).join(''); - body.innerHTML += `
-

订单明细 (${data.orders.length})

- - - ${ordersHtml} -
类型方向交易所价格数量手续费订单ID
-
`; - } - }) - .catch(err => { - body.innerHTML = '
加载失败: ' + err.message + '
'; - }); -} - -function closeTradeDetail() { - document.getElementById('trade-modal').style.display = 'none'; -} - -// Close modal on overlay click -document.addEventListener('click', function(e) { - const modal = document.getElementById('trade-modal'); - if (e.target === modal) closeTradeDetail(); -}); - -// Close on Escape -document.addEventListener('keydown', function(e) { - if (e.key === 'Escape') closeTradeDetail(); -}); - -// Expose modal functions to global scope for HTML onclick handlers -window.openTradeDetail = openTradeDetail; -window.closeTradeDetail = closeTradeDetail; - -// ---- Init ---- -function init() { - connectSSE(); - loadTrades(); - setInterval(loadTrades, 10000); -} - -if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', init); -} else { - init(); -} - -})(); diff --git a/web/static/index.html b/web/static/index.html deleted file mode 100644 index e1af196..0000000 --- a/web/static/index.html +++ /dev/null @@ -1,132 +0,0 @@ - - - - - -Exchange Monitor Dashboard - - - - -
-
-

⚡ 跨交易所套利监控

-
- --:--:-- - | - ● 未连接 -
-
- -
- -
-

📊 统计数据

-
-
0
-
0
-
0
-
0
-
0 / 5
-
0
-
-
- -
-
-
-
-
-
-
-
-
- - -
-

🔒 当前持仓

-
- - - - - - - -
币种方向规模入价差现价差估盈亏加仓时长
等待数据...
-
-
- - -
-

⛔ 黑名单

-
- 暂无 -
-
- - -
-

💰 实时价格

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

🎯 套利机会 (BG↔HL)

-
- - - - - - - -
币种方向买价卖价净利%
等待数据...
-
-
- - -
-

📋 历史交易

-
- - - - - - - -
时间币种方向入价差出价差净利%结果原因
等待数据...
-
-
- - -
- - - -
- - - - diff --git a/web/static/style.css b/web/static/style.css deleted file mode 100644 index 9e02de1..0000000 --- a/web/static/style.css +++ /dev/null @@ -1,220 +0,0 @@ -/* ============================================================ - Exchange Monitor Dashboard — Dark Theme - ============================================================ */ - -:root { - --bg: #0d1117; - --card: #161b22; - --border: #30363d; - --text: #c9d1d9; - --text-dim: #8b949e; - --accent: #58a6ff; - --green: #3fb950; - --red: #f85149; - --yellow: #d29922; - --blue: #58a6ff; -} - -* { margin: 0; padding: 0; box-sizing: border-box; } - -body { - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif; - background: var(--bg); - color: var(--text); - font-size: 14px; - line-height: 1.5; - min-height: 100vh; -} - -#app { max-width: 1440px; margin: 0 auto; padding: 16px; } - -/* Header */ -header { - display: flex; - justify-content: space-between; - align-items: center; - padding: 12px 16px; - background: var(--card); - border: 1px solid var(--border); - border-radius: 8px; - margin-bottom: 16px; -} - -header h1 { font-size: 18px; font-weight: 600; } -.header-meta { display: flex; align-items: center; gap: 8px; font-size: 13px; color: var(--text-dim); } -.sep { color: var(--border); } -.status-offline { color: var(--red); } -.status-online { color: var(--green); } - -/* Grid layout */ -.grid { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 12px; -} -.card-wide { grid-column: 1 / -1; } - -/* Cards */ -.card { - background: var(--card); - border: 1px solid var(--border); - border-radius: 8px; - padding: 12px; -} - -.card h2 { - font-size: 14px; - font-weight: 600; - color: var(--text-dim); - margin-bottom: 10px; - padding-bottom: 8px; - border-bottom: 1px solid var(--border); -} - -/* Stats row */ -.stats-row { - display: flex; - gap: 16px; - flex-wrap: wrap; -} -.stat { - display: flex; - flex-direction: column; - align-items: center; - min-width: 60px; -} -.stat label { font-size: 11px; color: var(--text-dim); margin-bottom: 2px; } -.stat span { font-size: 20px; font-weight: 700; } -.pct-green { color: var(--green); } -.pct-red { color: var(--red); } -.pct-gray { color: var(--text-dim); } -.pct-yellow { color: var(--yellow); } -.pct-blue { color: var(--blue); } - -/* Connection status dots */ -#conn-details { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; } -#conn-detail { font-size: 11px; white-space: nowrap; } -#conn-detail span { margin-right: 4px; font-size: 10px; } - -/* Tables */ -.table-wrap { - overflow-x: auto; - max-height: 320px; - overflow-y: auto; -} -table { - width: 100%; - border-collapse: collapse; - font-size: 13px; -} -th { - text-align: left; - padding: 6px 8px; - color: var(--text-dim); - font-weight: 500; - font-size: 11px; - text-transform: uppercase; - letter-spacing: 0.5px; - position: sticky; - top: 0; - background: var(--card); - border-bottom: 1px solid var(--border); -} -td { - padding: 5px 8px; - border-bottom: 1px solid rgba(48, 54, 61, 0.5); - white-space: nowrap; -} -tr:hover td { background: rgba(88, 166, 255, 0.05); } -.trade-row { cursor: pointer; } -.loading { text-align: center; color: var(--text-dim); padding: 20px !important; } - -.text-green { color: var(--green); } -.text-red { color: var(--red); } -.text-yellow { color: var(--yellow); } -.text-dim { color: var(--text-dim); } -.text-right { text-align: right; } - -/* Scrollbar */ -::-webkit-scrollbar { width: 6px; height: 6px; } -::-webkit-scrollbar-track { background: transparent; } -::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; } -::-webkit-scrollbar-thumb:hover { background: #484f58; } - -/* Responsive */ -@media (max-width: 768px) { - .grid { grid-template-columns: 1fr; } - header { flex-direction: column; gap: 8px; } - .stats-row { justify-content: center; } -} - -/* Trade Detail Modal */ -.modal-overlay { - position: fixed; - top: 0; left: 0; right: 0; bottom: 0; - background: rgba(0,0,0,0.7); - z-index: 1000; - display: flex; - align-items: flex-start; - justify-content: center; - padding: 40px 16px; - overflow-y: auto; -} -.modal-content { - background: var(--card); - border: 1px solid var(--border); - border-radius: 12px; - max-width: 700px; - width: 100%; - box-shadow: 0 8px 32px rgba(0,0,0,0.5); -} -.modal-header { - display: flex; - justify-content: space-between; - align-items: center; - padding: 16px 20px; - border-bottom: 1px solid var(--border); -} -.modal-header h2 { font-size: 16px; margin: 0; padding: 0; border: none; color: var(--text); } -.modal-close { - background: none; - border: none; - color: var(--text-dim); - font-size: 20px; - cursor: pointer; - padding: 4px 8px; - border-radius: 4px; - line-height: 1; -} -.modal-close:hover { background: rgba(255,255,255,0.1); color: var(--text); } -#trade-detail-body { padding: 0; } -.detail-grid { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 0; -} -.detail-section { - padding: 14px 20px; - border-bottom: 1px solid rgba(48,54,61,0.4); -} -.detail-section:last-child { border-bottom: none; } -.detail-section-full { grid-column: 1 / -1; } -.detail-section h3 { - font-size: 12px; - color: var(--text-dim); - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.5px; - margin-bottom: 8px; -} -.detail-row { - display: flex; - justify-content: space-between; - padding: 3px 0; - font-size: 13px; -} -.detail-row .label { color: var(--text-dim); } -.detail-row .value { font-weight: 500; } -.detail-orders { width: 100%; font-size: 12px; } -.detail-orders th { background: var(--bg); font-size: 10px; } -.detail-orders td { padding: 4px 6px; }