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 (
+
+
+
+
+ {/* 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}
+
+
+
+ | 币种 | HyperLiquid | Bitget | BG↔HL价差 |
+
+
+ {coins.length === 0 ? (
+ | 等待数据... |
+ ) : coins.map(coin => {
+ const row = prices.find(p => p.coin === coin)
+ if (!row) {
+ return | {coin} | - | - | - |
+ }
+ const cells = EXCHANGES.map(ex => {
+ const p = row[ex]
+ const prev = getPrevPrice(coin, ex)
+ const cls = prev ? priceClass(prev, p || 0) : ''
+ return {formatPrice(p)} |
+ })
+ const spread = row['bg_hl_spread']
+ const spreadCls = spread > 0.2 ? 'text-green' : spread < -0.2 ? 'text-red' : ''
+ return (
+
+ | {coin} |
+ {cells}
+ {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})
+
+
+ | 类型 | 方向 | 交易所 | 价格 | 数量 | 手续费 | 订单ID |
+
+
+ {orders.map((o, i) => (
+
+ | {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 @@
+
+
+ 🔒 当前持仓
+
+
+
+ | 币种 | 方向 | 规模 | 入价差 | 现价差 | 估盈亏 | 加仓 | 时长 |
+
+
+ | 等待数据... |
+
+
+
+
+
-
-
- 🔒 当前持仓
-
-
-
- | 币种 | 方向 | 规模 | 入价差 | 现价差 | 估盈亏 | 加仓 | 时长 |
-
-
- | 等待数据... |
-
-
-
-
-
-
-
-
-
-
- 📉 价差走势 (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; }