From 0288dc8284ae99f867d6e6807dc4721e7a29f642 Mon Sep 17 00:00:00 2001 From: jackyu66git Date: Mon, 4 May 2026 04:34:27 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=89=8B=E7=BB=AD=E8=B4=B9=E6=94=B9?= =?UTF-8?q?=E4=B8=BA=E9=80=90=E7=AC=94USD=E7=B4=AF=E7=AE=97=20+=20Vite=20R?= =?UTF-8?q?eact=E5=89=8D=E7=AB=AF=20+=20system=5Forders=E8=A1=A8=20+=20REA?= =?UTF-8?q?DME?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 7 + README.md | 290 +++---- dashboard.go | 45 +- db/db.go | 14 + db/trade_repo.go | 30 +- frontend/index.html | 12 + frontend/package-lock.json | 1568 ++++++++++++++++++++++++++++++++++++ frontend/package.json | 19 + frontend/src/App.css | 226 ++++++ frontend/src/App.jsx | 698 ++++++++++++++++ frontend/src/main.jsx | 10 + frontend/vite.config.js | 17 + start.sh | 4 +- static.go | 6 +- trader.go | 175 +++- web/static/app.js | 242 +----- web/static/index.html | 57 +- web/static/style.css | 20 - 18 files changed, 2919 insertions(+), 521 deletions(-) create mode 100644 frontend/index.html create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/src/App.css create mode 100644 frontend/src/App.jsx create mode 100644 frontend/src/main.jsx create mode 100644 frontend/vite.config.js diff --git a/.gitignore b/.gitignore index b3e3e6a..854b3f1 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,10 @@ trade_stats.txt # Temp /tmp/ + +# Python scripts +check_db.py + +# Frontend +frontend/node_modules/ +frontend/dist/ diff --git a/README.md b/README.md index 924b48e..31a7a7f 100644 --- a/README.md +++ b/README.md @@ -1,216 +1,112 @@ -# Exchange Monitor Go +# ⚡ 跨交易所永续合约套利监控 -Cross-exchange perpetual futures arbitrage monitoring and automated trading system. Tracks **Bitget ↔ HyperLiquid** spread in real time, executes simulated trades at configurable thresholds. +Bitget ↔ HyperLiquid 跨交易所永续合约价差套利系统。支持模拟盘/实盘交易、价差监控、自动开仓/加仓/平仓、Web 仪表盘。 -## Architecture +## 功能特点 + +- **实时价差监控** — 200ms 扫描间隔,追踪 DOGE/LINK/ONDO/OP/WIF/ARB 六个币种 +- **自动套利交易** — 价差超过阈值自动开仓,收敛自动平仓,支持多级加仓 +- **模拟/实盘双模式** — `TestMode` 控制,模拟模式无需真实 API Key +- **Web 仪表盘** — Go 内置 HTTP Server + Vite React 前端,SSE 实时推送 +- **SQLite 持久化** — 交易记录、订单明细、手续费明细全量存储 +- **手续费精确计算** — 逐笔累加实际 USD 手续费(开仓费+平仓费),非百分比估算 +- **Telegram 通知** — 开仓/平仓/异常实时推送 + +## 架构 ``` - ┌──────────────┐ - │ Bitget │◄──── ticker WS (trading exchange) - └──────────────┘ -PriceStore ─────────┼──────────────┤ - │ HyperLiquid │◄──── webData2 WS (trading exchange) - └──────────────┘ - │ - ┌─────────▼─────────┐ - │ ScanBGHL (200ms) │ - │ BG ↔ HL only │ - └─────────┬─────────┘ - │ - ┌───────────────▼────────────────┐ - │ Trader │ - │ TryEntry (async goroutine) │ - │ → placeOrder (REST/mock) │ - │ Tick / Exit / Scale-in │ - │ Config-driven thresholds │ - └───────────────┬────────────────┘ - │ - ┌─────────▼─────────┐ - │ SpreadWindowTracker│ - │ (opportunity life) │ - └─────────┬─────────┘ - │ - ┌──────────────────┼──────────────────┐ - │ │ │ - ┌─────▼─────┐ ┌────────▼───────┐ ┌─────▼─────┐ - │ Notifier │ │ Dashboard │ │ DB │ - │ Telegram │ │ :8888 │ │ SQLite │ - │ │ │ Stats calc │ │ trades.db │ - │ │ │ Blacklist UI │ │ │ - └───────────┘ └────────────────┘ └───────────┘ +┌─────────────────────────────────────────────────┐ +│ scanner.go ← 每 200ms 扫描价差 │ +│ ↓ 发现机会 (NetProfit > 阈值) │ +│ trader.go ← 开仓/加仓/平仓逻辑 │ +│ ↓ 持久化 │ +│ db/ ← SQLite (trades / orders / system) │ +│ ↓ SSE 推送 │ +│ dashboard.go ← HTTP Server :8888 │ +│ ↓ │ +│ frontend/ ← Vite + React 仪表盘 │ +└─────────────────────────────────────────────────┘ ``` -## Tracked Coins +## 快速开始 -| Coin | Bitget | HyperLiquid | -|:----:|:---------:|:-----------:| -| DOGE | DOGEUSDT | DOGE | -| LINK | LINKUSDT | LINK | -| ONDO | ONDOUSDT | ONDO | -| OP | OPUSDT | OP | -| WIF | WIFUSDT | WIF | -| ARB | ARBUSDT | ARB | +### 1. 配置 -> **Note:** Binance and dYdX have been removed — only Bitget and HyperLiquid are monitored. +编辑 `config.json`: -## Requirements +```json +{ + "scan_interval_ms": 200, + "trade_enabled": true, + "test_mode": true, + "trade_threshold": 0.10, + "take_profit_pct": 0.20, + "trade_amount_usd": 5, + "max_positions": 5, + "taker_fee_bitget": 0.060, + "taker_fee_hyperliquid": 0.045, + "telegram_bot_token": "xxx", + "telegram_chat_id": "xxx" +} +``` -- Go 1.25+ -- WebSocket connectivity to Bitget and HyperLiquid - -## Quick Start +### 2. 启动 ```bash -cd exchange-monitor-go -go build -o exchange-monitor . -# Edit config.json to set parameters -./exchange-monitor +# 一键启动(自动清理旧进程 + 编译 + 运行) +bash start.sh + +# 清空数据库 + 启动 +bash start.sh --clean + +# 强制重新编译 + 启动 +bash start.sh --rebuild ``` -Then open [http://localhost:8888](http://localhost:8888) for the Web dashboard. +仪表盘地址:http://localhost:8888 -## Configuration +### 3. 前端开发 -### config.json (all trading parameters) - -All numerical parameters are defined in `config.json` — **no need to edit Go source**: - -| Parameter | Default | Description | -|:----------|:-------:|:------------| -| `test_mode` | `true` | Simulate orders with mock fills (no real API calls) | -| `trade_enabled` | `true` | Enable automated trading | -| `scan_interval_ms` | `200` | Scanner loop interval (ms) | -| `arb_threshold` | `0.03` | Min net profit % to trigger alert | -| `trade_threshold` | `0.10` | Min net profit % to execute trade | -| `trade_amount_usd` | `5` | USD per leg (per order) | -| `trade_cooldown_ms` | `30000` | Cooldown between same-coin trades (ms) | -| `max_positions` | `5` | Maximum concurrent open positions | -| `initial_capital` | `1000` | Starting capital in USD (for PnL %) | -| `mock_slippage_pct` | `0.005` | Simulated slippage per leg (%) | -| `blacklist_duration_sec` | `3600` | Coin blacklist duration (seconds) | -| `taker_fee_bitget` | `0.060` | Bitget taker fee rate (%) | -| `taker_fee_hyperliquid` | `0.045` | HyperLiquid taker fee rate (%) | -| `take_profit_pct` | `0.20` | Net profit % threshold for take-profit | -| `spread_reverse_exit_pct` | `0` | Spread convergence/reversal exit (0 = exit when ≤ 0) | -| `position_timeout_sec` | `1800` | Max position hold time before auto-close (30 min) | -| `leg_delay_ms` | `300` | Delay between placing long and short legs | -| `reversal_tolerance_pct` | `0.1` | Price movement tolerance for entry sanity check | -| `scale_step_pct` | `0.10` | Spread widening % to trigger each scale-in level | -| `scale_cooldown_sec` | `5` | Minimum seconds between scale-ins | - -### .env (secrets only) - -Secrets (API keys) go in `.env` — never checked into git: - -| Variable | Description | -|:---------|:------------| -| `TELEGRAM_BOT_TOKEN` | Telegram bot token for notifications | -| `TELEGRAM_CHAT_ID` | Target chat ID for notifications | -| `BITGET_API_KEY` | Bitget API key (skipped if test_mode) | -| `BITGET_API_SECRET` | Bitget API secret | -| `BITGET_PASSPHRASE` | Bitget passphrase | -| `HL_PRIVATE_KEY` | HyperLiquid ed25519 private key hex | -| `HL_ADDRESS` | HyperLiquid wallet address | - -> **Priority:** `.env` vars > `config.json` > code defaults. - -## Fee Model - -All trades use **taker** (market orders). Only Bitget and HyperLiquid: - -| Exchange | Taker Fee | -|:---------|:---------:| -| Bitget | configurable (`taker_fee_bitget`, default 0.060%) | -| HyperLiquid | configurable (`taker_fee_hyperliquid`, default 0.045%) | - -Round trip (2 legs entry + 2 legs exit): configurable, default **0.21%** total fees. - -## Trading Logic - -1. **Scanner** runs every `scan_interval_ms`, checks all coins for BG ↔ HL spread -2. **Entry** when net profit ≥ `trade_threshold` (after full round-trip fees) - - Uses scan-time prices directly (no re-read to avoid WS jitter) - - **Async goroutine** — `TryEntry` returns immediately, `executeEntry` runs in background - - Reversal tolerance check prevents entry on flipped spreads - - `entering` map prevents duplicate entries on same coin -3. **Scale-in** adds another leg-worth when spread widens another `scale_step_pct` (default 0.10%) -4. **Exit** conditions (whichever hits first): - - **Net profit ≥ `take_profit_pct`** → **利润止盈**(大盈利退出) - - **Spread narrowed to ≤ 0.02% + netPnl > 0** → **价差收敛止盈**(小盈利退出) - - **Spread flipped negative** → **价差反转平仓**(紧急止损) - - **Position held > `position_timeout_sec`** → **超时平仓** -5. **Direction**: BG → HL (buy BG, sell HL) or HL → BG (buy HL, sell BG) - -## Blacklist Mechanism - -- Positions held open for > 10 minutes without converging are auto-closed and blacklisted -- Blacklisted coins are skipped for `blacklist_duration_sec` (default 1 hour) -- Blacklist state visible on the dashboard - -## Spread Window Monitoring - -`SpreadWindowTracker` runs every scan tick and measures how long each coin's spread stays above the trade threshold: - -- Records window **start time** when net profit first hits threshold -- Tracks real **peak net profit** during the window -- Logs window **duration + peak** when spread converges below threshold -- Covers both directions (BG→HL and HL→BG) independently - -## Web Dashboard - -Built-in HTTP server at `:8888` with real-time SSE push (1-second refresh): - -- **Price table** — live prices from both exchanges -- **BG↔HL spread** — per-coin arbitrage spread -- **Open positions** — live PnL estimate, scaling level, duration, sorting by coin -- **Trade history** — past trades with detail view -- **Blacklist** — currently blacklisted coins and remaining time -- **Connection status** — exchange health (online / stale / offline) - -## DB & Persistence - -- SQLite at current directory (auto-deleted on each restart in test mode) -- Tracks open positions across restarts (`restoreOpenPositions`) -- Stores all closed trades with full PnL details - -## Signals - -| Signal | Action | -|:-------|:-------| -| `Ctrl+C` / `SIGINT` | Graceful shutdown (closes all WS connections) | -| `SIGUSR1` | Dump convergence statistics to `trade_stats.txt` | - -## Project Structure - -``` -exchange-monitor-go/ -├── main.go # Entry point, WS startup, main loop -├── config.go # config.json + .env hierarchical config -├── config.json # All trading parameters (editable) -├── types.go # PriceStore, TrackedCoin, ArbOpportunity -├── scanner.go # ScanBGHL — arbitrage scanner -├── trader.go # Position management, entry/exit/scale-in -├── dashboard.go # Web server + SSE + stats calc -├── toaster.go # Telegram notifications -├── static.go # Embedded web static files -├── .env # Secrets only (API keys) -├── exchange/ -│ ├── connector.go # Generic WS connector with reconnect -│ ├── hyperliquid.go # HyperLiquid webData2 WS -│ ├── hyperliquid_trade.go # HL REST trade API -│ ├── bitget.go # Bitget ticker WS (TextPing for stability) -│ ├── bitget_trade.go # Bitget REST trade API -│ ├── helpers.go # Package helpers -│ └── ping.go # Accessibility check tools -├── db/ -│ ├── db.go # SQLite open/migrate -│ └── trade_repo.go # Trade record queries -└── web/static/ - ├── index.html # Dashboard HTML - ├── app.js # SSE client + UI logic - └── style.css # Dashboard CSS +```bash +cd frontend +npm install +npm run dev # 开发模式 (Vite HMR :5173) +npm run build # 构建生产版本 ``` -## Disclaimer +后端优先从 `frontend/dist/` 读取静态文件(热加载),回退到 Go embed。 -This software is for educational/research purposes. Use at your own risk. Cryptocurrency trading involves substantial risk of loss. +## 配置参数 + +| 参数 | 说明 | 默认 | +|------|------|------| +| `scan_interval_ms` | 扫描间隔 (ms) | 200 | +| `trade_threshold` | 开仓阈值 (%) | 0.10 | +| `take_profit_pct` | 止盈净利 (%) | 0.20 | +| `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 | + +## 数据库 + +SQLite (`data/trades.db`),三张核心表: + +| 表 | 说明 | +|----|------| +| `trades` | 交易主表 — 价差、PnL、手续费 ($) | +| `orders` | 订单明细 — 每腿的开仓/加仓/平仓、手续费 ($) | +| `system_orders` | 系统订单 — 双向关联 long↔short 订单 | + +## 版本历史 + +### v1.2 (当前) +- ✨ `system_orders` 表,记录系统级开仓/加仓/平仓 +- ✨ 手续费改为逐笔累加 USD,不再用百分比估算 +- ✨ Vite + React 前端,支持热加载 +- ✨ Web 仪表盘持仓 PnL 美元化显示 +- 🐛 修复 `persistTrade` 费用在 `SaveTrade` 后才累加导致 fee=0 的 bug +- 🗑 移除老版 Chart.js 图表 diff --git a/dashboard.go b/dashboard.go index bfc8ce6..321deaf 100644 --- a/dashboard.go +++ b/dashboard.go @@ -7,6 +7,7 @@ import ( "log" "math" "net/http" + "os" "sync" "time" @@ -217,8 +218,15 @@ func (d *Dashboard) Run() { mux := http.NewServeMux() - staticSub, err := fs.Sub(staticFS, "web/static") - if err != nil { + // Try disk-based serving first (hot-reload friendly), fall back to embed + staticSub, err := fs.Sub(staticFS, "frontend/dist") + 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 { mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticSub)))) @@ -379,19 +387,21 @@ func (d *Dashboard) broadcastLoop() { 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 - totalFees := 2 * (takerFees[ExBitget] + takerFees[ExHyperLiquid]) - netPnl := longPnl + shortPnl - totalFees + 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 { + 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(netPnl*10000) / 10000 + posEntry["pnl_est"] = math.Round(netPnLUSD*10000) / 10000 } } @@ -519,7 +529,15 @@ func (d *Dashboard) BroadcastEvent(event string, data interface{}) { // ============================================================ func (d *Dashboard) handleIndex(w http.ResponseWriter, r *http.Request) { - data, err := staticFS.ReadFile("web/static/index.html") + var data []byte + var err error + + // 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 { http.Error(w, "Not found", 404) return @@ -602,6 +620,11 @@ func (d *Dashboard) handleTrades(w http.ResponseWriter, r *http.Request) { 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 + } + } trades, total, err := d.db.GetTrades(page, limit, coin) if err != nil { http.Error(w, err.Error(), 500) diff --git a/db/db.go b/db/db.go index 9770e90..0262f4e 100644 --- a/db/db.go +++ b/db/db.go @@ -94,6 +94,20 @@ func (d *DB) migrate() error { CREATE INDEX IF NOT EXISTS idx_trades_status ON trades(status); CREATE INDEX IF NOT EXISTS idx_trades_opened ON trades(opened_at); CREATE INDEX IF NOT EXISTS idx_orders_trade_id ON orders(trade_id); + + CREATE TABLE IF NOT EXISTS system_orders ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + trade_id INTEGER NOT NULL REFERENCES trades(id), + type TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'filled', + spread REAL, + long_price REAL, + short_price REAL, + long_order_id INTEGER REFERENCES orders(id), + short_order_id INTEGER REFERENCES orders(id), + created_at DATETIME NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_system_orders_trade ON system_orders(trade_id); ` _, err := d.Exec(schema) if err != nil { diff --git a/db/trade_repo.go b/db/trade_repo.go index 3b10f7e..fa8751f 100644 --- a/db/trade_repo.go +++ b/db/trade_repo.go @@ -48,6 +48,20 @@ type OrderRecord struct { 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 ( @@ -150,6 +164,20 @@ func (d *DB) SaveOrder(o *OrderRecord) (int64, error) { 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, @@ -222,4 +250,4 @@ func (d *DB) GetClosedStats() (converged, diverged, flat, total int, err error) return } return -} +} \ No newline at end of file diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..5c9e730 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + Exchange Monitor Dashboard + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..62c6b35 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,1568 @@ +{ + "name": "exchange-monitor-frontend", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "exchange-monitor-frontend", + "version": "1.0.0", + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@vitejs/plugin-react": "^4.2.1", + "vite": "^5.4.2" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.3.tgz", + "integrity": "sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz", + "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==", + "dev": true, + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.2.tgz", + "integrity": "sha512-dnlp69efPPg6Uaw2dVqzWRfAWRnYVb1XJ8CyyhIbZeaq4CA5/mLeZ1IEt9QqQxmbdvagjLIm2ZL8BxXv5lH4Yw==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.2.tgz", + "integrity": "sha512-OqZTwDRDchGRHHm/hwLOL7uVPB9aUvI0am/eQuWMNyFHf5PSEQmyEeYYheA0EPPKUO/l0uigCp+iaTjoLjVoHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.2.tgz", + "integrity": "sha512-UwRE7CGpvSVEQS8gUMBe1uADWjNnVgP3Iusyda1nSRwNDCsRjnGc7w6El6WLQsXmZTbLZx9cecegumcitNfpmA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.2.tgz", + "integrity": "sha512-gjEtURKLCC5VXm1I+2i1u9OhxFsKAQJKTVB8WvDAHF+oZlq0GTVFOlTlO1q3AlCTE/DF32c16ESvfgqR7343/g==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.2.tgz", + "integrity": "sha512-Bcl6CYDeAgE70cqZaMojOi/eK63h5Me97ZqAQoh77VPjMysA/4ORQBRGo3rRy45x4MzVlU9uZxs8Uwy7ZaKnBw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.2.tgz", + "integrity": "sha512-LU+TPda3mAE2QB0/Hp5VyeKJivpC6+tlOXd1VMoXV/YFMvk/MNk5iXeBfB4MQGRWyOYVJ01625vjkr0Az98OJQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.2.tgz", + "integrity": "sha512-2QxQrM+KQ7DAW4o22j+XZ6RKdxjLD7BOWTP0Bv0tmjdyhXSsr2Ul1oJDQqh9Zf5qOwTuTc7Ek83mOFaKnodPjg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.2.tgz", + "integrity": "sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.2.tgz", + "integrity": "sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.2.tgz", + "integrity": "sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.2.tgz", + "integrity": "sha512-pOjB/uSIyDt+ow3k/RcLvUAOGpysT2phDn7TTUB3n75SlIgZzM6NKAqlErPhoFU+npgY3/n+2HYIQVbF70P9/A==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.2.tgz", + "integrity": "sha512-2/w+q8jszv9Ww1c+6uJT3OwqhdmGP2/4T17cu8WuwyUuuaCDDJ2ojdyYwZzCxx0GcsZBhzi3HmH+J5pZNXnd+Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.2.tgz", + "integrity": "sha512-11+aL5vKheYgczxtPVVRhdptAM2H7fcDR5Gw4/bTcteuZBlH4oP9f5s9zYO9aGZvoGeBpqXI/9TZZihZ609wKw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.2.tgz", + "integrity": "sha512-i16fokAGK46IVZuV8LIIwMdtqhin9hfYkCh8pf8iC3QU3LpwL+1FSFGej+O7l3E/AoknL6Dclh2oTdnRMpTzFQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.2.tgz", + "integrity": "sha512-49FkKS6RGQoriDSK/6E2GkAsAuU5kETFCh7pG4yD/ylj9rKhTmO3elsnmBvRD4PgJPds5W2PkhC82aVwmUcJ7A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.2.tgz", + "integrity": "sha512-mjYNkHPfGpUR00DuM1ZZIgs64Hpf4bWcz9Z41+4Q+pgDx73UwWdAYyf6EG/lRFldmdHHzgrYyge5akFUW0D3mQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.2.tgz", + "integrity": "sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.2.tgz", + "integrity": "sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.2.tgz", + "integrity": "sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.2.tgz", + "integrity": "sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.2.tgz", + "integrity": "sha512-NetAg5iO2uN7eB8zE5qrZ3CSil+7IJt4WDFLcC75Ymywq1VZVD6qJ6EvNLjZ3rEm6gB7XW5JdT60c6MN35Z85Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.2.tgz", + "integrity": "sha512-NCYhOotpgWZ5kdxCZsv6Iudx0wX8980Q/oW4pNFNihpBKsDbEA1zpkfxJGC0yugsUuyDZ7gL37dbzwhR0VI7pQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.2.tgz", + "integrity": "sha512-RXsaOqXxfoUBQoOgvmmijVxJnW2IGB0eoMO7F8FAjaj0UTywUO/luSqimWBJn04WNgUkeNhh7fs7pESXajWmkg==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.2.tgz", + "integrity": "sha512-qdAzEULD+/hzObedtmV6iBpdL5TIbKVztGiK7O3/KYSf+HIzU257+MX1EXJcyIiDbMAqmbwaufcYPvyRryeZtA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.2.tgz", + "integrity": "sha512-Nd/SgG27WoA9e+/TdK74KnHz852TLa94ovOYySo/yMPuTmpckK/jIF2jSwS3g7ELSKXK13/cVdmg1Z/DaCWKxA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.27", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.27.tgz", + "integrity": "sha512-zEs/ufmZoUd7WftKpKyXaT6RFxpQ5Qm9xytKRHvJfxFV9DFJkZph9RvJ1LcOUi0Z1ZVijMte65JbILeV+8QQEA==", + "dev": true, + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001791", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001791.tgz", + "integrity": "sha512-yk0l/YSrOnFZk3UROpDLQD9+kC1l4meK/wed583AXrzoarMGJcbRi2Q4RaUYbKxYAsZ8sWmaSa/DsLmdBeI1vQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ] + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.349", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.349.tgz", + "integrity": "sha512-QsWVGyRuY07Aqb234QytTfwd5d9AJlfNIQ5wIOl1L+PZDzI9d9+Fn0FRale/QYlFxt/bUnB0/nLd1jFPGxGK1A==", + "dev": true + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.38", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.38.tgz", + "integrity": "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==", + "dev": true + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true + }, + "node_modules/postcss": { + "version": "8.5.13", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.13.tgz", + "integrity": "sha512-qif0+jGGZoLWdHey3UFHHWP0H7Gbmsk8T5VEqyYFbWqPr1XqvLGBbk/sl8V5exGmcYJklJOhOQq1pV9IcsiFag==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.2.tgz", + "integrity": "sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ==", + "dev": true, + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.2", + "@rollup/rollup-android-arm64": "4.60.2", + "@rollup/rollup-darwin-arm64": "4.60.2", + "@rollup/rollup-darwin-x64": "4.60.2", + "@rollup/rollup-freebsd-arm64": "4.60.2", + "@rollup/rollup-freebsd-x64": "4.60.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.2", + "@rollup/rollup-linux-arm-musleabihf": "4.60.2", + "@rollup/rollup-linux-arm64-gnu": "4.60.2", + "@rollup/rollup-linux-arm64-musl": "4.60.2", + "@rollup/rollup-linux-loong64-gnu": "4.60.2", + "@rollup/rollup-linux-loong64-musl": "4.60.2", + "@rollup/rollup-linux-ppc64-gnu": "4.60.2", + "@rollup/rollup-linux-ppc64-musl": "4.60.2", + "@rollup/rollup-linux-riscv64-gnu": "4.60.2", + "@rollup/rollup-linux-riscv64-musl": "4.60.2", + "@rollup/rollup-linux-s390x-gnu": "4.60.2", + "@rollup/rollup-linux-x64-gnu": "4.60.2", + "@rollup/rollup-linux-x64-musl": "4.60.2", + "@rollup/rollup-openbsd-x64": "4.60.2", + "@rollup/rollup-openharmony-arm64": "4.60.2", + "@rollup/rollup-win32-arm64-msvc": "4.60.2", + "@rollup/rollup-win32-ia32-msvc": "4.60.2", + "@rollup/rollup-win32-x64-gnu": "4.60.2", + "@rollup/rollup-win32-x64-msvc": "4.60.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..12f1399 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,19 @@ +{ + "name": "exchange-monitor-frontend", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@vitejs/plugin-react": "^4.2.1", + "vite": "^5.4.2" + } +} diff --git a/frontend/src/App.css b/frontend/src/App.css new file mode 100644 index 0000000..05a47ac --- /dev/null +++ b/frontend/src/App.css @@ -0,0 +1,226 @@ +: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-detail { font-size: 11px; white-space: nowrap; } + +/* 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; } +} + +/* 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 { + 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; } diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx new file mode 100644 index 0000000..3227e7a --- /dev/null +++ b/frontend/src/App.jsx @@ -0,0 +1,698 @@ +import { useState, useEffect, useRef, useCallback } from 'react' + +const EXCHANGES = ['HyperLiquid', 'Bitget'] + +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 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 [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 priceCacheRef = useRef({}) + + // Clock + useEffect(() => { + const tick = () => setClock(new Date().toLocaleTimeString('zh-CN', { hour12: false })) + tick() + const id = setInterval(tick, 1000) + return () => clearInterval(id) + }, []) + + // SSE + useEffect(() => { + let es = new EventSource('/events') + + es.addEventListener('connected', () => { + setConnStatus('● 已连接') + setConnOnline(true) + }) + + es.onerror = () => { + setConnStatus('● 已断开 (重连中...)') + setConnOnline(false) + setTimeout(() => { + es = new EventSource('/events') + }, 3000) + } + + es.onmessage = (e) => { + try { + const msg = JSON.parse(e.data) + switch (msg.event) { + case 'prices': + handlePrices(msg.data) + break + case 'arb': + setOpps(msg.data || []) + break + case 'positions': + setPositions(msg.data || []) + break + case 'blacklist': + setBlacklist(msg.data || []) + break + case 'stats': + setStats(msg.data || {}) + if (msg.data && msg.data.blacklist) { + setBlacklist(msg.data.blacklist) + } + break + case 'trade_close': + loadTrades() + break + } + } catch (err) { + // ignore + } + } + + 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]) + + // 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) { + const key = row.coin + '.' + ex + const p = row[ex] || 0 + if (cache[key]) { + cache[key].last = p + } else { + cache[key] = { last: p } + } + } + } + } + + // ---- Render helpers ---- + function getCoinList() { + const seen = new Set() + const coins = [] + if (!prices) return coins + for (const row of prices) { + if (!seen.has(row.coin)) { + seen.add(row.coin) + coins.push(row.coin) + } + } + return coins + } + + function getPrevPrice(coin, ex) { + return priceCacheRef.current[coin + '.' + ex]?.last + } + + function priceClass(last, cur) { + if (last == null || cur == null) return '' + return cur > last ? 'text-green' : cur < last ? 'text-red' : '' + } + + const coins = getCoinList() + + return ( +
+
+

