From e51f357b48b658a767478d1440f8074a79a66c36 Mon Sep 17 00:00:00 2001 From: dekun Date: Fri, 24 Jul 2026 16:33:25 +0800 Subject: [PATCH] Initial eth_hedge_sim: P0 market, auth UI, one-click deploy. Co-authored-by: Cursor --- .env.example | 35 + .gitignore | 35 + README.md | 70 ++ backend/app/__init__.py | 1 + backend/app/api/__init__.py | 8 + backend/app/api/auth.py | 85 ++ backend/app/api/auth_routes.py | 36 + backend/app/api/market.py | 30 + backend/app/config.py | 52 + backend/app/live/__init__.py | 1 + backend/app/main.py | 109 ++ backend/app/market/__init__.py | 22 + backend/app/market/book_cache.py | 124 ++ backend/app/market/gateway.py | 150 +++ backend/app/market/instruments.py | 119 ++ backend/app/market/okx_rest.py | 99 ++ backend/app/market/okx_ws.py | 207 ++++ backend/app/market/types.py | 94 ++ backend/app/models/__init__.py | 1 + backend/app/services/__init__.py | 1 + backend/app/sim/__init__.py | 1 + backend/app/strategy/__init__.py | 1 + backend/app/ws/__init__.py | 1 + backend/data/.gitkeep | 0 backend/tests/conftest.py | 8 + backend/tests/test_instruments.py | 50 + deploy/bootstrap.sh | 13 + deploy/ecosystem.config.cjs | 17 + deploy/pull_and_restart.sh | 50 + docs/代码结构.md | 283 +++++ docs/开发方案.md | 221 ++++ frontend/index.html | 18 + frontend/package-lock.json | 1920 +++++++++++++++++++++++++++++ frontend/package.json | 23 + frontend/src/App.tsx | 91 ++ frontend/src/api/client.ts | 110 ++ frontend/src/main.tsx | 13 + frontend/src/pages/Login.tsx | 79 ++ frontend/src/pages/Plan.tsx | 121 ++ frontend/src/pages/Settings.tsx | 38 + frontend/src/pages/Stats.tsx | 8 + frontend/src/pages/Trades.tsx | 8 + frontend/src/styles/app.css | 238 ++++ frontend/src/vite-env.d.ts | 1 + frontend/tsconfig.json | 20 + frontend/vite.config.ts | 17 + requirements.txt | 10 + scripts/deploy_remote.py | 69 ++ scripts/smoke_market.py | 75 ++ 49 files changed, 4783 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 README.md create mode 100644 backend/app/__init__.py create mode 100644 backend/app/api/__init__.py create mode 100644 backend/app/api/auth.py create mode 100644 backend/app/api/auth_routes.py create mode 100644 backend/app/api/market.py create mode 100644 backend/app/config.py create mode 100644 backend/app/live/__init__.py create mode 100644 backend/app/main.py create mode 100644 backend/app/market/__init__.py create mode 100644 backend/app/market/book_cache.py create mode 100644 backend/app/market/gateway.py create mode 100644 backend/app/market/instruments.py create mode 100644 backend/app/market/okx_rest.py create mode 100644 backend/app/market/okx_ws.py create mode 100644 backend/app/market/types.py create mode 100644 backend/app/models/__init__.py create mode 100644 backend/app/services/__init__.py create mode 100644 backend/app/sim/__init__.py create mode 100644 backend/app/strategy/__init__.py create mode 100644 backend/app/ws/__init__.py create mode 100644 backend/data/.gitkeep create mode 100644 backend/tests/conftest.py create mode 100644 backend/tests/test_instruments.py create mode 100644 deploy/bootstrap.sh create mode 100644 deploy/ecosystem.config.cjs create mode 100644 deploy/pull_and_restart.sh create mode 100644 docs/代码结构.md create mode 100644 docs/开发方案.md create mode 100644 frontend/index.html create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/src/App.tsx create mode 100644 frontend/src/api/client.ts create mode 100644 frontend/src/main.tsx create mode 100644 frontend/src/pages/Login.tsx create mode 100644 frontend/src/pages/Plan.tsx create mode 100644 frontend/src/pages/Settings.tsx create mode 100644 frontend/src/pages/Stats.tsx create mode 100644 frontend/src/pages/Trades.tsx create mode 100644 frontend/src/styles/app.css create mode 100644 frontend/src/vite-env.d.ts create mode 100644 frontend/tsconfig.json create mode 100644 frontend/vite.config.ts create mode 100644 requirements.txt create mode 100644 scripts/deploy_remote.py create mode 100644 scripts/smoke_market.py diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..7858cd3 --- /dev/null +++ b/.env.example @@ -0,0 +1,35 @@ +# eth_hedge_sim — 独立自动对冲模拟盘 +# 复制为 .env 后按需填写。模拟阶段禁止真实下单;真密钥不上库。 + +MODE=SIM +ENV_NAME=test +TZ=Asia/Shanghai + +# HTTP(前后端同端口,默认 5155) +API_HOST=0.0.0.0 +API_PORT=5155 + +# Web 登录(部署后请立刻修改) +AUTH_USERNAME=admin +AUTH_PASSWORD=admin123 +AUTH_SECRET=change-me-eth-hedge-sim-secret +AUTH_TOKEN_TTL_SEC=604800 + +# OKX(SIM 阶段公共盘口可不填 Key) +OKX_API_KEY= +OKX_API_SECRET= +OKX_API_PASSPHRASE= +OKX_REST_BASE=https://www.okx.com +OKX_WS_PUBLIC=wss://ws.okx.com:8443/ws/v5/public +# 云上一般直连留空;本机受限时再填代理 +OKX_HTTP_PROXY= + +PERP_INST_ID=ETH-USDT-SWAP +OPTION_INST_FAMILY=ETH-USD_UM +INDEX_INST_ID=ETH-USD + +FEE_RATE=0.0005 +INITIAL_EQUITY=100000 +MAX_ROUNDS=3 +OPEN_HHMM=16:00 +STOP_OPEN_HHMM=08:00 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4f755c9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,35 @@ +# env / secrets +.env +.env.local +*.pem + +# python +__pycache__/ +*.py[cod] +*.egg-info/ +.venv/ +venv/ +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ + +# local data +backend/data/*.db +backend/data/*.sqlite +backend/data/*.sqlite3 +!backend/data/.gitkeep + +# frontend +frontend/node_modules/ +frontend/dist/ + +# ide / os +.idea/ +.vscode/ +*.swp +.DS_Store +Thumbs.db + +# logs +*.log +logs/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..eb33d0a --- /dev/null +++ b/README.md @@ -0,0 +1,70 @@ +# eth_hedge_sim + +独立自动对冲**模拟盘**:OKX 实盘只读行情 + 本地虚拟资金撮合。 +与现网 `crypto_monitor` **无代码、进程、密钥共用**。 + +仓库: + +## 文档 + +- [开发方案](docs/开发方案.md) +- [代码结构](docs/代码结构.md) + +## 访问(测试机) + +- 地址:`http://47.236.184.99:5155` +- 默认登录:见服务器 `/opt/eth_hedge_sim/.env` 的 `AUTH_USERNAME` / `AUTH_PASSWORD`(示例 `admin` / `admin123`) +- 登录页可填写 **API 地址**(同机部署填 `http://47.236.184.99:5155` 或留当前域名) + +## 一键部署 / 更新(禁止 scp 传代码) + +服务器目录:`/opt/eth_hedge_sim` +更新方式:**只允许 `git pull`**,然后构建并 `pm2 startOrReload` 本项目进程。 + +### 服务器上 + +```bash +# 首次 +export REPO_URL=https://git.bz121.com/dekun/eth_hedge_sim.git +bash /opt/eth_hedge_sim/deploy/bootstrap.sh +# 若尚未 clone: +# git clone "$REPO_URL" /opt/eth_hedge_sim && bash /opt/eth_hedge_sim/deploy/bootstrap.sh + +# 日常更新 +bash /opt/eth_hedge_sim/deploy/pull_and_restart.sh +``` + +### 本机触发远程更新 + +```bash +pip install paramiko +set DEPLOY_PASS=*** # Windows PowerShell: $env:DEPLOY_PASS='***' +python scripts/deploy_remote.py +``` + +PM2 进程名:`eth-hedge-api`(端口 **5155**)。禁止 `pm2 restart all`。 + +## 本地开发 + +```bash +python -m venv .venv +.venv\Scripts\activate +pip install -r requirements.txt +copy .env.example .env + +python scripts/smoke_market.py +cd backend +uvicorn app.main:app --host 0.0.0.0 --port 5155 --reload + +# 另开终端 +cd frontend +npm ci +npm run dev +``` + +## 硬规则摘要 + +- 模拟阶段零交易类 API +- 永续市价;期权只吃买卖一;滑点 = 1×手续费 +- 仓位:永续 1 ETH,期权 2 ETH 名义 +- 部署更新只用 git pull,不用 scp diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..2ce63a8 --- /dev/null +++ b/backend/app/__init__.py @@ -0,0 +1 @@ +"""eth_hedge_sim backend package.""" diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py new file mode 100644 index 0000000..39d4c1a --- /dev/null +++ b/backend/app/api/__init__.py @@ -0,0 +1,8 @@ +from fastapi import APIRouter + +from .auth_routes import router as auth_router +from .market import router as market_router + +router = APIRouter() +router.include_router(auth_router) +router.include_router(market_router) diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py new file mode 100644 index 0000000..b683671 --- /dev/null +++ b/backend/app/api/auth.py @@ -0,0 +1,85 @@ +"""简单 HMAC Token 鉴权(无 JWT 依赖)。""" + +from __future__ import annotations + +import base64 +import hashlib +import hmac +import json +import time +from typing import Annotated + +from fastapi import Depends, HTTPException, status +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer +from pydantic import BaseModel, Field + +from ..config import Settings, get_settings + +_bearer = HTTPBearer(auto_error=False) + + +class LoginRequest(BaseModel): + username: str = Field(min_length=1) + password: str = Field(min_length=1) + + +class LoginResponse(BaseModel): + token: str + username: str + expires_in: int + env_name: str + mode: str + + +def _b64url(data: bytes) -> str: + return base64.urlsafe_b64encode(data).decode("ascii").rstrip("=") + + +def _b64url_decode(s: str) -> bytes: + pad = "=" * (-len(s) % 4) + return base64.urlsafe_b64decode(s + pad) + + +def issue_token(username: str, settings: Settings) -> tuple[str, int]: + exp = int(time.time()) + int(settings.auth_token_ttl_sec) + payload = {"u": username, "exp": exp} + raw = _b64url(json.dumps(payload, separators=(",", ":")).encode("utf-8")) + sig = hmac.new( + settings.auth_secret.encode("utf-8"), + raw.encode("ascii"), + hashlib.sha256, + ).hexdigest() + return f"{raw}.{sig}", settings.auth_token_ttl_sec + + +def verify_token(token: str, settings: Settings) -> str: + try: + raw, sig = token.rsplit(".", 1) + except ValueError as e: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid token") from e + expect = hmac.new( + settings.auth_secret.encode("utf-8"), + raw.encode("ascii"), + hashlib.sha256, + ).hexdigest() + if not hmac.compare_digest(expect, sig): + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid token") + try: + payload = json.loads(_b64url_decode(raw)) + except Exception as e: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid token") from e + if int(payload.get("exp") or 0) < int(time.time()): + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="token expired") + username = str(payload.get("u") or "") + if not username: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid token") + return username + + +def require_user( + creds: Annotated[HTTPAuthorizationCredentials | None, Depends(_bearer)], + settings: Annotated[Settings, Depends(get_settings)], +) -> str: + if creds is None or not creds.credentials: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="login required") + return verify_token(creds.credentials, settings) diff --git a/backend/app/api/auth_routes.py b/backend/app/api/auth_routes.py new file mode 100644 index 0000000..91c275b --- /dev/null +++ b/backend/app/api/auth_routes.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException, status + +from ..config import Settings, get_settings +from .auth import LoginRequest, LoginResponse, issue_token, require_user + +router = APIRouter(prefix="/api/auth", tags=["auth"]) + + +@router.post("/login", response_model=LoginResponse) +async def login(body: LoginRequest, settings: Annotated[Settings, Depends(get_settings)]) -> LoginResponse: + user_ok = body.username == settings.auth_username + pass_ok = body.password == settings.auth_password + if not (user_ok and pass_ok): + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="用户名或密码错误") + token, ttl = issue_token(body.username, settings) + return LoginResponse( + token=token, + username=body.username, + expires_in=ttl, + env_name=settings.env_name, + mode=settings.mode, + ) + + +@router.get("/me") +async def me(username: Annotated[str, Depends(require_user)], settings: Annotated[Settings, Depends(get_settings)]) -> dict: + return { + "username": username, + "env_name": settings.env_name, + "mode": settings.mode, + "sim": settings.is_sim, + } diff --git a/backend/app/api/market.py b/backend/app/api/market.py new file mode 100644 index 0000000..e444def --- /dev/null +++ b/backend/app/api/market.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException + +from ..market import get_gateway +from .auth import require_user + +router = APIRouter(prefix="/api/market", tags=["market"]) + + +@router.get("/snapshot") +async def market_snapshot(_user: Annotated[str, Depends(require_user)]) -> dict: + gw = get_gateway() + snap = gw.snapshot_dict() + if snap.get("pair") is None: + raise HTTPException(status_code=503, detail="market not aligned yet") + return snap + + +@router.post("/realign") +async def market_realign(_user: Annotated[str, Depends(require_user)]) -> dict: + """手动重对齐次日到期 ATM 合约(运维/调试用)。""" + gw = get_gateway() + try: + pair = await gw.realign_async() + except Exception as e: + raise HTTPException(status_code=502, detail=str(e)) from e + return {"ok": True, "pair": pair.to_dict() if pair else None, "snapshot": gw.snapshot_dict()} diff --git a/backend/app/config.py b/backend/app/config.py new file mode 100644 index 0000000..82be518 --- /dev/null +++ b/backend/app/config.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +from functools import lru_cache + +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + model_config = SettingsConfigDict( + env_file=(".env", "../.env"), + env_file_encoding="utf-8", + extra="ignore", + ) + + mode: str = "SIM" + tz: str = "Asia/Shanghai" + env_name: str = "test" # test / prod + + api_host: str = "0.0.0.0" + api_port: int = 5155 + + # Web 登录(仅本机 .env,勿提交真密码) + auth_username: str = "admin" + auth_password: str = "admin123" + auth_secret: str = "change-me-eth-hedge-sim-secret" + auth_token_ttl_sec: int = 60 * 60 * 24 * 7 + + okx_api_key: str = "" + okx_api_secret: str = "" + okx_api_passphrase: str = "" + okx_rest_base: str = "https://www.okx.com" + okx_ws_public: str = "wss://ws.okx.com:8443/ws/v5/public" + okx_http_proxy: str = "" + + perp_inst_id: str = "ETH-USDT-SWAP" + option_inst_family: str = "ETH-USD_UM" + index_inst_id: str = "ETH-USD" + + fee_rate: float = 0.0005 + initial_equity: float = 100_000.0 + max_rounds: int = 3 + open_hhmm: str = "16:00" + stop_open_hhmm: str = "08:00" + + @property + def is_sim(self) -> bool: + return self.mode.strip().upper() != "LIVE" + + +@lru_cache +def get_settings() -> Settings: + return Settings() diff --git a/backend/app/live/__init__.py b/backend/app/live/__init__.py new file mode 100644 index 0000000..181d4f1 --- /dev/null +++ b/backend/app/live/__init__.py @@ -0,0 +1 @@ +# Placeholder: live OKX trade adapter (P5). Default off. diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..c03379c --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,109 @@ +from __future__ import annotations + +import logging +from contextlib import asynccontextmanager +from pathlib import Path + +from fastapi import FastAPI, HTTPException +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import FileResponse +from fastapi.staticfiles import StaticFiles + +from .api import router as api_router +from .config import get_settings +from .market import MarketGateway, set_gateway + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(levelname)s [%(name)s] %(message)s", +) +logger = logging.getLogger("eth_hedge_sim") + + +def resolve_frontend_dist() -> Path: + here = Path(__file__).resolve() + # backend/app/main.py -> repo root is parents[2] + repo_root = here.parents[2] + return repo_root / "frontend" / "dist" + + +@asynccontextmanager +async def lifespan(app: FastAPI): + settings = get_settings() + if not settings.is_sim: + logger.warning("MODE=%s — still read-only market in current phase", settings.mode) + + gw = MarketGateway(settings) + set_gateway(gw) + try: + await gw.start() + logger.info("market gateway started (SIM read-only)") + except Exception: + logger.exception("market gateway failed to start") + yield + await gw.stop() + set_gateway(None) + + +app = FastAPI( + title="eth_hedge_sim", + version="0.2.0", + description="ETH 自动对冲模拟盘", + lifespan=lifespan, +) +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) +app.include_router(api_router) + + +@app.get("/health") +async def health() -> dict: + from .market import get_gateway + + settings = get_settings() + gw = get_gateway() + snap = gw.snapshot() + return { + "ok": True, + "mode": settings.mode, + "env_name": settings.env_name, + "sim": settings.is_sim, + "market_connected": snap.connected, + "pair": snap.pair.to_dict() if snap.pair else None, + "updated_at_ms": snap.updated_at_ms, + } + + +_DIST = resolve_frontend_dist() +if (_DIST / "assets").is_dir(): + app.mount("/assets", StaticFiles(directory=str(_DIST / "assets")), name="assets") + + +@app.get("/") +async def index_page(): + index = _DIST / "index.html" + if index.exists(): + return FileResponse(index) + return { + "ok": True, + "msg": "frontend not built yet; run: cd frontend && npm ci && npm run build", + "health": "/health", + } + + +@app.get("/app/{full_path:path}") +@app.get("/plan") +@app.get("/trades") +@app.get("/stats") +@app.get("/settings") +@app.get("/login") +async def spa_pages(full_path: str = ""): + index = _DIST / "index.html" + if not index.exists(): + raise HTTPException(status_code=404, detail="frontend not built") + return FileResponse(index) diff --git a/backend/app/market/__init__.py b/backend/app/market/__init__.py new file mode 100644 index 0000000..6c6283a --- /dev/null +++ b/backend/app/market/__init__.py @@ -0,0 +1,22 @@ +"""OKX 实盘只读行情网关。""" + +from .book_cache import BookCache +from .gateway import MarketGateway, get_gateway, set_gateway +from .instruments import next_session_expiry_ymd, select_option_pair +from .okx_rest import OkxRestClient +from .okx_ws import OkxPublicWs +from .types import MarketSnapshot, OptionPair, Quote + +__all__ = [ + "BookCache", + "MarketGateway", + "MarketSnapshot", + "OkxPublicWs", + "OkxRestClient", + "OptionPair", + "Quote", + "get_gateway", + "next_session_expiry_ymd", + "select_option_pair", + "set_gateway", +] diff --git a/backend/app/market/book_cache.py b/backend/app/market/book_cache.py new file mode 100644 index 0000000..e52e139 --- /dev/null +++ b/backend/app/market/book_cache.py @@ -0,0 +1,124 @@ +from __future__ import annotations + +import threading +import time +from typing import Iterable + +from .types import BookLevel, MarketSnapshot, OptionPair, Quote + + +class BookCache: + """内存盘口缓存:永续 + Call/Put。线程安全。""" + + def __init__(self) -> None: + self._lock = threading.RLock() + self._quotes: dict[str, Quote] = {} + self._index_px: float | None = None + self._pair: OptionPair | None = None + self._connected = False + self._updated_at_ms: int | None = None + + def set_connected(self, ok: bool) -> None: + with self._lock: + self._connected = bool(ok) + + def set_pair(self, pair: OptionPair | None) -> None: + with self._lock: + self._pair = pair + + def set_index_px(self, px: float | None) -> None: + with self._lock: + if px is not None and px > 0: + self._index_px = float(px) + self._touch() + + def upsert_book( + self, + inst_id: str, + *, + bids: list[BookLevel], + asks: list[BookLevel], + ts_ms: int | None = None, + ) -> None: + with self._lock: + q = self._quotes.get(inst_id) or Quote(inst_id=inst_id) + q.bids = bids + q.asks = asks + q.bid = bids[0].px if bids else None + q.ask = asks[0].px if asks else None + q.bid_sz = bids[0].sz if bids else None + q.ask_sz = asks[0].sz if asks else None + if ts_ms is not None: + q.ts_ms = ts_ms + self._quotes[inst_id] = q + self._touch(ts_ms) + + def upsert_top( + self, + inst_id: str, + *, + bid: float | None, + ask: float | None, + bid_sz: float | None = None, + ask_sz: float | None = None, + ts_ms: int | None = None, + ) -> None: + with self._lock: + q = self._quotes.get(inst_id) or Quote(inst_id=inst_id) + if bid is not None: + q.bid = bid + if ask is not None: + q.ask = ask + if bid_sz is not None: + q.bid_sz = bid_sz + if ask_sz is not None: + q.ask_sz = ask_sz + if ts_ms is not None: + q.ts_ms = ts_ms + # 同步一层盘口,便于 snapshot 展示 + if bid is not None and bid_sz is not None: + q.bids = [BookLevel(px=bid, sz=bid_sz)] + q.bids[1:] + if ask is not None and ask_sz is not None: + q.asks = [BookLevel(px=ask, sz=ask_sz)] + q.asks[1:] + self._quotes[inst_id] = q + self._touch(ts_ms) + + def set_mark_px(self, inst_id: str, mark_px: float | None, ts_ms: int | None = None) -> None: + with self._lock: + if mark_px is None or mark_px <= 0: + return + q = self._quotes.get(inst_id) or Quote(inst_id=inst_id) + q.mark_px = float(mark_px) + if ts_ms is not None: + q.ts_ms = ts_ms + self._quotes[inst_id] = q + self._touch(ts_ms) + + def get(self, inst_id: str) -> Quote | None: + with self._lock: + return self._quotes.get(inst_id) + + def drop_except(self, keep: Iterable[str]) -> None: + keep_set = set(keep) + with self._lock: + for k in list(self._quotes): + if k not in keep_set: + del self._quotes[k] + + def snapshot(self, perp_inst_id: str) -> MarketSnapshot: + with self._lock: + pair = self._pair + call = self._quotes.get(pair.call_inst_id) if pair else None + put = self._quotes.get(pair.put_inst_id) if pair else None + return MarketSnapshot( + perp=self._quotes.get(perp_inst_id), + call=call, + put=put, + index_px=self._index_px, + pair=pair, + connected=self._connected, + updated_at_ms=self._updated_at_ms, + ) + + def _touch(self, ts_ms: int | None = None) -> None: + self._updated_at_ms = int(ts_ms) if ts_ms is not None else int(time.time() * 1000) diff --git a/backend/app/market/gateway.py b/backend/app/market/gateway.py new file mode 100644 index 0000000..fca4d3b --- /dev/null +++ b/backend/app/market/gateway.py @@ -0,0 +1,150 @@ +"""行情网关:REST 对齐合约 + WS 推送盘口。""" + +from __future__ import annotations + +import asyncio +import logging +from typing import Any + +from ..config import Settings, get_settings +from .book_cache import BookCache +from .instruments import next_session_expiry_ymd, select_option_pair +from .okx_rest import OkxRestClient +from .okx_ws import OkxPublicWs +from .types import MarketSnapshot, OptionPair + +logger = logging.getLogger(__name__) + + +class MarketGateway: + def __init__(self, settings: Settings | None = None) -> None: + self.settings = settings or get_settings() + self.cache = BookCache() + proxy = self.settings.okx_http_proxy or None + self.rest = OkxRestClient(self.settings.okx_rest_base, proxy=proxy) + self.ws = OkxPublicWs(self.settings.okx_ws_public, self.cache, proxy=proxy) + self._pair: OptionPair | None = None + self._refresh_task: asyncio.Task[None] | None = None + self._started = False + + @property + def pair(self) -> OptionPair | None: + return self._pair + + async def start(self) -> None: + if self._started: + return + self._started = True + await asyncio.to_thread(self.align_instruments) + await self.ws.start() + self._refresh_task = asyncio.create_task(self._refresh_loop(), name="market-align") + + async def stop(self) -> None: + self._started = False + if self._refresh_task: + self._refresh_task.cancel() + try: + await self._refresh_task + except asyncio.CancelledError: + pass + self._refresh_task = None + await self.ws.stop() + self.rest.close() + + def align_instruments(self) -> OptionPair | None: + """同步:拉期权列表,选次日到期 ATM Call/Put,REST 预热盘口,切换 WS 订阅。""" + s = self.settings + idx = self.rest.fetch_index_ticker(s.index_inst_id) + mark = self.rest.fetch_mark_price(s.perp_inst_id) or idx + if mark is None or mark <= 0: + raise RuntimeError("无法获取 ETH 标记/指数价格,无法选 ATM") + + instruments = self.rest.fetch_option_instruments(s.option_inst_family) + ymd = next_session_expiry_ymd() + pair = select_option_pair(instruments, mark_px=float(mark), expiry_ymd=ymd) + if pair is None: + raise RuntimeError(f"未找到到期 {ymd} 的 ATM Call/Put 合约 pair (family={s.option_inst_family})") + + self._pair = pair + self.cache.set_pair(pair) + self.cache.set_index_px(idx) + + # REST 预热:永续 + Call + Put + for inst in (s.perp_inst_id, pair.call_inst_id, pair.put_inst_id): + bids, asks, ts = self.rest.fetch_books(inst, sz=5) + self.cache.upsert_book(inst, bids=bids, asks=asks, ts_ms=ts) + mp = self.rest.fetch_mark_price(inst) + if mp: + self.cache.set_mark_px(inst, mp) + + keep = {s.perp_inst_id, pair.call_inst_id, pair.put_inst_id} + self.cache.drop_except(keep) + self.ws.set_instruments([s.perp_inst_id, pair.call_inst_id, pair.put_inst_id]) + logger.info( + "aligned pair expiry=%s strike=%s call=%s put=%s mark=%.2f", + pair.expiry_ymd, + pair.strike, + pair.call_inst_id, + pair.put_inst_id, + mark, + ) + return pair + + async def realign_async(self) -> OptionPair | None: + old = self._pair + pair = await asyncio.to_thread(self.align_instruments) + if old is None or ( + pair + and ( + pair.call_inst_id != old.call_inst_id + or pair.put_inst_id != old.put_inst_id + ) + ): + await self.ws.resubscribe( + [ + self.settings.perp_inst_id, + pair.call_inst_id, + pair.put_inst_id, + ] + ) + return pair + + def snapshot(self) -> MarketSnapshot: + return self.cache.snapshot(self.settings.perp_inst_id) + + def snapshot_dict(self) -> dict[str, Any]: + return self.snapshot().to_dict() + + async def _refresh_loop(self) -> None: + """周期性刷新指数价;跨日到期切换时重对齐。""" + while True: + await asyncio.sleep(30) + try: + idx = await asyncio.to_thread( + self.rest.fetch_index_ticker, self.settings.index_inst_id + ) + self.cache.set_index_px(idx) + want = next_session_expiry_ymd() + if self._pair and self._pair.expiry_ymd != want: + logger.info("expiry rollover %s -> %s", self._pair.expiry_ymd, want) + await self.realign_async() + except asyncio.CancelledError: + raise + except Exception as e: + logger.warning("market refresh failed: %s", e) + + +# 进程级单例(FastAPI lifespan 注入) +_gateway: MarketGateway | None = None + + +def get_gateway() -> MarketGateway: + global _gateway + if _gateway is None: + _gateway = MarketGateway() + return _gateway + + +def set_gateway(gw: MarketGateway | None) -> None: + global _gateway + _gateway = gw diff --git a/backend/app/market/instruments.py b/backend/app/market/instruments.py new file mode 100644 index 0000000..ddc5d06 --- /dev/null +++ b/backend/app/market/instruments.py @@ -0,0 +1,119 @@ +"""合约选择:次日 16:00(上海)到期 + ATM 行权价(暂定默认,待拍板可改)。""" + +from __future__ import annotations + +import re +from datetime import datetime, timedelta, timezone +from typing import Any +from zoneinfo import ZoneInfo + +from .types import OptionPair + +_SH = ZoneInfo("Asia/Shanghai") +_DATE_RE = re.compile(r"^\d{6}$") + + +def safe_float(v: Any) -> float | None: + if v is None or v == "": + return None + try: + return float(v) + except (TypeError, ValueError): + return None + + +def parse_option_inst_id(inst_id: str) -> tuple[str | None, float | None, str | None]: + """ETH-USD_UM-YYMMDD-STRIKE-C → (YYMMDD, strike, C|P).""" + parts = (inst_id or "").strip().split("-") + if len(parts) < 5: + return None, None, None + ymd = parts[-3] + strike = safe_float(parts[-2]) + opt = parts[-1].upper() + if not _DATE_RE.fullmatch(ymd) or strike is None or opt not in ("C", "P"): + return None, None, None + return ymd, strike, opt + + +def expiry_ms_from_ymd(ymd: str) -> int: + """OKX 期权到期:当日 08:00 UTC = 上海 16:00。""" + yy, mm, dd = int(ymd[0:2]), int(ymd[2:4]), int(ymd[4:6]) + dt = datetime(2000 + yy, mm, dd, 8, 0, 0, tzinfo=timezone.utc) + return int(dt.timestamp() * 1000) + + +def next_session_expiry_ymd(now: datetime | None = None) -> str: + """ + 业务约定:开仓选「次日 16:00」到期。 + - 上海时间 >= 当日 16:00:目标到期日 = 次日 + - 上海时间 < 当日 16:00:目标到期日 = 当日(当日 16:00 到期仍可用作盘口对齐/预热) + 正式开仓窗从当日 16:00 起,届时「次日」即日历次日。 + """ + now_sh = (now or datetime.now(tz=_SH)).astimezone(_SH) + open_today = now_sh.replace(hour=16, minute=0, second=0, microsecond=0) + if now_sh >= open_today: + target = now_sh.date() + timedelta(days=1) + else: + target = now_sh.date() + return target.strftime("%y%m%d") + + +def pick_atm_strike(strikes: list[float], mark_px: float) -> float | None: + if not strikes or mark_px <= 0: + return None + return min(strikes, key=lambda s: (abs(s - mark_px), s)) + + +def select_option_pair( + instruments: list[dict[str, Any]], + *, + mark_px: float, + expiry_ymd: str | None = None, + now: datetime | None = None, +) -> OptionPair | None: + """ + 从 live 合约列表中选出:目标到期日 + ATM 同行权价 Call/Put。 + 行权价规则暂定 ATM(最接近标记/指数价);待拍板后可替换。 + """ + ymd = expiry_ymd or next_session_expiry_ymd(now) + by_strike: dict[float, dict[str, str]] = {} + + for row in instruments: + if not isinstance(row, dict): + continue + state = str(row.get("state") or "live").lower() + if state and state != "live": + continue + + inst_id = str(row.get("instId") or "") + y, stk, opt = parse_option_inst_id(inst_id) + + if y is None or stk is None or opt is None: + exp = safe_float(row.get("expTime")) + if exp: + ms = int(exp) if exp > 10_000_000_000 else int(exp * 1000) + y = datetime.fromtimestamp(ms / 1000, tz=timezone.utc).strftime("%y%m%d") + stk = safe_float(row.get("stk")) + opt_raw = str(row.get("optType") or "").upper() + opt = opt_raw if opt_raw in ("C", "P") else None + + if not inst_id or y != ymd or stk is None or opt not in ("C", "P"): + continue + by_strike.setdefault(float(stk), {})[opt] = inst_id + + complete = {s: v for s, v in by_strike.items() if "C" in v and "P" in v} + if not complete: + return None + + atm = pick_atm_strike(list(complete.keys()), mark_px) + if atm is None: + return None + + legs = complete[atm] + return OptionPair( + expiry_ymd=ymd, + expiry_ms=expiry_ms_from_ymd(ymd), + strike=atm, + call_inst_id=legs["C"], + put_inst_id=legs["P"], + ) diff --git a/backend/app/market/okx_rest.py b/backend/app/market/okx_rest.py new file mode 100644 index 0000000..6210ff8 --- /dev/null +++ b/backend/app/market/okx_rest.py @@ -0,0 +1,99 @@ +"""OKX REST 只读行情。不调用任何交易类接口。""" + +from __future__ import annotations + +from typing import Any + +import httpx + +from .instruments import safe_float +from .types import BookLevel + + +class OkxRestClient: + def __init__( + self, + base_url: str = "https://www.okx.com", + timeout: float = 15.0, + proxy: str | None = None, + ) -> None: + self.base_url = base_url.rstrip("/") + self.proxy = (proxy or "").strip() or None + self._client = httpx.Client( + base_url=self.base_url, + timeout=timeout, + proxy=self.proxy, + headers={"Accept": "application/json", "User-Agent": "eth-hedge-sim/0.1"}, + ) + + def close(self) -> None: + self._client.close() + + def __enter__(self) -> OkxRestClient: + return self + + def __exit__(self, *args: object) -> None: + self.close() + + def _get(self, path: str, params: dict[str, Any] | None = None) -> list[dict[str, Any]]: + r = self._client.get(path, params=params or {}) + r.raise_for_status() + body = r.json() + if str(body.get("code")) != "0": + raise RuntimeError(f"OKX REST error code={body.get('code')} msg={body.get('msg')}") + data = body.get("data") or [] + return [x for x in data if isinstance(x, dict)] + + def fetch_instruments(self, *, inst_type: str, inst_family: str | None = None) -> list[dict[str, Any]]: + params: dict[str, Any] = {"instType": inst_type} + if inst_family: + params["instFamily"] = inst_family + return self._get("/api/v5/public/instruments", params) + + def fetch_option_instruments(self, inst_family: str) -> list[dict[str, Any]]: + rows = self.fetch_instruments(inst_type="OPTION", inst_family=inst_family) + return [r for r in rows if str(r.get("state") or "").lower() == "live"] + + def fetch_index_ticker(self, inst_id: str) -> float | None: + rows = self._get("/api/v5/market/index-tickers", {"instId": inst_id}) + if not rows: + return None + return safe_float(rows[0].get("idxPx")) + + def fetch_mark_price(self, inst_id: str) -> float | None: + rows = self._get("/api/v5/public/mark-price", {"instId": inst_id}) + if not rows: + t = self._get("/api/v5/market/ticker", {"instId": inst_id}) + if not t: + return None + return safe_float(t[0].get("markPx")) or safe_float(t[0].get("last")) + return safe_float(rows[0].get("markPx")) + + def fetch_books(self, inst_id: str, sz: int = 5) -> tuple[list[BookLevel], list[BookLevel], int | None]: + rows = self._get( + "/api/v5/market/books", + {"instId": inst_id, "sz": str(max(1, min(int(sz), 400)))}, + ) + if not rows: + return [], [], None + row = rows[0] + ts = safe_float(row.get("ts")) + ts_ms = int(ts) if ts is not None else None + return ( + _levels(row.get("bids") or []), + _levels(row.get("asks") or []), + ts_ms, + ) + + +def _levels(raw: list[Any]) -> list[BookLevel]: + out: list[BookLevel] = [] + for item in raw: + if not isinstance(item, (list, tuple)) or len(item) < 2: + continue + px = safe_float(item[0]) + sz = safe_float(item[1]) + if px is None or sz is None or px <= 0 or sz <= 0: + continue + out.append(BookLevel(px=px, sz=sz)) + return out diff --git a/backend/app/market/okx_ws.py b/backend/app/market/okx_ws.py new file mode 100644 index 0000000..229cde4 --- /dev/null +++ b/backend/app/market/okx_ws.py @@ -0,0 +1,207 @@ +"""OKX 公共 WebSocket:永续 + 期权 books5 / mark-price。只读。""" + +from __future__ import annotations + +import asyncio +import json +import logging +from typing import Any +from urllib.parse import urlparse + +import websockets +from websockets.asyncio.client import ClientConnection + +from .book_cache import BookCache +from .instruments import safe_float +from .types import BookLevel + +logger = logging.getLogger(__name__) + + +class OkxPublicWs: + def __init__( + self, + url: str, + cache: BookCache, + *, + proxy: str | None = None, + ping_interval: float = 20.0, + ) -> None: + self.url = url + self.cache = cache + self.proxy = (proxy or "").strip() or None + self.ping_interval = ping_interval + self._inst_ids: list[str] = [] + self._task: asyncio.Task[None] | None = None + self._stop = asyncio.Event() + self._subscribed: set[str] = set() + + def set_instruments(self, inst_ids: list[str]) -> None: + self._inst_ids = [i for i in inst_ids if i] + + async def start(self) -> None: + if self._task and not self._task.done(): + return + self._stop.clear() + self._task = asyncio.create_task(self._run_forever(), name="okx-public-ws") + + async def stop(self) -> None: + self._stop.set() + if self._task: + self._task.cancel() + try: + await self._task + except asyncio.CancelledError: + pass + self._task = None + self.cache.set_connected(False) + + async def resubscribe(self, inst_ids: list[str]) -> None: + self.set_instruments(inst_ids) + self._stop.set() + await asyncio.sleep(0) + self._stop.clear() + if self._task and not self._task.done(): + self._task.cancel() + try: + await self._task + except asyncio.CancelledError: + pass + self._task = asyncio.create_task(self._run_forever(), name="okx-public-ws") + + async def _open_connection(self) -> ClientConnection: + if not self.proxy: + return await websockets.connect( + self.url, + ping_interval=None, + max_size=2**22, + open_timeout=20, + ) + + from python_socks.async_.asyncio import Proxy + + parsed = urlparse(self.url) + host = parsed.hostname or "ws.okx.com" + port = parsed.port or (443 if parsed.scheme == "wss" else 80) + sock = await Proxy.from_url(self.proxy).connect(dest_host=host, dest_port=port) + return await websockets.connect( + self.url, + sock=sock, + server_hostname=host, + ping_interval=None, + max_size=2**22, + open_timeout=20, + ) + + async def _run_forever(self) -> None: + backoff = 1.0 + while not self._stop.is_set(): + try: + async with await self._open_connection() as ws: + self.cache.set_connected(True) + backoff = 1.0 + await self._subscribe(ws) + waiter = asyncio.create_task(self._stop.wait()) + reader = asyncio.create_task(self._read_loop(ws)) + pinger = asyncio.create_task(self._ping_loop(ws)) + done, pending = await asyncio.wait( + {waiter, reader, pinger}, + return_when=asyncio.FIRST_COMPLETED, + ) + for t in pending: + t.cancel() + for t in done: + exc = t.exception() + if exc and not isinstance(exc, asyncio.CancelledError): + raise exc + except asyncio.CancelledError: + raise + except Exception as e: + logger.warning("OKX WS disconnected: %s", e) + self.cache.set_connected(False) + try: + await asyncio.wait_for(self._stop.wait(), timeout=backoff) + break + except asyncio.TimeoutError: + backoff = min(backoff * 2, 30.0) + + self.cache.set_connected(False) + + async def _subscribe(self, ws: ClientConnection) -> None: + args: list[dict[str, str]] = [] + for inst in self._inst_ids: + args.append({"channel": "books5", "instId": inst}) + args.append({"channel": "mark-price", "instId": inst}) + if not args: + return + payload = {"op": "subscribe", "args": args} + await ws.send(json.dumps(payload)) + self._subscribed = {a["instId"] for a in args} + logger.info("OKX WS subscribed: %s", sorted(self._subscribed)) + + async def _ping_loop(self, ws: ClientConnection) -> None: + while True: + await asyncio.sleep(self.ping_interval) + await ws.send("ping") + + async def _read_loop(self, ws: ClientConnection) -> None: + try: + async for raw in ws: + if raw == "pong": + continue + if isinstance(raw, bytes): + raw = raw.decode("utf-8", errors="ignore") + if raw == "ping": + await ws.send("pong") + continue + try: + msg = json.loads(raw) + except json.JSONDecodeError: + continue + self._handle_message(msg) + except websockets.exceptions.ConnectionClosed: + return + + def _handle_message(self, msg: dict[str, Any]) -> None: + if msg.get("event") in ("subscribe", "error", "channel-conn-count"): + if msg.get("event") == "error": + logger.error("OKX WS error: %s", msg) + return + arg = msg.get("arg") or {} + channel = str(arg.get("channel") or "") + inst_id = str(arg.get("instId") or "") + data = msg.get("data") or [] + if not inst_id or not data: + return + row = data[0] if isinstance(data[0], dict) else None + if row is None: + return + + if channel == "books5": + ts = safe_float(row.get("ts")) + self.cache.upsert_book( + inst_id, + bids=_levels(row.get("bids") or []), + asks=_levels(row.get("asks") or []), + ts_ms=int(ts) if ts is not None else None, + ) + elif channel == "mark-price": + ts = safe_float(row.get("ts")) + self.cache.set_mark_px( + inst_id, + safe_float(row.get("markPx")), + ts_ms=int(ts) if ts is not None else None, + ) + + +def _levels(raw: list[Any]) -> list[BookLevel]: + out: list[BookLevel] = [] + for item in raw: + if not isinstance(item, (list, tuple)) or len(item) < 2: + continue + px = safe_float(item[0]) + sz = safe_float(item[1]) + if px is None or sz is None or px <= 0 or sz <= 0: + continue + out.append(BookLevel(px=px, sz=sz)) + return out diff --git a/backend/app/market/types.py b/backend/app/market/types.py new file mode 100644 index 0000000..ca4b5a7 --- /dev/null +++ b/backend/app/market/types.py @@ -0,0 +1,94 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + + +@dataclass(slots=True) +class BookLevel: + px: float + sz: float # OKX 张数 / 合约张数口径 + + +@dataclass(slots=True) +class Quote: + inst_id: str + bid: float | None = None + ask: float | None = None + bid_sz: float | None = None + ask_sz: float | None = None + mark_px: float | None = None + ts_ms: int | None = None + bids: list[BookLevel] = field(default_factory=list) + asks: list[BookLevel] = field(default_factory=list) + + def to_dict(self, *, depth: int = 5) -> dict[str, Any]: + return { + "inst_id": self.inst_id, + "bid": self.bid, + "ask": self.ask, + "bid_sz": self.bid_sz, + "ask_sz": self.ask_sz, + "mark_px": self.mark_px, + "ts_ms": self.ts_ms, + "bids": [{"px": x.px, "sz": x.sz} for x in self.bids[:depth]], + "asks": [{"px": x.px, "sz": x.sz} for x in self.asks[:depth]], + } + + +@dataclass(slots=True) +class OptionPair: + expiry_ymd: str # YYMMDD + expiry_ms: int + strike: float + call_inst_id: str + put_inst_id: str + + def to_dict(self) -> dict[str, Any]: + return { + "expiry_ymd": self.expiry_ymd, + "expiry_ms": self.expiry_ms, + "strike": self.strike, + "call_inst_id": self.call_inst_id, + "put_inst_id": self.put_inst_id, + } + + +@dataclass(slots=True) +class MarketSnapshot: + perp: Quote | None + call: Quote | None + put: Quote | None + index_px: float | None + pair: OptionPair | None + connected: bool + updated_at_ms: int | None + + def to_dict(self) -> dict[str, Any]: + return { + "connected": self.connected, + "updated_at_ms": self.updated_at_ms, + "index_px": self.index_px, + "pair": self.pair.to_dict() if self.pair else None, + "perp": self.perp.to_dict() if self.perp else None, + "call": self.call.to_dict() if self.call else None, + "put": self.put.to_dict() if self.put else None, + "ask_compare": { + "call_ask": self.call.ask if self.call else None, + "put_ask": self.put.ask if self.put else None, + "bias": _ask_bias(self.call, self.put), + }, + } + + +def _ask_bias(call: Quote | None, put: Quote | None) -> str: + """卖一比价仅用于选向展示;相等则 wait。""" + ca = call.ask if call else None + pa = put.ask if put else None + if ca is None or pa is None: + return "unknown" + if ca > pa: + return "call_ask_gt_put" # 永续多 + 期权空(腿待拍板) + if ca < pa: + return "put_ask_gt_call" # 永续空 + 期权多(腿待拍板) + return "equal" diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 0000000..f7d8c18 --- /dev/null +++ b/backend/app/models/__init__.py @@ -0,0 +1 @@ +# Placeholder: DB models (P1). diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py new file mode 100644 index 0000000..4c192d2 --- /dev/null +++ b/backend/app/services/__init__.py @@ -0,0 +1 @@ +# Placeholder: application services (P1+). diff --git a/backend/app/sim/__init__.py b/backend/app/sim/__init__.py new file mode 100644 index 0000000..18b3d54 --- /dev/null +++ b/backend/app/sim/__init__.py @@ -0,0 +1 @@ +# Placeholder packages for later phases (P1–P5). diff --git a/backend/app/strategy/__init__.py b/backend/app/strategy/__init__.py new file mode 100644 index 0000000..90d4c45 --- /dev/null +++ b/backend/app/strategy/__init__.py @@ -0,0 +1 @@ +# Placeholder: strategy state machine (P2). diff --git a/backend/app/ws/__init__.py b/backend/app/ws/__init__.py new file mode 100644 index 0000000..65c6ae0 --- /dev/null +++ b/backend/app/ws/__init__.py @@ -0,0 +1 @@ +# Placeholder: frontend push WS (P3). diff --git a/backend/data/.gitkeep b/backend/data/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000..c460220 --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,8 @@ +from __future__ import annotations + +import sys +from pathlib import Path + +BACKEND = Path(__file__).resolve().parents[1] +if str(BACKEND) not in sys.path: + sys.path.insert(0, str(BACKEND)) diff --git a/backend/tests/test_instruments.py b/backend/tests/test_instruments.py new file mode 100644 index 0000000..84bf880 --- /dev/null +++ b/backend/tests/test_instruments.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +from datetime import datetime +from zoneinfo import ZoneInfo + +from app.market.instruments import ( + next_session_expiry_ymd, + parse_option_inst_id, + pick_atm_strike, + select_option_pair, +) + +_SH = ZoneInfo("Asia/Shanghai") + + +def test_parse_option_inst_id() -> None: + y, s, o = parse_option_inst_id("ETH-USD_UM-260725-3500-C") + assert y == "260725" + assert s == 3500.0 + assert o == "C" + + +def test_pick_atm_strike() -> None: + assert pick_atm_strike([3400, 3500, 3600], 3510) == 3500 + + +def test_next_session_expiry_before_open() -> None: + now = datetime(2026, 7, 24, 15, 0, tzinfo=_SH) + assert next_session_expiry_ymd(now) == "260724" + + +def test_next_session_expiry_after_open() -> None: + now = datetime(2026, 7, 24, 16, 0, tzinfo=_SH) + assert next_session_expiry_ymd(now) == "260725" + + +def test_select_option_pair_atm() -> None: + rows = [ + {"instId": "ETH-USD_UM-260725-3490-C", "state": "live"}, + {"instId": "ETH-USD_UM-260725-3490-P", "state": "live"}, + {"instId": "ETH-USD_UM-260725-3500-C", "state": "live"}, + {"instId": "ETH-USD_UM-260725-3500-P", "state": "live"}, + {"instId": "ETH-USD_UM-260726-3500-C", "state": "live"}, + {"instId": "ETH-USD_UM-260726-3500-P", "state": "live"}, + ] + pair = select_option_pair(rows, mark_px=3502, expiry_ymd="260725") + assert pair is not None + assert pair.strike == 3500 + assert pair.call_inst_id.endswith("-3500-C") + assert pair.put_inst_id.endswith("-3500-P") diff --git a/deploy/bootstrap.sh b/deploy/bootstrap.sh new file mode 100644 index 0000000..dd32f7b --- /dev/null +++ b/deploy/bootstrap.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +# 首次安装到 /opt/eth_hedge_sim(仅 git clone,后续一律 git pull) +set -euo pipefail + +REPO_URL="${REPO_URL:-https://git.bz121.com/dekun/eth_hedge_sim.git}" +ROOT="${INSTALL_ROOT:-/opt/eth_hedge_sim}" + +if [[ ! -d "$ROOT/.git" ]]; then + mkdir -p "$(dirname "$ROOT")" + git clone "$REPO_URL" "$ROOT" +fi + +bash "$ROOT/deploy/pull_and_restart.sh" diff --git a/deploy/ecosystem.config.cjs b/deploy/ecosystem.config.cjs new file mode 100644 index 0000000..731cac7 --- /dev/null +++ b/deploy/ecosystem.config.cjs @@ -0,0 +1,17 @@ +module.exports = { + apps: [ + { + name: 'eth-hedge-api', + cwd: '/opt/eth_hedge_sim/backend', + script: '/opt/eth_hedge_sim/.venv/bin/uvicorn', + args: 'app.main:app --host 0.0.0.0 --port 5155', + interpreter: 'none', + env: { + MODE: 'SIM', + ENV_NAME: 'test', + TZ: 'Asia/Shanghai', + }, + // 密钥与本地配置一律读 /opt/eth_hedge_sim/.env(勿写进本文件) + }, + ], +}; diff --git a/deploy/pull_and_restart.sh b/deploy/pull_and_restart.sh new file mode 100644 index 0000000..871deec --- /dev/null +++ b/deploy/pull_and_restart.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +# 一键更新/部署:仅 git pull,禁止 scp 传代码。 +# 用法(服务器上): bash /opt/eth_hedge_sim/deploy/pull_and_restart.sh +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" + +echo "[eth_hedge_sim] pull @ $ROOT" +git fetch --all --prune +git pull --ff-only + +if [[ ! -f "$ROOT/.env" ]]; then + echo "[eth_hedge_sim] missing .env — copy from example" + cp "$ROOT/.env.example" "$ROOT/.env" + # 云上测试机默认直连 OKX + sed -i 's/^OKX_HTTP_PROXY=.*/OKX_HTTP_PROXY=/' "$ROOT/.env" || true + sed -i 's/^API_PORT=.*/API_PORT=5155/' "$ROOT/.env" || true + sed -i 's/^ENV_NAME=.*/ENV_NAME=test/' "$ROOT/.env" || true + echo "[eth_hedge_sim] wrote .env — please review AUTH_* secrets" +fi + +if [[ ! -d "$ROOT/.venv" ]]; then + python3 -m venv "$ROOT/.venv" +fi +# shellcheck disable=SC1091 +source "$ROOT/.venv/bin/activate" +pip install -U pip +pip install -r "$ROOT/requirements.txt" + +if ! command -v npm >/dev/null 2>&1; then + echo "ERROR: npm not found. Install Node.js 18+ first." + exit 1 +fi + +( + cd "$ROOT/frontend" + if [[ -f package-lock.json ]]; then npm ci; else npm install; fi + npm run build +) + +if ! command -v pm2 >/dev/null 2>&1; then + npm install -g pm2 +fi + +# 仅 reload 本项目进程,禁止 pm2 restart all +pm2 startOrReload "$ROOT/deploy/ecosystem.config.cjs" --update-env +pm2 save + +echo "[eth_hedge_sim] done. health: http://127.0.0.1:5155/health" diff --git a/docs/代码结构.md b/docs/代码结构.md new file mode 100644 index 0000000..f1fa497 --- /dev/null +++ b/docs/代码结构.md @@ -0,0 +1,283 @@ +# eth_hedge_sim — 代码结构 + +> 本文描述目标仓库目录与模块职责。当前阶段以文档为准;业务代码按阶段逐步添加。 +> 可从 `crypto_monitor` **复制** 有用片段到本仓库后改造;**禁止修改** 现网仓库内文件。 + +--- + +## 1. 根目录总览 + +```text +eth_hedge_sim/ +├── README.md +├── .gitignore +├── .env.example # 无密钥的示例;真 .env 不上库 +├── package.json # 若前端/Node;或改用 Python 则 requirements.txt +├── requirements.txt # 后端若用 Python +├── docs/ +│ ├── 开发方案.md # 业务与部署方案(本文档姐妹篇) +│ └── 代码结构.md # 本文件 +├── deploy/ +│ ├── ecosystem.config.cjs # PM2:仅本项目进程 +│ └── pull_and_restart.sh # git pull + 构建 + pm2 reload(不碰现网) +├── backend/ # API + 行情 + 策略 + 本地撮合 +│ ├── app/ +│ │ ├── main.py # 或 index.ts:进程入口 +│ │ ├── config.py +│ │ ├── api/ # HTTP 路由 +│ │ ├── ws/ # 向前端推送 +│ │ ├── market/ # OKX 只读行情 +│ │ ├── strategy/ # 自动对冲状态机 +│ │ ├── sim/ # 本地撮合与账本 +│ │ ├── live/ # 实盘适配器(后期,默认关闭) +│ │ ├── models/ # DB 模型 / schema +│ │ └── services/ # 组、统计、设置等应用服务 +│ ├── data/ # 本地 SQLite 等(gitignore 数据文件) +│ └── tests/ +├── frontend/ # OKX 风格 Web +│ ├── index.html +│ ├── package.json +│ ├── src/ +│ │ ├── main.tsx +│ │ ├── App.tsx +│ │ ├── layouts/ # 导航壳 +│ │ ├── pages/ +│ │ │ ├── Plan.tsx # 自动对冲计划 +│ │ │ ├── Trades.tsx # 交易记录 +│ │ │ ├── Stats.tsx # 统计 +│ │ │ └── Settings.tsx # 系统设置 +│ │ ├── components/ # 盘口、组标识、持仓条等 +│ │ ├── api/ # 调后端 +│ │ └── styles/ # OKX 深色交易台风 +│ └── dist/ # 构建产物(可部署由 API 托管或 nginx) +└── scripts/ # 运维/一次性工具(可选) + └── smoke_market.py # 只读行情连通性检查 +``` + +技术栈可在开工时二选一(建议尽快定一种,避免双栈): + +- **推荐 A**:后端 Python(FastAPI)+ 前端 React/Vite(与现网 Python 生态接近,便于复制行情代码)。 +- **推荐 B**:全 Node(Nest/Express + React)。 + +下文按 **推荐 A** 描述模块;若选 B,目录名对应平移即可。 + +--- + +## 2. 后端模块职责 + +### 2.1 `backend/app/market/` — 行情(OKX 实盘只读) + +```text +market/ +├── okx_rest.py # 合约列表、到期、启动对齐 +├── okx_ws.py # 永续 + 期权盘口订阅 +├── instruments.py # 解析「次日 16:00」到期、ATM 行权价 +├── book_cache.py # 买一/卖一/深度内存缓存 +└── types.py # Quote / Depth 结构 +``` + +职责: + +- 只拉行情,**不调用交易接口**。 +- 对外提供:永续买卖一、Call/Put 买卖一与深度、标记价。 +- 可选:行情快照落盘,供 P4 回放。 + +可参考现网 OKX WS/REST 实现,复制后改成本模块 API。 + +### 2.2 `backend/app/sim/` — 本地模拟 + +```text +sim/ +├── matcher.py # 撮合:永续市价;期权吃买卖一;滑点=1×f;双边手续费 +├── ledger.py # 虚拟资金、占用、流水 +├── liquidity.py # 买一深度是否覆盖 2 ETH +└── pricing.py # 成交价公式(含 f) +``` + +成交价口径(与开发方案一致): + +| 腿 | 动作 | 基准 | 成交价 | +|----|------|------|--------| +| 永续 | 开多 / 平空 | 卖一 | 基准 × (1+f) | +| 永续 | 开空 / 平多 | 买一 | 基准 × (1-f) | +| 期权 | 买入 | 卖一 | 基准 × (1+f) | +| 期权 | 卖出 | 买一 | 基准 × (1-f) | + +另扣:`手续费 = 名义 × f`。 + +### 2.3 `backend/app/strategy/` — 自动对冲 + +```text +strategy/ +├── clock.py # 业务窗:D 16:00~D+1 08:00;次数≤3 +├── signal.py # Call 卖一 vs Put 卖一 → 方向 +├── exits.py # 权利金覆盖 / 30 点 +├── sizing.py # 永续 1 ETH、期权 2 ETH(写死) +├── group.py # 组 ID:G-YYYYMMDD-NN +└── engine.py # 状态机:空仓→开仓→盯盘→全平→下一组 +``` + +状态机要点: + +- 同时最多 1 组。 +- 平完才允许下一组;达 3 次或过 08:00 则停开。 +- 每组锁定 `initial_premium`。 + +### 2.4 `backend/app/live/` — 实盘(P5) + +```text +live/ +├── okx_trade.py # 永续市价单;期权吃一对应下单 +└── adapter.py # 与 sim.matcher 相同接口,便于切换 +``` + +默认关闭;仅 `MODE=LIVE` 且设置页确认后启用。 + +### 2.5 `backend/app/api/` + `ws/` + +```text +api/ +├── plan.py # 当前组、策略启停、紧急全平 +├── trades.py # 组列表、组成交明细 +├── stats.py # 汇总统计 +└── settings.py # 读写配置(脱敏) + +ws/ +└── push.py # market.snapshot / group.updated / fill.created / strategy.state +``` + +### 2.6 `backend/app/models/` — 数据 + +建议表: + +| 表 | 用途 | +|----|------| +| `groups` | 组头:方向、开平时间、初始权利金、平仓原因、盈亏 | +| `fills` | 成交:腿、价、量、滑点、手续费 | +| `positions` | 当前仓(最多一组两腿) | +| `ledger_entries` | 资金流水 | +| `settings` | 配置 KV | +| `market_ticks` | 可选行情快照 | + +--- + +## 3. 前端结构 + +```text +frontend/src/pages/ +├── Plan.tsx # 自动对冲计划(主盘) +├── Trades.tsx # 交易记录(按组) +├── Stats.tsx # 统计 +└── Settings.tsx # 系统设置 + +frontend/src/components/ +├── Nav.tsx # 四项导航 +├── GroupBadge.tsx # 组 ID + 状态色 +├── PerpOrderBook.tsx # 永续盘口 +├── OptionOrderBook.tsx # Call/Put;卖一选向高亮、买一平仓 +├── PositionBar.tsx # 1 ETH + 2 ETH、浮盈、触发进度 +└── EquityChart.tsx # 统计页曲线 +``` + +导航文案固定: + +1. 自动对冲计划 +2. 交易记录 +3. 统计 +4. 系统设置 + +--- + +## 4. 部署相关文件 + +### 4.1 `deploy/ecosystem.config.cjs`(示意) + +```js +module.exports = { + apps: [ + { + name: 'eth-hedge-api', + cwd: '/opt/eth_hedge_sim/backend', + script: 'uvicorn', + args: 'app.main:app --host 0.0.0.0 --port 8100', + interpreter: 'python3', + env: { MODE: 'SIM' }, + }, + // 若前端独立静态服务可再加 eth-hedge-web;也可由 nginx 指到 frontend/dist + ], +}; +``` + +### 4.2 `deploy/pull_and_restart.sh`(原则) + +- 仅 `cd /opt/eth_hedge_sim && git pull` +- 安装依赖 / `frontend` build +- `pm2 startOrReload deploy/ecosystem.config.cjs --update-env` +- **禁止** `pm2 restart all`,**禁止** 调用现网 `crypto_monitor` 的部署脚本 + +--- + +## 5. 进程与端口(建议,可改) + +| 服务 | 端口 | PM2 名 | +|------|------|--------| +| API + WS | `8100` | `eth-hedge-api` | +| 前端(若独立) | `8101` 或 nginx 反代 | `eth-hedge-web` | + +与现网端口、进程名全部错开。 + +--- + +## 6. 配置与密钥 + +```text +.env.example # 提交到 git +.env # 仅服务器 / 本机,gitignore +``` + +关键项: + +- `MODE=SIM|LIVE` +- `OKX_API_KEY / SECRET / PASSPHRASE`(SIM 阶段只读权限即可) +- `FEE_RATE`(滑点自动 = 1 × FEE_RATE) +- `INITIAL_EQUITY` +- `MAX_ROUNDS=3` +- `OPEN_HHMM=16:00` / `STOP_OPEN_HHMM=08:00` +- `TZ=Asia/Shanghai` + +--- + +## 7. 从现网复制代码时的规则 + +| 允许 | 禁止 | +|------|------| +| 复制 OKX 行情订阅、签名、深度解析到本仓库 | 修改 `C:\Users\dekun\Desktop\crypto_monitor` 下任何文件 | +| 复制「买一流动性检查」思路后重写 | 把本项目塞进现网 monorepo 一起 PM2 | +| 新建本仓库的 `.env` | 默认使用现网交易 Key 做模拟(模拟不需要交易权限) | + +复制后在本仓库内自由修改;现网保持原样。 + +--- + +## 8. 建议落地顺序(与开发方案分期对应) + +1. 建 git 远程 → clone 到本机该目录与服务器 `/opt/eth_hedge_sim` +2. P0:`market/` 通行情 +3. P1:`sim/` 手动开平 +4. P2:`strategy/` 自动化 +5. P3:`frontend/` 四页 +6. `deploy/` PM2 上独立机 +7. P5:`live/` 实盘开关 + +--- + +## 9. 当前仓库已有文件 + +```text +eth_hedge_sim/ +└── docs/ + ├── 开发方案.md + └── 代码结构.md +``` + +负责人创建远程仓库后,将本目录作为首批提交即可;业务代码按上表目录逐步添加。 diff --git a/docs/开发方案.md b/docs/开发方案.md new file mode 100644 index 0000000..31eea61 --- /dev/null +++ b/docs/开发方案.md @@ -0,0 +1,221 @@ +# eth_hedge_sim — 自动对冲模拟盘开发方案 + +> 独立项目,与现网 `crypto_monitor` / `zk.hyf2.cc` **无部署、无进程、无密钥共用关系**。 +> 可参考现有仓库的实现思路或复制片段到本仓库,但 **禁止修改** `crypto_monitor` 内任何文件。 +> Git 仓库由负责人自行创建;本目录为本地工程骨架与文档。 + +--- + +## 1. 项目定位 + +| 项 | 约定 | +|----|------| +| 项目名 / 目录名 | `eth_hedge_sim` | +| 本机路径 | `C:\Users\dekun\Desktop\新建文件夹\eth_hedge_sim` | +| 云服务器 | **单独一台 Ubuntu**(或同机不同目录/不同 PM2 进程名,且与现网隔离) | +| 部署 | PM2 | +| 行情 | OKX **实盘只读** API(REST + WebSocket) | +| 成交(默认) | **本地模拟撮合 + 本地虚拟资金**(非 OKX 模拟盘) | +| 后期 | 支持切换 **实盘下单**(显式开关 + 二次确认) | + +### 1.1 硬边界 + +- 模拟阶段:**零交易类 API**(不下单、不撤单、不改单)。 +- 永续:全部 **市价**(不做限价)。 +- 期权:只吃 **买一 / 卖一**。 +- 仓位固定:永续 **1 ETH**,期权 **2 ETH** 名义(始终 2 倍,与权利金金额无关)。 +- 卖一比对 **仅用于选方向**;平仓用买一,并检查买一流动性。 + +--- + +## 2. 业务规则 + +### 2.1 时间与次数 + +- 期权合约:选 **次日 16:00** 到期。 +- 可开仓窗:业务日 **D 日 16:00** 起 → **D+1 日 08:00** 前。 +- **D+1 08:00 起禁止新开仓**(已有持仓仍按平仓规则处理,不强制到点清仓——若改规则在设置中可配)。 +- 每个业务窗最多 **3 轮**;同时最多 **1 组**仓;平完才能开下一组。 + +### 2.2 方向(Call 卖一 vs Put 卖一) + +| 条件 | 永续 | 期权 | +|------|------|------| +| Call 卖一 > Put 卖一 | 市价做多 1 ETH | 做空 2 ETH 名义 | +| Call 卖一 < Put 卖一 | 市价做空 1 ETH | 做多 2 ETH 名义 | +| 相等 | 不开仓,等待 | — | + +> **实现前待定稿**:期权「做多/做空」具体买卖 Call 还是 Put(或组合);行权价选择(建议默认 ATM / 最接近标记价的同一行权价)。 + +### 2.3 平仓(任一触发 → 该组全平) + +1. **权利金覆盖**:永续浮盈 ≥ 该组开仓锁定的 **期权初始权利金总额**(建议触发口径 **不含手续费**;费用单独记账)。此时期权侧通常仍有盈余/剩余价值,属预期内。 +2. **方向 30 点**:期权方向运行满 30 点 → 全平(**待定**:标的 ETH 点数 vs 权利金点数)。 + +平仓执行: + +- 永续:本地市价平仓。 +- 期权:吃买一;买一深度需覆盖 2 ETH 名义;不足则不成交并记「流动性不足」,默认继续等待。 + +### 2.4 组(Group)标识 + +每一轮开→平为一组,稳定 ID 例如:`G-YYYYMMDD-序号`(`G-20260724-01`)。 +前端、交易记录、统计全部按组聚合。 + +--- + +## 3. 本地撮合与费用 + +### 3.1 手续费与滑点 + +- 永续、期权 **均收取手续费**,费率可配置,默认按 OKX taker 档位。 +- **滑点 = 1 倍手续费**(费率 `f` 时,滑点幅度按 `f` 计入成交价;手续费另扣)。 + +### 3.2 永续(仅市价) + +本地市价定义(无真实交易所市价单时): + +- 开多 / 平空:基准 **卖一**,成交价 = 基准 × `(1 + f)`,再扣手续费。 +- 开空 / 平多:基准 **买一**,成交价 = 基准 × `(1 - f)`,再扣手续费。 +- UI 与引擎均不提供限价单。 + +### 3.3 期权(只吃买卖一) + +- 买入:吃 **卖一**,成交价 = 卖一 × `(1 + f)`,再扣费。 +- 卖出:吃 **买一**,成交价 = 买一 × `(1 - f)`,再扣费。 +- 开仓成交后锁定该组 **初始权利金总额**(按成交价 × 2 ETH 名义)。 + +### 3.4 本地账本 + +虚拟权益、可用、占用、持仓、成交、滑点、手续费、按组盈亏;持久化 SQLite(或等价本地 DB)。 +**不是** OKX 模拟盘余额。 + +--- + +## 4. 系统架构 + +``` +OKX 实盘只读行情 (WS/REST) + ↓ + market 行情网关(本地缓存/可选落盘) + ↓ + strategy 策略状态机(时间窗 / 选向 / 开平 / 3 次 / 组 ID) + ↓ + executor + ├─ SIM(默认)→ LocalMatcher + Ledger + └─ LIVE(后期)→ OkxTradeAdapter + ↓ + API + WebSocket 推送 + ↓ + Frontend(OKX 风格四页) +``` + +### 4.1 与现网隔离 + +| | crypto_monitor(现网) | eth_hedge_sim(本项目) | +|--|----------------------|------------------------| +| 代码仓 | 独立 | 独立(负责人自建 remote) | +| 服务器目录 | 如 `/opt/crypto_monitor` | 如 `/opt/eth_hedge_sim`(另定) | +| PM2 进程名 | 现有一套 | **新名字**,如 `eth-hedge-api` / `eth-hedge-web` | +| 域名/端口 | 现网 | 独立端口或独立域名 | +| 密钥 | 现网 `.env` | 本项目独立 `.env`(先只读行情 Key) | + +允许:从现网 **复制** 盘口解析、OKX WS 订阅、流动性检查等代码到本仓库后改。 +禁止:在现网仓库里改文件、共用 PM2 restart 脚本、共用生产 Key(除非只读 Key 故意共用且你知情)。 + +--- + +## 5. 前端 + +视觉:复刻 OKX 交易台观感(深色、盘口、持仓、紧凑数字)。 + +### 导航(4 项) + +1. **自动对冲计划** + 模式 SIM/LIVE、时间窗状态、当前组 ID、方向、1+2 仓位、初始权利金、永续浮盈、距触发差值、30 点进度、永续/期权盘口、启动暂停、紧急全平(模拟)。 + +2. **交易记录** + 按组列表与组内成交明细;筛选日期、平仓原因、盈亏。 + +3. **统计** + 组数、胜率、总盈亏、总手续费、总滑点、平仓原因分布、按组权益曲线。 + +4. **系统设置** + 虚拟资金、费率 `f`(滑点自动 1×f)、轮次与时间、30 点定义、选约规则、OKX Key、SIM/LIVE 开关(LIVE 二次确认)。 + +每组必须有明确标识(组 ID + 状态标签:持仓中 / 已平 / 流动性等待等)。 + +--- + +## 6. 部署(Ubuntu + PM2) + +### 6.1 建议目录 + +```text +/opt/eth_hedge_sim # git clone 目标 + .env # 仅本机/本项目,不进 git + deploy/ + ecosystem.config.cjs # PM2 配置 + pull_and_restart.sh # 仅重启本项目进程 +``` + +### 6.2 PM2 进程(示例名,可改) + +| 进程名 | 作用 | +|--------|------| +| `eth-hedge-api` | 行情网关 + 策略 + 本地撮合 + HTTP/WS API | +| `eth-hedge-web` | 前端静态或 Node 服务(若前后端合一可合并为一个进程) | + +原则:**不要** `pm2 restart all` 误伤现网;脚本里写死本项目进程名。 + +### 6.3 发布流程(负责人自建仓库后) + +```bash +cd /opt/eth_hedge_sim +git pull +# 安装依赖 / 构建前端(按实际栈) +pm2 startOrReload deploy/ecosystem.config.cjs --update-env +pm2 save +``` + +### 6.4 环境变量(示例) + +```bash +MODE=SIM +OKX_API_KEY=... # 模拟阶段只需能拉行情的权限 +OKX_API_SECRET=... +OKX_API_PASSPHRASE=... +FEE_RATE=0.0005 # 示例;滑点 = 1 × FEE_RATE +INITIAL_EQUITY=100000 +TZ=Asia/Shanghai +``` + +--- + +## 7. 分期实施 + +| 阶段 | 内容 | 完成标准 | +|------|------|----------| +| P0 | 仓库骨架、依赖、OKX 只读行情(永续 + 次日期权盘口) | 能稳定收到真盘口 | +| P1 | 本地账本 + 撮合(永续市价、期权吃一、费+1×费滑点) | 手动开平一组账目正确 | +| P2 | 策略状态机(选向、两套平仓、3 次、08:00 停开、组 ID) | 无 UI 也能跑完业务窗 | +| P3 | 四页前端 | 可完整操作与复盘 | +| P4 | 行情落盘与回放 | 重放结果与当时一致 | +| P5 | 实盘适配器 | 显式开关下最小仓验证 | + +--- + +## 8. 实现前待拍板 + +1. 期权多空的具体合约腿(Call / Put)。 +2. 「30 点」定义(标的 vs 权利金)。 +3. 行权价选择规则。 +4. 初始权利金触发是否不含手续费(建议不含)。 +5. Call 卖一 = Put 卖一时:跳过等待(建议)。 +6. 云服务器路径、域名/端口、PM2 进程最终命名。 + +--- + +## 9. 一句话 + +**独立仓 `eth_hedge_sim`:OKX 真行情只读 + 本地虚拟资金撮合(永续市价、期权只吃买卖一、滑点=1×手续费)+ OKX 风四页前端 + Ubuntu/PM2 单独部署;可抄现网思路,但不改现网代码、不共用现网进程。** diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..29e1315 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,18 @@ + + + + + + eth_hedge_sim + + + + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..989d416 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,1920 @@ +{ + "name": "eth-hedge-sim-web", + "version": "0.2.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "eth-hedge-sim-web", + "version": "0.2.0", + "dependencies": { + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-router-dom": "^7.1.0" + }, + "devDependencies": { + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react": "^4.3.4", + "typescript": "~5.7.2", + "vite": "^6.0.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@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.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@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.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "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.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "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, + "license": "MIT", + "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, + "license": "MIT", + "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, + "license": "MIT", + "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, + "license": "MIT" + }, + "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, + "license": "MIT", + "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, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "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, + "license": "MIT", + "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, + "license": "MIT", + "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, + "license": "MIT", + "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, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.2.17", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", + "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "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, + "license": "MIT", + "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.11.1", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.1.tgz", + "integrity": "sha512-HYXq73DDpCtNzOmrFsm9eSwCvWCql0RzqjpDzXN9EadiLJ4DNat0nsZ/Bzmy+Ud12mb4/zKDY0cQ805ZzN+i0A==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.7", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.7.tgz", + "integrity": "sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==", + "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" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.44", + "caniuse-lite": "^1.0.30001806", + "electron-to-chromium": "^1.5.393", + "node-releases": "^2.0.51", + "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.30001806", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz", + "integrity": "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==", + "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" + } + ], + "license": "CC-BY-4.0" + }, + "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, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.396", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.396.tgz", + "integrity": "sha512-yHiw2Y3C3H9U6TMbOfoWK/BPreiOPXRfTWPBwQBoZG6/8TB6eOPnsy5oaRYuatR7Fw2SJ4kKforgufeo7fq0EQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "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, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "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, + "license": "MIT", + "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, + "license": "MIT", + "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==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "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, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "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, + "license": "ISC", + "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, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.51", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz", + "integrity": "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "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, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.22", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.22.tgz", + "integrity": "sha512-KBDEIpLrvpv16pp3K0Fw+UCoZfopFjjgeB+0tA/aaThfEE74kKDLrgg603YvOWJyg3+WYtyq3xYsQWsIyZlPqQ==", + "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" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.16", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", + "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", + "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.8" + } + }, + "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, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "7.18.1", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.1.tgz", + "integrity": "sha512-GDLgg3i3uM0aeJO3Fm+TCS+sDQ7gu12T6x0qdTEzcwqEfleci7JwugVNIF3U//0FWKnJT7ptG+20B2jfDqnZAg==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.18.1", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.1.tgz", + "integrity": "sha512-KaZh+X/6UtEp28x51AUYZDMg9NGoz2ja3dNHa+ta/tk40vCzKhQ/RypCWBMLbmDr6//E24Vv5uPsrqXFozdkAg==", + "license": "MIT", + "dependencies": { + "react-router": "7.18.1" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "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, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "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, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "5.7.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz", + "integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "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" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.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 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "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, + "license": "ISC" + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..b5e9b57 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,23 @@ +{ + "name": "eth-hedge-sim-web", + "private": true, + "version": "0.2.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-router-dom": "^7.1.0" + }, + "devDependencies": { + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react": "^4.3.4", + "typescript": "~5.7.2", + "vite": "^6.0.0" + } +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..67824c0 --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,91 @@ +import type { ReactNode } from "react"; +import { NavLink, Navigate, Route, Routes } from "react-router-dom"; +import { clearSession, getToken, getUsername } from "./api/client"; +import LoginPage from "./pages/Login"; +import PlanPage from "./pages/Plan"; +import TradesPage from "./pages/Trades"; +import StatsPage from "./pages/Stats"; +import SettingsPage from "./pages/Settings"; + +function Shell({ children }: { children: ReactNode }) { + const user = getUsername(); + return ( +
+ +
{children}
+
+ ); +} + +function RequireAuth({ children }: { children: ReactNode }) { + if (!getToken()) return ; + return {children}; +} + +export default function App() { + return ( + + } /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + } /> + } /> + + ); +} diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts new file mode 100644 index 0000000..a10ea75 --- /dev/null +++ b/frontend/src/api/client.ts @@ -0,0 +1,110 @@ +const API_KEY = "eth_hedge_api_base"; +const TOKEN_KEY = "eth_hedge_token"; +const USER_KEY = "eth_hedge_user"; + +export function getApiBase(): string { + const saved = localStorage.getItem(API_KEY); + if (saved && saved.trim()) return saved.trim().replace(/\/$/, ""); + // same-origin default when UI is served by backend on :5155 + return window.location.origin; +} + +export function setApiBase(url: string) { + localStorage.setItem(API_KEY, url.trim().replace(/\/$/, "")); +} + +export function getToken(): string | null { + return localStorage.getItem(TOKEN_KEY); +} + +export function setSession(token: string, username: string) { + localStorage.setItem(TOKEN_KEY, token); + localStorage.setItem(USER_KEY, username); +} + +export function clearSession() { + localStorage.removeItem(TOKEN_KEY); + localStorage.removeItem(USER_KEY); +} + +export function getUsername(): string | null { + return localStorage.getItem(USER_KEY); +} + +export async function apiFetch( + path: string, + options: RequestInit = {}, +): Promise { + const base = getApiBase(); + const headers = new Headers(options.headers || {}); + if (!headers.has("Content-Type") && options.body) { + headers.set("Content-Type", "application/json"); + } + const token = getToken(); + if (token) headers.set("Authorization", `Bearer ${token}`); + + const res = await fetch(`${base}${path}`, { ...options, headers }); + if (res.status === 401) { + clearSession(); + throw new Error("unauthorized"); + } + const text = await res.text(); + let data: unknown = null; + try { + data = text ? JSON.parse(text) : null; + } catch { + data = { detail: text }; + } + if (!res.ok) { + const detail = + typeof data === "object" && data && "detail" in data + ? String((data as { detail: unknown }).detail) + : res.statusText; + throw new Error(detail || `HTTP ${res.status}`); + } + return data as T; +} + +export type LoginResult = { + token: string; + username: string; + expires_in: number; + env_name: string; + mode: string; +}; + +export async function login(username: string, password: string) { + return apiFetch("/api/auth/login", { + method: "POST", + body: JSON.stringify({ username, password }), + }); +} + +export type MarketSnapshot = { + connected: boolean; + updated_at_ms: number | null; + index_px: number | null; + pair: { + expiry_ymd: string; + strike: number; + call_inst_id: string; + put_inst_id: string; + } | null; + perp: Quote | null; + call: Quote | null; + put: Quote | null; + ask_compare: { + call_ask: number | null; + put_ask: number | null; + bias: string; + }; +}; + +type Quote = { + inst_id: string; + bid: number | null; + ask: number | null; + bid_sz: number | null; + ask_sz: number | null; + mark_px: number | null; +}; diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..aab0622 --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,13 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { BrowserRouter } from "react-router-dom"; +import App from "./App"; +import "./styles/app.css"; + +createRoot(document.getElementById("root")!).render( + + + + + , +); diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx new file mode 100644 index 0000000..415e68c --- /dev/null +++ b/frontend/src/pages/Login.tsx @@ -0,0 +1,79 @@ +import { FormEvent, useMemo, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { getApiBase, login, setApiBase, setSession } from "../api/client"; + +export default function LoginPage() { + const nav = useNavigate(); + const [apiBase, setApi] = useState(getApiBase()); + const [username, setUsername] = useState("admin"); + const [password, setPassword] = useState(""); + const [err, setErr] = useState(""); + const [loading, setLoading] = useState(false); + + const hint = useMemo( + () => "默认同域 API。跨机访问时填写如 http://47.236.184.99:5155", + [], + ); + + async function onSubmit(e: FormEvent) { + e.preventDefault(); + setErr(""); + setLoading(true); + try { + setApiBase(apiBase); + const res = await login(username.trim(), password); + setSession(res.token, res.username); + nav("/plan", { replace: true }); + } catch (ex) { + setErr(ex instanceof Error ? ex.message : String(ex)); + } finally { + setLoading(false); + } + } + + return ( +
+
+

eth_hedge_sim

+

模拟盘登录 · 可自定义 API 地址

+ {err ?
{err}
: null} +
+ + setApi(e.target.value)} + placeholder="http://host:5155" + autoComplete="url" + /> +
+
+ + setUsername(e.target.value)} + autoComplete="username" + required + /> +
+
+ + setPassword(e.target.value)} + autoComplete="current-password" + required + /> +
+ +
{hint}
+
+
+ ); +} diff --git a/frontend/src/pages/Plan.tsx b/frontend/src/pages/Plan.tsx new file mode 100644 index 0000000..aa8c624 --- /dev/null +++ b/frontend/src/pages/Plan.tsx @@ -0,0 +1,121 @@ +import { useEffect, useState } from "react"; +import { apiFetch, MarketSnapshot } from "../api/client"; + +function fmt(n: number | null | undefined, d = 2) { + if (n == null || Number.isNaN(n)) return "—"; + return n.toFixed(d); +} + +export default function PlanPage() { + const [snap, setSnap] = useState(null); + const [err, setErr] = useState(""); + + useEffect(() => { + let alive = true; + const load = async () => { + try { + const data = await apiFetch("/api/market/snapshot"); + if (alive) { + setSnap(data); + setErr(""); + } + } catch (e) { + if (alive) setErr(e instanceof Error ? e.message : String(e)); + } + }; + load(); + const t = window.setInterval(load, 2000); + return () => { + alive = false; + window.clearInterval(t); + }; + }, []); + + const bias = snap?.ask_compare?.bias; + const biasTag = + bias === "call_ask_gt_put" ? ( + Call卖一 > Put卖一 → 永续多+期权空 + ) : bias === "put_ask_gt_call" ? ( + Put卖一 > Call卖一 → 永续空+期权多 + ) : ( + 等待 / 相等 + ); + + return ( +
+

自动对冲计划

+

+ P0 行情只读 · 策略开平仓待拍板后接入 +

+ {err ?
{err}
: null} + +
+
+ 模式 + SIM · 测试环境 +
+
+ 行情连接 + {snap?.connected ? "WS 已连接" : "REST/未连"} +
+
+ 指数 + {fmt(snap?.index_px)} +
+
+ 选约 + + {snap?.pair + ? `${snap.pair.expiry_ymd} @ ${snap.pair.strike}` + : "—"} + +
+
+ 选向 + {biasTag} +
+
+ +
+
+

永续 ETH-USDT-SWAP

+
+ 买一 + {fmt(snap?.perp?.bid)} × {fmt(snap?.perp?.bid_sz, 2)} +
+
+ 卖一 + {fmt(snap?.perp?.ask)} × {fmt(snap?.perp?.ask_sz, 2)} +
+
+ 标记 + {fmt(snap?.perp?.mark_px)} +
+
+
+

期权 ATM

+
+ Call 卖一 + {fmt(snap?.call?.ask)} / 买一 {fmt(snap?.call?.bid)} +
+
+ Put 卖一 + {fmt(snap?.put?.ask)} / 买一 {fmt(snap?.put?.bid)} +
+
+ Call + + {snap?.pair?.call_inst_id || "—"} + +
+
+ Put + + {snap?.pair?.put_inst_id || "—"} + +
+
+
+
+ ); +} diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx new file mode 100644 index 0000000..215adf7 --- /dev/null +++ b/frontend/src/pages/Settings.tsx @@ -0,0 +1,38 @@ +import { FormEvent, useState } from "react"; +import { getApiBase, setApiBase } from "../api/client"; + +export default function SettingsPage() { + const [apiBase, setApi] = useState(getApiBase()); + const [saved, setSaved] = useState(false); + + function onSave(e: FormEvent) { + e.preventDefault(); + setApiBase(apiBase); + setSaved(true); + window.setTimeout(() => setSaved(false), 1500); + } + + return ( +
+

系统设置

+

+ 前端可单独指定后端 API;同机部署默认用当前域名端口 5155。 +

+
+
+ + setApi(e.target.value)} + /> +
+ + {saved ? 已保存 : null} +
+
+ ); +} diff --git a/frontend/src/pages/Stats.tsx b/frontend/src/pages/Stats.tsx new file mode 100644 index 0000000..2715cc6 --- /dev/null +++ b/frontend/src/pages/Stats.tsx @@ -0,0 +1,8 @@ +export default function StatsPage() { + return ( +
+

统计

+

胜率 / 盈亏 / 手续费曲线将在有成交后展示。

+
+ ); +} diff --git a/frontend/src/pages/Trades.tsx b/frontend/src/pages/Trades.tsx new file mode 100644 index 0000000..58173b6 --- /dev/null +++ b/frontend/src/pages/Trades.tsx @@ -0,0 +1,8 @@ +export default function TradesPage() { + return ( +
+

交易记录

+

按组成交明细将在 P1/P2 接入。

+
+ ); +} diff --git a/frontend/src/styles/app.css b/frontend/src/styles/app.css new file mode 100644 index 0000000..fc864ce --- /dev/null +++ b/frontend/src/styles/app.css @@ -0,0 +1,238 @@ +:root { + --bg: #0b0e11; + --bg-elev: #12161c; + --bg-panel: #151a21; + --line: #1e2630; + --text: #eaecef; + --muted: #848e9c; + --accent: #f0b90b; + --up: #0ecb81; + --down: #f6465d; + --input: #0f141a; + --danger: #f6465d; + font-family: "IBM Plex Sans", sans-serif; + color: var(--text); + background: var(--bg); +} + +* { + box-sizing: border-box; +} + +html, +body, +#root { + margin: 0; + min-height: 100%; +} + +body { + background: + radial-gradient(1200px 600px at 10% -10%, rgba(240, 185, 11, 0.08), transparent 55%), + radial-gradient(900px 500px at 100% 0%, rgba(14, 203, 129, 0.05), transparent 50%), + var(--bg); +} + +button, +input { + font: inherit; +} + +.mono { + font-family: "IBM Plex Mono", monospace; +} + +.app-shell { + min-height: 100vh; + display: grid; + grid-template-rows: auto 1fr; +} + +.topnav { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 16px; + border-bottom: 1px solid var(--line); + background: rgba(11, 14, 17, 0.92); + backdrop-filter: blur(8px); + position: sticky; + top: 0; + z-index: 10; +} + +.brand { + font-weight: 700; + letter-spacing: 0.02em; + margin-right: 12px; + color: var(--accent); +} + +.topnav a { + color: var(--muted); + text-decoration: none; + padding: 8px 12px; + border-radius: 6px; +} + +.topnav a.active { + color: var(--text); + background: var(--bg-panel); +} + +.topnav .spacer { + flex: 1; +} + +.topnav .meta { + color: var(--muted); + font-size: 12px; + margin-right: 8px; +} + +.page { + padding: 16px; + max-width: 1200px; + margin: 0 auto; + width: 100%; +} + +.card { + background: var(--bg-panel); + border: 1px solid var(--line); + border-radius: 10px; + padding: 16px; +} + +.grid-2 { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; +} + +@media (max-width: 800px) { + .grid-2 { + grid-template-columns: 1fr; + } +} + +.kv { + display: flex; + justify-content: space-between; + gap: 12px; + padding: 8px 0; + border-bottom: 1px solid var(--line); + font-size: 14px; +} + +.kv:last-child { + border-bottom: 0; +} + +.kv span:first-child { + color: var(--muted); +} + +.login-wrap { + min-height: 100vh; + display: grid; + place-items: center; + padding: 24px; +} + +.login-box { + width: min(420px, 100%); + background: var(--bg-elev); + border: 1px solid var(--line); + border-radius: 14px; + padding: 28px 24px; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.35); +} + +.login-box h1 { + margin: 0 0 6px; + font-size: 22px; +} + +.login-box p { + margin: 0 0 20px; + color: var(--muted); + font-size: 13px; +} + +.field { + display: grid; + gap: 6px; + margin-bottom: 14px; +} + +.field label { + font-size: 12px; + color: var(--muted); +} + +.field input { + background: var(--input); + border: 1px solid var(--line); + color: var(--text); + border-radius: 8px; + padding: 10px 12px; + outline: none; +} + +.field input:focus { + border-color: rgba(240, 185, 11, 0.55); +} + +.btn { + border: 0; + border-radius: 8px; + padding: 10px 14px; + cursor: pointer; + background: var(--accent); + color: #111; + font-weight: 600; +} + +.btn.ghost { + background: transparent; + color: var(--muted); + border: 1px solid var(--line); +} + +.btn.block { + width: 100%; +} + +.err { + color: var(--danger); + font-size: 13px; + margin: 0 0 12px; +} + +.hint { + margin-top: 12px; + color: var(--muted); + font-size: 12px; + line-height: 1.5; +} + +.tag { + display: inline-flex; + align-items: center; + padding: 2px 8px; + border-radius: 999px; + font-size: 11px; + border: 1px solid var(--line); + color: var(--muted); +} + +.tag.up { + color: var(--up); + border-color: rgba(14, 203, 129, 0.35); +} + +.tag.down { + color: var(--down); + border-color: rgba(246, 70, 93, 0.35); +} diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/frontend/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..c351008 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..3d0434e --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,17 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; + +export default defineConfig({ + plugins: [react()], + server: { + port: 5173, + proxy: { + "/api": "http://127.0.0.1:5155", + "/health": "http://127.0.0.1:5155", + }, + }, + build: { + outDir: "dist", + emptyOutDir: true, + }, +}); diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..63f1582 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,10 @@ +fastapi>=0.115.0 +uvicorn[standard]>=0.32.0 +httpx>=0.27.0 +websockets>=13.0 +python-socks[asyncio]>=2.5.0 +pydantic>=2.9.0 +pydantic-settings>=2.5.0 +python-dotenv>=1.0.1 +tzdata>=2024.1 +pytest>=8.3.0 diff --git a/scripts/deploy_remote.py b/scripts/deploy_remote.py new file mode 100644 index 0000000..bfc83da --- /dev/null +++ b/scripts/deploy_remote.py @@ -0,0 +1,69 @@ +#!/usr/bin/env python3 +"""从本机 SSH 触发服务器一键更新(服务器内 git pull,不 scp 代码)。 + +环境变量(可选): + DEPLOY_HOST 默认 47.236.184.99 + DEPLOY_USER 默认 root + DEPLOY_PASS 服务器密码 + DEPLOY_ROOT 默认 /opt/eth_hedge_sim + REPO_URL 默认 https://git.bz121.com/dekun/eth_hedge_sim.git +""" + +from __future__ import annotations + +import os +import sys + +HOST = os.environ.get("DEPLOY_HOST", "47.236.184.99") +USER = os.environ.get("DEPLOY_USER", "root") +PASSWORD = os.environ.get("DEPLOY_PASS", "") +ROOT = os.environ.get("DEPLOY_ROOT", "/opt/eth_hedge_sim") +REPO = os.environ.get("REPO_URL", "https://git.bz121.com/dekun/eth_hedge_sim.git") + + +def main() -> int: + if not PASSWORD: + print("Set DEPLOY_PASS env var (do not commit password).", file=sys.stderr) + return 2 + try: + import paramiko + except ImportError: + print("pip install paramiko", file=sys.stderr) + return 2 + + remote = f""" +set -euo pipefail +export DEBIAN_FRONTEND=noninteractive +if ! command -v git >/dev/null; then apt-get update -y && apt-get install -y git; fi +if ! command -v python3 >/dev/null; then apt-get update -y && apt-get install -y python3 python3-venv python3-pip; fi +if ! command -v node >/dev/null; then + curl -fsSL https://deb.nodesource.com/setup_20.x | bash - + apt-get install -y nodejs +fi +if [[ ! -d '{ROOT}/.git' ]]; then + mkdir -p /opt + git clone '{REPO}' '{ROOT}' +fi +bash '{ROOT}/deploy/pull_and_restart.sh' +curl -fsS http://127.0.0.1:5155/health || true +""" + + client = paramiko.SSHClient() + client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + print(f"connecting {USER}@{HOST} ...") + client.connect(HOST, username=USER, password=PASSWORD, timeout=30) + print("running remote bootstrap/pull ...") + stdin, stdout, stderr = client.exec_command(remote, get_pty=True) + for line in iter(stdout.readline, ""): + print(line, end="") + err = stderr.read().decode("utf-8", errors="ignore") + code = stdout.channel.recv_exit_status() + if err: + print(err, file=sys.stderr) + client.close() + print(f"exit={code}") + return code + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/smoke_market.py b/scripts/smoke_market.py new file mode 100644 index 0000000..298568b --- /dev/null +++ b/scripts/smoke_market.py @@ -0,0 +1,75 @@ +#!/usr/bin/env python3 +"""P0 行情连通性烟测:REST 对齐次日 ATM Call/Put,可选挂 WS 数秒。 + +用法(仓库根目录): + python scripts/smoke_market.py + python scripts/smoke_market.py --ws-seconds 8 +""" + +from __future__ import annotations + +import argparse +import asyncio +import json +import sys +import time +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +BACKEND = ROOT / "backend" +sys.path.insert(0, str(BACKEND)) + +from app.config import get_settings # noqa: E402 +from app.market import MarketGateway # noqa: E402 + + +async def main() -> int: + parser = argparse.ArgumentParser(description="eth_hedge_sim P0 market smoke") + parser.add_argument("--ws-seconds", type=float, default=5.0, help="WS listen seconds") + args = parser.parse_args() + + settings = get_settings() + print(f"MODE={settings.mode} REST={settings.okx_rest_base}") + print(f"perp={settings.perp_inst_id} family={settings.option_inst_family}") + + gw = MarketGateway(settings) + t0 = time.time() + try: + pair = await asyncio.to_thread(gw.align_instruments) + except Exception as e: + print(f"FAIL align: {e}") + gw.rest.close() + return 1 + + print(f"aligned in {time.time() - t0:.2f}s") + print(json.dumps(pair.to_dict() if pair else None, ensure_ascii=False, indent=2)) + snap = gw.snapshot_dict() + print("--- REST snapshot ---") + print(json.dumps(snap, ensure_ascii=False, indent=2)) + + if args.ws_seconds > 0: + print(f"--- WS listen {args.ws_seconds}s ---") + await gw.ws.start() + await asyncio.sleep(args.ws_seconds) + snap2 = gw.snapshot_dict() + print(json.dumps(snap2, ensure_ascii=False, indent=2)) + await gw.ws.stop() + + gw.rest.close() + + ok = ( + snap.get("perp", {}) or {} + ).get("ask") is not None and ( + snap.get("call", {}) or {} + ).get("ask") is not None and ( + snap.get("put", {}) or {} + ).get("ask") is not None + if not ok: + print("FAIL: missing bid/ask on perp/call/put") + return 2 + print("OK: market smoke passed") + return 0 + + +if __name__ == "__main__": + raise SystemExit(asyncio.run(main()))