⚡ 跨交易所套利监控

+
+ {clock} + | + {connStatus} +
+
+ +
+ {/* Stats Summary */} + + + {/* Open Positions */} + + + {/* PnL Growth Chart */} + + + {/* Price Table */} + + + {/* Arbitrage Opportunities */} + + + {/* Recent Trades */} + + + {/* Blacklist */} + +
+
+ ) +} + +// ============ 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(' ') + } + return ( +
+

📊 统计数据

+
+
{stats.total_trades || 0}
+
{stats.converged || 0}
+
{stats.diverged || 0}
+
{stats.flat || 0}
+
{stats.open_positions || 0} / 5
+
{stats.coins || 0}
+
{connHtml}
+
+ {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 }) { + return ( +
+

🔒 当前持仓

+
+ + + + + + {positions.length === 0 ? ( + + ) : ( + [...positions].sort((a, b) => a.coin.localeCompare(b.coin)).map(p => ( + + + + + + + + + + + )) + )} + +
币种方向规模入价差现价差估盈亏加仓时长
无持仓
{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 || '-'}
+
+
+ ) +} + +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 ( +
+

💰 实时价格 {pricesAge}

+
+ + + + + + {coins.length === 0 ? ( + + ) : coins.map(coin => { + const row = prices.find(p => p.coin === coin) + if (!row) { + return + } + const cells = EXCHANGES.map(ex => { + const p = row[ex] + const prev = getPrevPrice(coin, ex) + 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' : '' + return ( + + + {cells} + + + ) + })} + +
币种HyperLiquidBitgetBG↔HL价差
等待数据...
{coin}---
{formatPrice(p)}
{coin}{spread != null ? spread.toFixed(4) + '%' : '-'}
+
+
+ ) +} + +function ArbTable({ opps }) { + return ( +
+

🎯 套利机会 (BG↔HL)

+
+ + + + + + {!opps || opps.length === 0 ? ( + + ) : opps.slice(0, 10).map((opp, i) => { + 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 || 0).toFixed(4)}
+
+
+ ) +} + +function TradesCard({ trades, onRefresh }) { + const [modalTrade, setModalTrade] = useState(null) + const [modalOrders, setModalOrders] = useState([]) + const [modalOpen, setModalOpen] = useState(false) + + 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 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 ( + <> +
+

📋 历史交易

+
+ + + + + + {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' + return ( + openTradeDetail(t.ID)}> + + + + + + + + + + ) + })} + +
时间币种方向入价差出价差净利%结果原因
暂无交易记录
{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 || '-'}
+
+
+ + {/* Trade Detail Modal */} + {modalOpen && ( + + )} + + ) +} + +function TradeDetailModal({ trade, orders, onClose }) { + function handleOverlayClick(e) { + if (e.target === e.currentTarget) onClose() + } + + if (!trade) { + return ( +
+
+
+

📋 交易详情

+ +
+
+
加载中...
+
+
+
+ ) + } + + 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' : '' + + 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) + '%' : '-'}
+
+
+

空仓 {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) + '%' : '-'}
+
+
+

净收益

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

订单明细 ({orders.length})

+ + + + + + {orders.map((o, i) => ( + + + + + + + + + + ))} + +
类型方向交易所价格数量手续费订单ID
{o.Type === 'entry' ? '开仓' : o.Type === 'exit' ? '平仓' : o.Type === 'scale' ? '加仓' : o.Type}{o.Side === 'buy' ? '买' : '卖'}{o.Exchange}${(o.Price || 0).toFixed(6)}{o.Size || '-'}{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 + (t.NetPnl || 0), 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 + for (const t of data) { + cum += (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 < 2 ? ( +
暂无数据...
+ ) : ( + + )} +
+
+ ) +} diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx new file mode 100644 index 0000000..9f86de4 --- /dev/null +++ b/frontend/src/main.jsx @@ -0,0 +1,10 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import App from './App' +import './App.css' + +ReactDOM.createRoot(document.getElementById('root')).render( + + + , +) diff --git a/frontend/vite.config.js b/frontend/vite.config.js new file mode 100644 index 0000000..f10b4f0 --- /dev/null +++ b/frontend/vite.config.js @@ -0,0 +1,17 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +export default defineConfig({ + plugins: [react()], + base: '/static/', + server: { + port: 5173, + proxy: { + '/api': { target: 'http://localhost:8888', changeOrigin: true }, + '/events': { target: 'http://localhost:8888', changeOrigin: true }, + }, + }, + build: { + outDir: 'dist', + }, +}) diff --git a/start.sh b/start.sh index e1ddf26..f0b08e2 100755 --- a/start.sh +++ b/start.sh @@ -23,8 +23,8 @@ for arg in "$@"; do esac done -# 清理旧进程(通过进程名而非端口,更可靠) -OLD_PIDS=$(pgrep -f exchange-monitor 2>/dev/null || true) +# 清理旧进程(pkill 按进程名匹配,不会匹配到 start.sh 自身) +OLD_PIDS=$(pgrep exchange-monitor 2>/dev/null || true) if [ -n "$OLD_PIDS" ]; then echo "[start] 停止旧进程 PID=$OLD_PIDS..." kill $OLD_PIDS 2>/dev/null || true diff --git a/static.go b/static.go index e31748b..0e1fd3c 100644 --- a/static.go +++ b/static.go @@ -1,6 +1,8 @@ package main -import "embed" +import ( + "embed" +) -//go:embed web/static/index.html web/static/app.js web/static/style.css +//go:embed frontend/dist var staticFS embed.FS diff --git a/trader.go b/trader.go index 2fb11ee..47789d0 100644 --- a/trader.go +++ b/trader.go @@ -520,7 +520,7 @@ func (t *Trader) checkExit(pos *ArbPosition, bgP, hlP, diffPct float64, notifier longPnl := (longCurrent - longAvg) / longAvg * 100 shortPnl := (shortAvg - shortCurrent) / shortAvg * 100 - totalFees := 2 * (takerFees[ExBitget] + takerFees[ExHyperLiquid]) // 开仓 + 平仓手续费 + totalFees := float64(2+pos.ScaleLevels) * (takerFees[ExBitget] + takerFees[ExHyperLiquid]) // 开仓(含加仓) + 平仓手续费 netPnl := longPnl + shortPnl - totalFees elapsed := time.Since(pos.StartedAt) @@ -905,15 +905,35 @@ func (t *Trader) GetClosedTrades() []TradeRecord { return r } -// persistTrade saves a completed trade to SQLite. +// persistTrade saves a completed trade to SQLite, with per-leg orders and system_orders. func (t *Trader) persistTrade(pos *ArbPosition, exitSpread float64, convergence, exitReason string, netPnl, longPnl, shortPnl, totalFees float64) { - var entrySpread, fe float64 + var entrySpread float64 if pos.LongLeg != nil { entrySpread = pos.EntrySpread } - fe = totalFees / 2 // split into entry/exit halves now := time.Now() + tradeUnit := t.cfg.TradeAmountUSD + + // Pre-calculate all fees BEFORE saving the trade + totalFeeEntryUSD := 0.0 + for range pos.LongEntryPrices { + totalFeeEntryUSD += tradeUnit * takerFees[pos.LongLeg.Exchange] / 100 + } + for range pos.ShortEntryPrices { + totalFeeEntryUSD += tradeUnit * takerFees[pos.ShortLeg.Exchange] / 100 + } + totalLongShares := 0.0 + for _, p := range pos.LongEntryPrices { + totalLongShares += tradeUnit / p + } + totalShortShares := 0.0 + for _, p := range pos.ShortEntryPrices { + totalShortShares += tradeUnit / p + } + totalFeeExitUSD := totalLongShares*pos.LongLeg.ExitPrice*takerFees[pos.LongLeg.Exchange]/100 + + totalShortShares*pos.ShortLeg.ExitPrice*takerFees[pos.ShortLeg.Exchange]/100 + dbTrade := &db.TradeRecord{ Coin: pos.Coin, Direction: pos.Direction, @@ -928,8 +948,8 @@ func (t *Trader) persistTrade(pos *ArbPosition, exitSpread float64, convergence, ShortExit: &pos.ShortLeg.ExitPrice, LongPnl: &longPnl, ShortPnl: &shortPnl, - FeeEntry: &fe, - FeeExit: &fe, + FeeEntry: &totalFeeEntryUSD, + FeeExit: &totalFeeExitUSD, NetPnl: &netPnl, AmountUSD: pos.AmountUSD, ScaleCount: pos.ScaleLevels, @@ -938,8 +958,145 @@ func (t *Trader) persistTrade(pos *ArbPosition, exitSpread float64, convergence, OpenedAt: pos.StartedAt, ClosedAt: &now, } - if _, err := t.db.SaveTrade(dbTrade); err != nil { + tradeID, err := t.db.SaveTrade(dbTrade) + if err != nil { log.Printf("[Trader] Failed to save trade to DB: %v", err) + return + } + + // Save per-leg order records + // Long leg: entry (buy), scales (buy), exit (sell) + status := "filled" + var longEntryOrderIDs []int64 + for i, p := range pos.LongEntryPrices { + shares := tradeUnit / p + orderType := "entry" + if i > 0 { + orderType = "scale" + } + fee := tradeUnit * takerFees[pos.LongLeg.Exchange] / 100 + oid, oErr := t.db.SaveOrder(&db.OrderRecord{ + TradeID: tradeID, + Leg: "long", + Type: orderType, + Exchange: pos.LongLeg.Exchange, + Side: "buy", + Price: &p, + Size: &shares, + Fee: &fee, + Status: &status, + CreatedAt: pos.StartedAt, + }) + if oErr != nil { + log.Printf("[Trader] Failed to save long entry order: %v", oErr) + } else { + longEntryOrderIDs = append(longEntryOrderIDs, oid) + } + } + // Long exit (sell) + longExitShares := totalLongShares + longExitFee := totalLongShares * pos.LongLeg.ExitPrice * takerFees[pos.LongLeg.Exchange] / 100 + status = "filled" + var longExitOrderID int64 + if oid, oErr := t.db.SaveOrder(&db.OrderRecord{ + TradeID: tradeID, + Leg: "long", + Type: "exit", + Exchange: pos.LongLeg.Exchange, + Side: "sell", + Price: &pos.LongLeg.ExitPrice, + Size: &longExitShares, + Fee: &longExitFee, + Status: &status, + CreatedAt: now, + }); oErr != nil { + log.Printf("[Trader] Failed to save long exit order: %v", oErr) + } else { + longExitOrderID = oid + } + + // Short leg: entry (sell), scales (sell), exit (buy) + var shortEntryOrderIDs []int64 + for i, p := range pos.ShortEntryPrices { + shares := tradeUnit / p + orderType := "entry" + if i > 0 { + orderType = "scale" + } + fee := tradeUnit * takerFees[pos.ShortLeg.Exchange] / 100 + oid, oErr := t.db.SaveOrder(&db.OrderRecord{ + TradeID: tradeID, + Leg: "short", + Type: orderType, + Exchange: pos.ShortLeg.Exchange, + Side: "sell", + Price: &p, + Size: &shares, + Fee: &fee, + Status: &status, + CreatedAt: pos.StartedAt, + }) + if oErr != nil { + log.Printf("[Trader] Failed to save short entry order: %v", oErr) + } else { + shortEntryOrderIDs = append(shortEntryOrderIDs, oid) + } + } + // Short exit (buy) + shortExitShares := totalShortShares + shortExitFee := totalShortShares * pos.ShortLeg.ExitPrice * takerFees[pos.ShortLeg.Exchange] / 100 + var shortExitOrderID int64 + if oid, oErr := t.db.SaveOrder(&db.OrderRecord{ + TradeID: tradeID, + Leg: "short", + Type: "exit", + Exchange: pos.ShortLeg.Exchange, + Side: "buy", + Price: &pos.ShortLeg.ExitPrice, + Size: &shortExitShares, + Fee: &shortExitFee, + Status: &status, + CreatedAt: now, + }); oErr != nil { + log.Printf("[Trader] Failed to save short exit order: %v", oErr) + } else { + shortExitOrderID = oid + } + + // Save system orders linking long+short legs + es := pos.EntrySpread + for i := 0; i < len(longEntryOrderIDs) && i < len(shortEntryOrderIDs); i++ { + sysType := "entry" + if i > 0 { + sysType = "scale" + } + if _, sErr := t.db.SaveSystemOrder(&db.SystemOrderRecord{ + TradeID: tradeID, + Type: sysType, + Status: "filled", + Spread: &es, + LongPrice: &pos.LongEntryPrices[i], + ShortPrice: &pos.ShortEntryPrices[i], + LongOrderID: &longEntryOrderIDs[i], + ShortOrderID: &shortEntryOrderIDs[i], + CreatedAt: pos.StartedAt, + }); sErr != nil { + log.Printf("[Trader] Failed to save entry system order: %v", sErr) + } + } + // Exit system order + if _, sErr := t.db.SaveSystemOrder(&db.SystemOrderRecord{ + TradeID: tradeID, + Type: "exit", + Status: "filled", + Spread: &exitSpread, + LongPrice: &pos.LongLeg.ExitPrice, + ShortPrice: &pos.ShortLeg.ExitPrice, + LongOrderID: &longExitOrderID, + ShortOrderID: &shortExitOrderID, + CreatedAt: now, + }); sErr != nil { + log.Printf("[Trader] Failed to save exit system order: %v", sErr) } } @@ -1004,7 +1161,7 @@ func (t *Trader) blacklistCoin(pos *ArbPosition, bgP, hlP, diffPct float64, noti shortAvg := weightedAvgPrice(pos.ShortEntryPrices, t.cfg.TradeAmountUSD) longPnl := (longCurrent - longAvg) / longAvg * 100 shortPnl := (shortAvg - shortCurrent) / shortAvg * 100 - totalFees := 2 * (takerFees[ExBitget] + takerFees[ExHyperLiquid]) + totalFees := float64(2+pos.ScaleLevels) * (takerFees[ExBitget] + takerFees[ExHyperLiquid]) netPnl := longPnl + shortPnl - totalFees pos.ExitDiffPct = diffPct @@ -1074,4 +1231,4 @@ func (t *Trader) RemoveBlacklist(coin string) { defer t.mu.Unlock() delete(t.blacklist, coin) log.Printf("[Trader] ✅ %s: Removed from blacklist", coin) -} +} \ No newline at end of file diff --git a/web/static/app.js b/web/static/app.js index 5ba2b3e..cf9f6fd 100644 --- a/web/static/app.js +++ b/web/static/app.js @@ -23,11 +23,6 @@ const els = { statFlat: $('stat-flat'), statPos: $('stat-positions'), statCoins: $('stat-coins'), - chartCoin: $('chart-coin'), - chartExch: $('chart-exchange'), - chartCanvas: $('priceChart'), - spreadCoin: $('spread-coin'), - spreadCanvas: $('spreadChart'), }; // ---- Clock ---- @@ -58,7 +53,6 @@ function pnlClass(val) { return val > 0 ? 'text-green' : val < 0 ? 'text-red' : ''; } -// ---- Price cache for chart data ---- const priceCache = {}; // ---- SSE Connection ---- @@ -125,15 +119,7 @@ eventHandlers.prices = (prices) => { if (prev) { prev.last = curP; } else { - priceCache[key] = { last: curP, points: [] }; - } - - if (p > 0) { - if (!priceCache[key]) priceCache[key] = { last: p, points: [] }; - priceCache[key].points.push({ t: Date.now(), p: p }); - if (priceCache[key].points.length > 500) { - priceCache[key].points = priceCache[key].points.slice(-500); - } + priceCache[key] = { last: curP }; } let display = formatPrice(p); @@ -150,8 +136,6 @@ eventHandlers.prices = (prices) => { els.priceBody.innerHTML = html; els.pricesAge.textContent = new Date().toLocaleTimeString('zh-CN', { hour12: false }); - - updateChartSelectors(prices); }; eventHandlers.arb = (opps) => { @@ -268,226 +252,6 @@ eventHandlers.trade_close = (trade) => { setTimeout(loadTrades, 500); }; -// ---- Price Chart ---- -let priceChart = null; - -function initPriceChart() { - const ctx = els.chartCanvas.getContext('2d'); - priceChart = new Chart(ctx, { - type: 'line', - data: { datasets: [{ - label: 'Price', - data: [], - borderColor: '#58a6ff', - backgroundColor: 'rgba(88, 166, 255, 0.1)', - borderWidth: 2, - pointRadius: 0, - fill: true, - tension: 0.2, - }] }, - options: { - responsive: true, - maintainAspectRatio: false, - animation: { duration: 0 }, - plugins: { - legend: { display: false }, - tooltip: { - mode: 'index', intersect: false, - callbacks: { - title: (items) => items.length ? new Date(items[0].parsed.x).toLocaleTimeString('zh-CN', { hour12: false }) : '', - label: (item) => item.parsed.y.toFixed(4), - }, - }, - }, - scales: { - x: { - type: 'linear', - ticks: { - color: '#8b949e', maxTicksLimit: 10, - callback: (v) => new Date(v).toLocaleTimeString('zh-CN', { hour12: false, hour: '2-digit', minute: '2-digit' }), - }, - grid: { color: 'rgba(48,54,61,0.5)' }, - }, - y: { - ticks: { color: '#8b949e', callback: (v) => v.toFixed(4) }, - grid: { color: 'rgba(48,54,61,0.3)' }, - }, - }, - }, - }); -} - -// ---- P3-2: Spread Chart ---- -let spreadChart = null; - -function initSpreadChart() { - const ctx = els.spreadCanvas.getContext('2d'); - spreadChart = new Chart(ctx, { - type: 'line', - data: { datasets: [{ - label: 'BG↔HL Spread %', - data: [], - borderColor: '#d29922', - backgroundColor: 'rgba(210, 153, 34, 0.1)', - borderWidth: 2, - pointRadius: 0, - fill: true, - tension: 0.2, - }] }, - options: { - responsive: true, - maintainAspectRatio: false, - animation: { duration: 0 }, - plugins: { - legend: { display: false }, - tooltip: { - mode: 'index', intersect: false, - callbacks: { - title: (items) => items.length ? new Date(items[0].parsed.x).toLocaleTimeString('zh-CN', { hour12: false }) : '', - label: (item) => item.parsed.y.toFixed(4) + '%', - }, - }, - }, - scales: { - x: { - type: 'linear', - ticks: { - color: '#8b949e', maxTicksLimit: 10, - callback: (v) => new Date(v).toLocaleTimeString('zh-CN', { hour12: false, hour: '2-digit', minute: '2-digit' }), - }, - grid: { color: 'rgba(48,54,61,0.5)' }, - }, - y: { - ticks: { color: '#8b949e', callback: (v) => v.toFixed(3) + '%' }, - grid: { color: 'rgba(48,54,61,0.3)' }, - }, - }, - }, - }); -} - -// ---- Chart Selectors ---- -function updateChartSelectors(prices) { - const coinSel = els.chartCoin; - const exSel = els.chartExch; - const spreadSel = els.spreadCoin; - - // Price chart coin selector - if (coinSel.options.length <= 1) { - const cur = coinSel.value; - coinSel.innerHTML = ''; - for (const row of prices) { - const opt = document.createElement('option'); - opt.value = row.coin; opt.textContent = row.coin; - coinSel.appendChild(opt); - } - if (cur) coinSel.value = cur; - else if (prices.length > 0) coinSel.value = prices[0].coin; - } - - // Price chart exchange selector - if (exSel.options.length <= 1) { - exSel.innerHTML = ''; - for (const ex of EXCHANGES) { - const opt = document.createElement('option'); - opt.value = ex; opt.textContent = ex; - exSel.appendChild(opt); - } - exSel.value = 'HyperLiquid'; - } - - // Spread chart coin selector - if (spreadSel.options.length <= 1) { - const cur = spreadSel.value; - spreadSel.innerHTML = ''; - for (const row of prices) { - const opt = document.createElement('option'); - opt.value = row.coin; opt.textContent = row.coin; - spreadSel.appendChild(opt); - } - if (cur) spreadSel.value = cur; - else if (prices.length > 0) spreadSel.value = prices[0].coin; - } - - // Update charts on selection change - const selCoin = coinSel.value, selEx = exSel.value; - if (selCoin && selEx) updatePriceChart(selCoin, selEx); - - const spCoin = spreadSel.value; - if (spCoin) updateSpreadChart(spCoin); -} - -function updatePriceChart(coin, exchange) { - const key = coin + '.' + exchange; - const cache = priceCache[key]; - if (!cache || !cache.points || cache.points.length < 2) { - if (priceChart) { - priceChart.data.datasets[0].data = []; - priceChart.data.datasets[0].label = `${coin} @ ${exchange}`; - priceChart.update('none'); - } - return; - } - const data = cache.points.map(p => ({ x: p.t, y: p.p })); - if (priceChart) { - priceChart.data.datasets[0].data = data; - priceChart.data.datasets[0].label = `${coin} @ ${exchange}`; - priceChart.update('none'); - } -} - -async function updateSpreadChart(coin) { - try { - const resp = await fetch(`/api/spread-history?coin=${coin}`); - const data = await resp.json(); - const pts = data.points || []; - if (pts.length < 2) { - if (spreadChart) { - spreadChart.data.datasets[0].data = []; - spreadChart.data.datasets[0].label = `${coin} BG↔HL`; - spreadChart.update('none'); - } - return; - } - const chartData = pts.map(p => ({ x: p.t, y: p.s })); - if (spreadChart) { - spreadChart.data.datasets[0].data = chartData; - spreadChart.data.datasets[0].label = `${coin} BG↔HL`; - spreadChart.update('none'); - } - } catch (err) { - // ignore - } -} - -// ---- Chart controls ---- -els.chartCoin.addEventListener('change', () => { - const coin = els.chartCoin.value; - const ex = els.chartExch.value; - if (coin && ex) updatePriceChart(coin, ex); -}); - -els.chartExch.addEventListener('change', () => { - const coin = els.chartCoin.value; - const ex = els.chartExch.value; - if (coin && ex) updatePriceChart(coin, ex); -}); - -els.spreadCoin.addEventListener('change', () => { - if (els.spreadCoin.value) updateSpreadChart(els.spreadCoin.value); -}); - -// ---- Auto-refresh charts ---- -setInterval(() => { - const coin = els.chartCoin.value; - const ex = els.chartExch.value; - if (coin && ex) updatePriceChart(coin, ex); -}, 2000); - -setInterval(() => { - if (els.spreadCoin.value) updateSpreadChart(els.spreadCoin.value); -}, 3000); - // ---- Trades from API ---- async function loadTrades() { try { @@ -643,10 +407,8 @@ window.closeTradeDetail = closeTradeDetail; // ---- Init ---- function init() { connectSSE(); - loadTrades(); // run before charts in case Chart CDN is slow + loadTrades(); setInterval(loadTrades, 10000); - try { initPriceChart(); } catch(e) { console.warn('Price chart init failed:', e); } - try { initSpreadChart(); } catch(e) { console.warn('Spread chart init failed:', e); } } if (document.readyState === 'loading') { diff --git a/web/static/index.html b/web/static/index.html index 9d3d113..e1af196 100644 --- a/web/static/index.html +++ b/web/static/index.html @@ -5,7 +5,7 @@ Exchange Monitor Dashboard - +
@@ -42,6 +42,21 @@
+ +
+

🔒 当前持仓

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

⛔ 黑名单

@@ -80,44 +95,6 @@
- -
-

🔒 当前持仓

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

📈 价格走势

-
- - -
-
- -
-
- - -
-

📉 价差走势 (BG↔HL)

-
- -
-
- -
-
-

📋 历史交易

@@ -132,6 +109,8 @@
+ + diff --git a/web/static/style.css b/web/static/style.css index 638fb94..9e02de1 100644 --- a/web/static/style.css +++ b/web/static/style.css @@ -135,26 +135,6 @@ tr:hover td { background: rgba(88, 166, 255, 0.05); } .text-dim { color: var(--text-dim); } .text-right { text-align: right; } -/* Chart controls */ -.chart-controls { - display: flex; - gap: 8px; - margin-bottom: 10px; -} -.chart-controls select { - background: var(--bg); - color: var(--text); - border: 1px solid var(--border); - border-radius: 4px; - padding: 4px 8px; - font-size: 13px; - cursor: pointer; -} -.chart-container { - position: relative; - height: 300px; -} - /* Scrollbar */ ::-webkit-scrollbar { width: 6px; height: 6px; } ::-webkit-scrollbar-track { background: transparent; }