From 9c3bee87ac2383298d418cf89e12c295a582604b Mon Sep 17 00:00:00 2001 From: matitos Date: Fri, 18 Sep 2026 17:02:33 +0000 Subject: [PATCH] Crypto trading bot: engine, brokers, backtester, dashboard, deploy FastAPI backend with a single-strategy async engine, paper and Binance brokers, pure-python indicators, a backtester that shares the engine's portfolio math, and a Vue dashboard with a live WebSocket feed. Adds the Gitea Actions CI/CD workflow that deploys to Traefik via docker_default. --- .env.example | 38 + .gitea/workflows/deploy.yml | 229 + .gitignore | 28 + AGENTS.md | 195 + Makefile | 45 + README.md | 154 +- backend/.dockerignore | 11 + backend/Dockerfile | 17 + backend/app/__init__.py | 0 backend/app/api/__init__.py | 0 backend/app/api/routes.py | 155 + backend/app/backtest/__init__.py | 6 + backend/app/backtest/backtester.py | 172 + backend/app/backtest/metrics.py | 83 + backend/app/broker/__init__.py | 20 + backend/app/broker/base.py | 36 + backend/app/broker/binance.py | 75 + backend/app/broker/paper.py | 38 + backend/app/config.py | 65 + backend/app/db.py | 38 + backend/app/engine/__init__.py | 0 backend/app/engine/engine.py | 390 ++ backend/app/engine/indicators.py | 120 + backend/app/engine/portfolio.py | 100 + backend/app/engine/strategies/__init__.py | 35 + backend/app/engine/strategies/base.py | 217 + .../app/engine/strategies/rsi_reversion.py | 65 + backend/app/engine/strategies/sma_cross.py | 48 + backend/app/main.py | 46 + backend/app/market/__init__.py | 16 + backend/app/market/base.py | 20 + backend/app/market/binance.py | 52 + backend/app/market/fake.py | 46 + backend/app/models.py | 121 + backend/app/runtime.py | 140 + backend/app/schemas.py | 179 + backend/app/store.py | 173 + backend/pytest.ini | 4 + backend/requirements-dev.txt | 5 + backend/requirements.txt | 11 + backend/ruff.toml | 9 + backend/tests/conftest.py | 63 + backend/tests/test_api.py | 84 + backend/tests/test_backtest.py | 142 + backend/tests/test_engine.py | 110 + backend/tests/test_indicators.py | 40 + backend/tests/test_portfolio.py | 59 + backend/tests/test_strategies.py | 68 + docker-compose.local.yml | 37 + docker-compose.yml | 100 + frontend/.dockerignore | 6 + frontend/Dockerfile | 12 + frontend/index.html | 12 + frontend/nginx.conf | 20 + frontend/package-lock.json | 4906 +++++++++++++++++ frontend/package.json | 26 + frontend/postcss.config.js | 6 + frontend/src/App.vue | 164 + frontend/src/api.js | 45 + frontend/src/components/BacktestPanel.vue | 140 + frontend/src/components/EquityChart.vue | 47 + frontend/src/components/EventLog.vue | 27 + frontend/src/components/OrdersTable.vue | 42 + frontend/src/components/PositionsTable.vue | 77 + frontend/src/components/StatusCards.vue | 32 + frontend/src/components/StrategyForm.vue | 122 + frontend/src/components/StrategyPanel.vue | 38 + frontend/src/format.js | 36 + frontend/src/format.test.js | 28 + frontend/src/main.js | 5 + frontend/src/style.css | 30 + frontend/tailwind.config.js | 14 + frontend/vite.config.js | 20 + 73 files changed, 9759 insertions(+), 1 deletion(-) create mode 100644 .env.example create mode 100644 .gitea/workflows/deploy.yml create mode 100644 .gitignore create mode 100644 AGENTS.md create mode 100644 Makefile create mode 100644 backend/.dockerignore create mode 100644 backend/Dockerfile create mode 100644 backend/app/__init__.py create mode 100644 backend/app/api/__init__.py create mode 100644 backend/app/api/routes.py create mode 100644 backend/app/backtest/__init__.py create mode 100644 backend/app/backtest/backtester.py create mode 100644 backend/app/backtest/metrics.py create mode 100644 backend/app/broker/__init__.py create mode 100644 backend/app/broker/base.py create mode 100644 backend/app/broker/binance.py create mode 100644 backend/app/broker/paper.py create mode 100644 backend/app/config.py create mode 100644 backend/app/db.py create mode 100644 backend/app/engine/__init__.py create mode 100644 backend/app/engine/engine.py create mode 100644 backend/app/engine/indicators.py create mode 100644 backend/app/engine/portfolio.py create mode 100644 backend/app/engine/strategies/__init__.py create mode 100644 backend/app/engine/strategies/base.py create mode 100644 backend/app/engine/strategies/rsi_reversion.py create mode 100644 backend/app/engine/strategies/sma_cross.py create mode 100644 backend/app/main.py create mode 100644 backend/app/market/__init__.py create mode 100644 backend/app/market/base.py create mode 100644 backend/app/market/binance.py create mode 100644 backend/app/market/fake.py create mode 100644 backend/app/models.py create mode 100644 backend/app/runtime.py create mode 100644 backend/app/schemas.py create mode 100644 backend/app/store.py create mode 100644 backend/pytest.ini create mode 100644 backend/requirements-dev.txt create mode 100644 backend/requirements.txt create mode 100644 backend/ruff.toml create mode 100644 backend/tests/conftest.py create mode 100644 backend/tests/test_api.py create mode 100644 backend/tests/test_backtest.py create mode 100644 backend/tests/test_engine.py create mode 100644 backend/tests/test_indicators.py create mode 100644 backend/tests/test_portfolio.py create mode 100644 backend/tests/test_strategies.py create mode 100644 docker-compose.local.yml create mode 100644 docker-compose.yml create mode 100644 frontend/.dockerignore create mode 100644 frontend/Dockerfile create mode 100644 frontend/index.html create mode 100644 frontend/nginx.conf create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/postcss.config.js create mode 100644 frontend/src/App.vue create mode 100644 frontend/src/api.js create mode 100644 frontend/src/components/BacktestPanel.vue create mode 100644 frontend/src/components/EquityChart.vue create mode 100644 frontend/src/components/EventLog.vue create mode 100644 frontend/src/components/OrdersTable.vue create mode 100644 frontend/src/components/PositionsTable.vue create mode 100644 frontend/src/components/StatusCards.vue create mode 100644 frontend/src/components/StrategyForm.vue create mode 100644 frontend/src/components/StrategyPanel.vue create mode 100644 frontend/src/format.js create mode 100644 frontend/src/format.test.js create mode 100644 frontend/src/main.js create mode 100644 frontend/src/style.css create mode 100644 frontend/tailwind.config.js create mode 100644 frontend/vite.config.js diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..b5c2bc5 --- /dev/null +++ b/.env.example @@ -0,0 +1,38 @@ +# --- Core --- +APP_NAME=Crypto Trader +ENV=dev +# API host/port +HOST=0.0.0.0 +PORT=8000 + +# --- Persistence --- +# SQLite by default. Point at Postgres with e.g. +# postgresql+psycopg://user:pass@localhost:5432/trader +DATABASE_URL=sqlite:///./data/trader.db + +# --- Trading defaults --- +# paper = simulated fills against live market data (safe default) +# live = real orders via the exchange +MODE=paper +EXCHANGE=binance +SYMBOL=BTC/USDT +TIMEFRAME=1m +# How often the engine polls for new candles, in seconds +POLL_INTERVAL=5 +# Candles fetched per tick and kept for indicators +CANDLE_LIMIT=200 + +# Paper broker +STARTING_BALANCE=10000 +FEE_BPS=10 +SLIPPAGE_BPS=5 + +# --- Exchange credentials (only needed for MODE=live) --- +EXCHANGE_API_KEY= +EXCHANGE_API_SECRET= +# Set true for testnet/sandbox where supported +EXCHANGE_SANDBOX=false + +# --- Web --- +# Comma separated list, or * in dev +CORS_ORIGINS=* diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml new file mode 100644 index 0000000..1dd27e1 --- /dev/null +++ b/.gitea/workflows/deploy.yml @@ -0,0 +1,229 @@ +name: CI/CD + +on: + workflow_dispatch: + pull_request: + branches: [main] + paths-ignore: + - '**.md' + push: + branches: [main] + paths-ignore: + - '**.md' + +# Never let two deploys interleave (build/up/rollback flapping). +concurrency: + group: crypto-trading-deploy + cancel-in-progress: false + +jobs: + backend: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: "pip" + cache-dependency-path: backend/requirements.txt + + - name: Install dependencies + run: pip install -r backend/requirements-dev.txt + + - name: Lint (ruff) + run: | + cd backend + ruff check . + ruff format --check . + + - name: Run tests + run: cd backend && python -m pytest -q + + frontend: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: "22" + cache: "npm" + cache-dependency-path: frontend/package-lock.json + + - name: Install dependencies + run: cd frontend && npm ci + + - name: Test + run: cd frontend && npm test + + - name: Build + run: cd frontend && npm run build + + build: + runs-on: ubuntu-latest + needs: [backend, frontend] + if: github.ref == 'refs/heads/main' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') + steps: + - uses: actions/checkout@v4 + + - name: Log in to Docker Hub (optional) + env: + DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} + DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} + run: | + if [ -n "$DOCKERHUB_USERNAME" ] && [ -n "$DOCKERHUB_TOKEN" ]; then + echo "$DOCKERHUB_TOKEN" | docker login -u "$DOCKERHUB_USERNAME" --password-stdin + else + echo "Docker Hub credentials not set; using anonymous pulls" + fi + + - name: Build backend + run: | + for i in 1 2 3; do + docker build -t crypto_trading-backend ./backend && exit 0 + echo "backend build failed (attempt $i); retrying in 30s"; sleep 30 + done + exit 1 + + - name: Build frontend + run: | + for i in 1 2 3; do + docker build -t crypto_trading-frontend ./frontend && exit 0 + echo "frontend build failed (attempt $i); retrying in 30s"; sleep 30 + done + exit 1 + + deploy: + runs-on: ubuntu-latest + needs: build + if: github.ref == 'refs/heads/main' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') + env: + BASE_DOMAIN: ${{ secrets.BASE_DOMAIN || 'matitos.org' }} + # htpasswd string for the WebUI. Prefer the crypto-trading specific + # variable/secret, falling back to the shared org-level SCA users. The + # compose basicauth middleware is fail-closed, so it must be present for + # every `docker compose` command in this job to interpolate the file. + CRYPTO_FRONTEND_AUTH_USERS: ${{ vars.CRYPTO_FRONTEND_AUTH_USERS || secrets.CRYPTO_FRONTEND_AUTH_USERS || vars.SCA_FRONTEND_AUTH_USERS || secrets.SCA_FRONTEND_AUTH_USERS }} + CRYPTO_MODE: ${{ vars.CRYPTO_MODE || 'paper' }} + CRYPTO_EXCHANGE: ${{ vars.CRYPTO_EXCHANGE || 'binance' }} + CRYPTO_SYMBOL: ${{ vars.CRYPTO_SYMBOL || 'BTC/USDT' }} + CRYPTO_TIMEFRAME: ${{ vars.CRYPTO_TIMEFRAME || '1m' }} + CRYPTO_POLL_INTERVAL: ${{ vars.CRYPTO_POLL_INTERVAL || '5' }} + CRYPTO_STARTING_BALANCE: ${{ vars.CRYPTO_STARTING_BALANCE || '10000' }} + CRYPTO_CORS_ORIGINS: ${{ vars.CRYPTO_CORS_ORIGINS || format('https://crypto.{0}', secrets.BASE_DOMAIN || 'matitos.org') }} + CRYPTO_EXCHANGE_API_KEY: ${{ secrets.CRYPTO_EXCHANGE_API_KEY }} + CRYPTO_EXCHANGE_API_SECRET: ${{ secrets.CRYPTO_EXCHANGE_API_SECRET }} + CRYPTO_EXCHANGE_SANDBOX: ${{ vars.CRYPTO_EXCHANGE_SANDBOX || 'false' }} + CRYPTO_AUTH_RATE_LIMIT_AVERAGE: ${{ vars.CRYPTO_AUTH_RATE_LIMIT_AVERAGE || '10' }} + CRYPTO_AUTH_RATE_LIMIT_BURST: ${{ vars.CRYPTO_AUTH_RATE_LIMIT_BURST || '5' }} + # Persistent data root: repo var wins, else derive from the org + # PERSISTENT_MOUNT (absolute HOST path), else the workspace fallback. + CRYPTO_DATA_DIR: ${{ vars.CRYPTO_DATA_DIR || (vars.PERSISTENT_MOUNT && format('{0}/crypto_trading', vars.PERSISTENT_MOUNT)) || './data' }} + DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} + DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} + steps: + - uses: actions/checkout@v4 + + - name: Require CRYPTO_FRONTEND_AUTH_USERS + run: | + # The compose basicauth middleware is fail-closed, so refuse early + # with a clear message instead of starting an unauthenticated UI that + # can control a trading bot. + if [ -z "$CRYPTO_FRONTEND_AUTH_USERS" ]; then + echo "::error::CRYPTO_FRONTEND_AUTH_USERS is not set (repo/org variable or secret); refusing to deploy" + exit 1 + fi + + - name: Log in to Docker Hub (optional) + run: | + if [ -n "$DOCKERHUB_USERNAME" ] && [ -n "$DOCKERHUB_TOKEN" ]; then + echo "$DOCKERHUB_TOKEN" | docker login -u "$DOCKERHUB_USERNAME" --password-stdin + else + echo "Docker Hub credentials not set; using anonymous pulls" + fi + + - name: Deploy with Docker Compose + run: | + echo "Deploying to crypto.${BASE_DOMAIN} (mode=${CRYPTO_MODE})" + echo "Data dir: ${CRYPTO_DATA_DIR}" + # Keep the currently-running images so we can roll back on failure. + for img in crypto_trading-backend crypto_trading-frontend; do + if docker image inspect "$img" >/dev/null 2>&1; then + docker tag "$img" "${img}:prev" + fi + done + built=0 + for i in 1 2 3 4 5; do + if docker compose build; then built=1; break; fi + echo "docker compose build failed (attempt $i); retrying in 30s" + sleep 30 + done + [ "$built" = "1" ] || { echo "::error::compose build failed after retries"; exit 1; } + # Bring the new stack up and wait for healthchecks. No pre-`down`, so + # the old containers keep serving until the new ones are healthy. + if ! docker compose up -d --remove-orphans --wait --wait-timeout 300; then + echo "::error::New stack is unhealthy; rolling back to previous images" + docker compose down --remove-orphans || true + for img in crypto_trading-backend crypto_trading-frontend; do + if docker image inspect "${img}:prev" >/dev/null 2>&1; then + docker tag "${img}:prev" "$img" + fi + done + docker compose up -d --remove-orphans --wait --wait-timeout 300 || true + exit 1 + fi + + - name: Services and recent logs + run: | + docker compose ps + docker compose logs --tail=80 + + - name: Health check (HTTP) + run: | + probe_backend() { + docker compose exec -T backend python -c \ + "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8000/api/health', timeout=5).status==200 else 1)" + } + probe_frontend() { + docker compose exec -T frontend wget -q --spider http://127.0.0.1:80/ + } + for i in 1 2 3 4 5 6; do + echo "Attempt $i/6..." + if probe_backend && probe_frontend; then + echo "All services healthy" + exit 0 + fi + sleep 15 + done + echo "::error::Health checks failed; rolling back" + docker compose down --remove-orphans || true + for img in crypto_trading-backend crypto_trading-frontend; do + if docker image inspect "${img}:prev" >/dev/null 2>&1; then + docker tag "${img}:prev" "$img" + fi + done + docker compose up -d --remove-orphans --wait --wait-timeout 300 || true + exit 1 + + cleanup: + runs-on: ubuntu-latest + needs: [backend, frontend] + if: github.ref == 'refs/heads/main' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Delete branches merged into main + run: | + git fetch --all --prune + git branch -r --merged origin/main \ + | grep -vE 'origin/(main|HEAD)([[:space:]]|$)' \ + | sed -E 's|^[[:space:]]*origin/||' \ + | while read -r branch; do + [ -z "$branch" ] && continue + echo "Deleting merged branch: $branch" + git push origin --delete "$branch" || echo " (could not delete $branch)" + done diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5c852c1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,28 @@ +# Python +__pycache__/ +*.py[cod] +.venv/ +venv/ +*.egg-info/ +.pytest_cache/ +.ruff_cache/ +.mypy_cache/ + +# Data / runtime +data/ +*.db +*.sqlite3 +*.log + +# Env / secrets +.env + +# Frontend +node_modules/ +frontend/dist/ +frontend/.vite/ + +# Editors / OS +.vscode/ +.idea/ +.DS_Store diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..b40cc50 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,195 @@ +# AGENTS.md + +Guidance for agents (and humans) working in this repository. + +## What this is + +A small, hackable crypto trading bot. A FastAPI backend runs one strategy at a +time against market data and executes orders through a paper or live broker. A +Vue dashboard shows status, positions, orders, P&L and a live activity feed. + +The primary workflow is: **describe a strategy in plain English → add one small +Python file → run it from the UI.** + +## Golden rules + +1. **Paper by default.** Never switch the default `MODE` to `live`. Live trading + requires explicit user action and API keys. +2. **Strategies are pure and synchronous.** Compute signals in `on_candle` and + call `ctx.enter_long` / `ctx.enter_short` / `ctx.close`. Never place orders, + touch the DB, or make network calls from a strategy. +3. **One file per strategy** in `backend/app/engine/strategies/`. It is + auto-discovered — no central registry to edit. +4. **Keep dependencies light.** The backend deliberately avoids `ccxt` (and its + `aiohttp` build dependency). Market data and live orders use `httpx`. +5. **Run the checks before finishing** (see Commands). + +## Branching & delivery + +- Always work on a **feature branch**; never commit directly to `main`. +- `main` is the deployment branch: merging/pushing to `main` runs + `.gitea/workflows/deploy.yml` (Gitea Actions) which tests, builds images and + deploys the stack. Doc-only changes (`**.md`) skip test/deploy via + `paths-ignore`. +- **After a merged PR, delete the obsolete branch** (`git push origin --delete + ` plus local `git branch -d` and `git fetch --prune`). Gitea does not + auto-delete. The workflow's `cleanup` job also prunes merged branches. +- Never commit when not explicitly asked. + +### Deployment shape + +- `docker-compose.yml` is the **production** stack. It joins the external + `docker_default` network and is published by the shared Traefik instance at + `crypto.${BASE_DOMAIN}` (default `matitos.org`). The **frontend** carries the + Traefik labels; the **backend** is internal-only and reached through the + frontend nginx `/api` proxy (which also carries the WebSocket). +- `docker-compose.local.yml` is the self-contained local stack (`make up`); it + needs no Traefik and publishes ports. +- The WebUI uses **fail-closed Basic auth** via the Traefik basicauth + middleware. The deploy job refuses to run unless `CRYPTO_FRONTEND_AUTH_USERS` + (or the shared `SCA_FRONTEND_AUTH_USERS`) is set. Because a JS WebSocket + cannot always replay Basic credentials, the dashboard also polls every 5s as a + fallback — keep that fallback when editing `App.vue`. +- `CRYPTO_MODE` defaults to `paper`. Never change the default to `live`. + +## Adding a strategy (the main task) + +Create `backend/app/engine/strategies/.py`: + +```python +from __future__ import annotations + +from typing import ClassVar + +from app.engine.strategies.base import Strategy, StrategyContext, register +from app.models import PositionSide + + +@register +class MyStrategy(Strategy): + key: ClassVar[str] = "my_strategy" # snake_case, unique, used by the API + name: ClassVar[str] = "My Strategy" # shown in the UI + description: ClassVar[str] = "One sentence explaining the entry/exit logic." + + # Parameters become form fields in the UI. Types are inferred from defaults. + default_params: ClassVar[dict] = {"period": 14, "size": 1.0} + param_help: ClassVar[dict] = {"period": "Lookback", "size": "Fraction of equity"} + param_bounds: ClassVar[dict] = {"period": (2, 200), "size": (0.01, 1.0)} + + def on_candle(self, ctx: StrategyContext) -> None: + value = ctx.rsi(int(self.params["period"])) + if value is None: + return + if ctx.is_flat and value < 30: + ctx.enter_long(size=float(self.params["size"]), stop_loss=ctx.price * 0.98) + elif ctx.side == PositionSide.LONG and value > 70: + ctx.close() +``` + +`StrategyContext` gives you: + +| Area | API | +| --- | --- | +| Price / candles | `ctx.price`, `ctx.candles`, `ctx.closes`, `ctx.high(n)`, `ctx.low(n)` | +| Portfolio | `ctx.is_flat`, `ctx.side`, `ctx.position`, `ctx.equity`, `ctx.cash` | +| Indicators | `ctx.sma(p)`, `ctx.ema(p)`, `ctx.rsi(p)`, `ctx.atr(p)`, `ctx.std(p)` | +| Orders | `ctx.enter_long(size, stop_loss, take_profit, reason)`, `ctx.enter_short(...)`, `ctx.close(reason)` | + +Notes: + +- `size` is a fraction of equity (1.0 = all-in). It is optional; defaults to 1.0. +- `stop_loss` / `take_profit` are absolute prices; the engine enforces them. +- Only one position is open at a time. Intents that conflict are ignored (logged + via the engine status, not raised). +- Raise nothing for "not enough data" — just `return` early, as above. +- Need a raw indicator series? Import from `app.engine.indicators` + (`sma`, `ema`, `rsi`, `atr`, `rolling_std`); each returns a list aligned to the + input with `None` during warmup. + +### Verify the strategy + +Add a focused test in `backend/tests/` that builds a `StrategyContext` with +candles you control and asserts the emitted intents. See +`tests/test_strategies.py` for the pattern. Then run the checks. + +## Engine model (how execution works) + +- One `TradingEngine` runs a single strategy on a symbol/timeframe. +- Every `POLL_INTERVAL` seconds it fetches `CANDLE_LIMIT` candles, updates the + mark price, enforces stop/take levels, calls `on_candle`, then executes the + emitted intents. +- `PaperBroker` simulates fills with configurable `FEE_BPS` and `SLIPPAGE_BPS`. + `BinanceBroker` places real market orders (requires keys, only in `MODE=live`). +- Portfolio invariant: `equity = cash ± qty * mark`, realized P&L accumulates on + close, and every order/equity point/event is persisted. + +## Backtesting + +`app/backtest/` replays historical candles through the *same* strategy +interface. Crucially, it shares `app/engine/portfolio.py` (cash deltas, realized/ +unrealized P&L, position sizing, stop/take exits) with the live engine, so a +strategy behaves identically in backtest, paper and live. When you change +portfolio math, change it **only** in `app/engine/portfolio.py`; both paths use +it and `tests/test_portfolio.py` covers it. + +`POST /api/backtest` fetches candles from the configured feed and returns +metrics (total return, buy & hold, max drawdown, win rate, profit factor, +Sharpe), the equity curve and the trade list. The dashboard's **Backtest** panel +renders all of it. Add a strategy-focused test in `tests/test_backtest.py` if the +behaviour is non-obvious. + +## Commands + +Backend (run from `backend/`): + +```bash +python -m pytest # tests +ruff check . # lint +ruff format . # format +``` + +Frontend (run from `frontend/`): + +```bash +npm test # vitest +npm run build # production build +``` + +Whole repo: `make test`, `make lint`, `make fmt`, `make build`. + +## Layout + +``` +backend/app/ + main.py FastAPI app factory + lifespan + api/routes.py REST + WebSocket endpoints + runtime.py active engine + pub/sub fan-out to sockets + engine/ + engine.py trading loop, portfolio, risk + portfolio.py shared cash/P&L/sizing/risk math (engine + backtest) + context.py -> base.py (StrategyContext) lives in strategies/base.py + indicators.py pure-python indicators + strategies/ <-- add strategies here (auto-discovered) + backtest/ historical replay + metrics + broker/ paper + binance brokers + market/ binance + fake data feeds + models.py/store.py SQLAlchemy models and persistence +frontend/src/ Vue dashboard (App.vue + components/) +docker-compose.yml production (Traefik + docker_default) +docker-compose.local.yml local, self-contained +.gitea/workflows/ CI/CD (deploy on main) +``` + +## Local environment notes + +This checkout was bootstrapped in a minimal Alpine sandbox. If `python` or +`node` are not found, the toolchain lives here: + +```bash +export LD_LIBRARY_PATH=/home/opencode/.local/apkroot/usr/lib +export PATH=/home/opencode/.local/apkroot/usr/bin:$PATH +export NODE_ICU_DATA=/home/opencode/.local/apkroot/usr/share/icu/78.1/icudt78l.dat +``` + +The backend virtualenv is `.venv/` at the repo root. Postgres is optional — +SQLite works out of the box. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..466b9ce --- /dev/null +++ b/Makefile @@ -0,0 +1,45 @@ +PY := .venv/bin/python +BACKEND := backend +FRONTEND := frontend + +.PHONY: install dev-backend dev-frontend test test-backend test-frontend lint fmt build up down deploy + +install: + python3 -m venv .venv + $(PY) -m pip install -r $(BACKEND)/requirements-dev.txt + cd $(FRONTEND) && npm install + +dev-backend: + cd $(BACKEND) && ../$(PY) -m uvicorn app.main:app --reload --port 8000 + +dev-frontend: + cd $(FRONTEND) && npm run dev + +test: test-backend test-frontend + +test-backend: + cd $(BACKEND) && ../$(PY) -m pytest + +test-frontend: + cd $(FRONTEND) && npm test + +lint: + cd $(BACKEND) && ../.venv/bin/ruff check . + cd $(BACKEND) && ../.venv/bin/ruff format --check . + +fmt: + cd $(BACKEND) && ../.venv/bin/ruff format . + cd $(BACKEND) && ../.venv/bin/ruff check --fix . + +build: + cd $(FRONTEND) && npm run build + +up: + docker compose -f docker-compose.local.yml up --build + +down: + docker compose -f docker-compose.local.yml down + +# Production stack (requires the shared docker_default network + Traefik). +deploy: + docker compose up -d --build diff --git a/README.md b/README.md index e8c9c53..5405f58 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,154 @@ -# crypto_trading +# Crypto Trader +A small, hackable crypto trading bot with a web dashboard. Describe a strategy, +drop in one Python file, run it, and watch positions and P&L live. + +- **Backend:** FastAPI, SQLAlchemy, SQLite (Postgres optional), pure-Python + indicators, `httpx` for market data. +- **Frontend:** Vue 3 + Vite + Tailwind dashboard with a live WebSocket feed. +- **Brokers:** `paper` (simulated fills, default) and `binance` (live spot). +- **Market data:** Binance public REST (no API key needed). +- **Backtesting:** replay historical candles through the same strategy interface, + with return/drawdown/win-rate/Sharpe metrics — so what you test is what runs. + +> **Paper trading is the default.** Live mode places real orders and is opt-in. +> This is experimental software. Never trade more than you can afford to lose. + +## How it works + +``` +Binance REST ──▶ MarketData ──▶ TradingEngine ──▶ Broker (paper | binance) + │ + Strategy.on_candle(ctx) + │ + REST /api + WebSocket /api/ws ──▶ Vue dashboard +``` + +The engine runs one strategy at a time. Each tick it fetches candles, updates +the mark price, enforces stop-loss/take-profit, calls the strategy, executes the +resulting intents, and persists orders, equity snapshots and events. + +## Quickstart (local) + +```bash +# 1. Config +cp .env.example .env + +# 2. Backend +python3 -m venv .venv +.venv/bin/pip install -r backend/requirements-dev.txt +cd backend && ../.venv/bin/python -m uvicorn app.main:app --reload --port 8000 + +# 3. Frontend (new terminal) +cd frontend && npm install && npm run dev +``` + +Open http://localhost:5173. API docs are at http://localhost:8000/docs. + +## Quickstart (Docker, local) + +```bash +cp .env.example .env +docker compose -f docker-compose.local.yml up --build +# dashboard: http://localhost:8080 API docs: http://localhost:8000/docs +``` + +The default `docker-compose.yml` is the **deployment** stack (Traefik + +`docker_default`); use `docker-compose.local.yml` for a self-contained local run. + +## Deployment + +Merging to `main` triggers `.gitea/workflows/deploy.yml`, which tests, builds +both images, and rolls the stack out on the shared Traefik host: + +- The **frontend** joins the external `docker_default` network and is published + at `crypto.${BASE_DOMAIN}` (default `crypto.matitos.org`) via Traefik, with a + Let's Encrypt cert (`certresolver=myresolvercd`). +- The **backend** stays on the internal network only; the frontend nginx proxies + `/api` (including the `/api/ws` WebSocket) to it, so API keys are never + directly exposed. +- The WebUI is protected by **fail-closed Basic auth**: the deploy aborts unless + `CRYPTO_FRONTEND_AUTH_USERS` is set (falling back to the shared + `SCA_FRONTEND_AUTH_USERS`). Generate a value with + `htpasswd -nbB user 'password'`. +- A new release is rolled back automatically if healthchecks fail. + +Repo/org variables and secrets used by the deploy: + +| Name | Kind | Purpose | +| --- | --- | --- | +| `CRYPTO_FRONTEND_AUTH_USERS` | var/secret | htpasswd string for the WebUI (required) | +| `BASE_DOMAIN` | secret | Base domain (default `matitos.org`) | +| `CRYPTO_MODE` | var | `paper` (default) or `live` | +| `CRYPTO_EXCHANGE` / `CRYPTO_SYMBOL` / `CRYPTO_TIMEFRAME` | var | Trading defaults | +| `CRYPTO_STARTING_BALANCE` / `CRYPTO_POLL_INTERVAL` | var | Paper account + tick rate | +| `CRYPTO_EXCHANGE_API_KEY` / `CRYPTO_EXCHANGE_API_SECRET` | secret | Required only for `live` | +| `CRYPTO_EXCHANGE_SANDBOX` | var | Use the Binance testnet | +| `CRYPTO_CORS_ORIGINS` | var | Defaults to `https://crypto.` | +| `CRYPTO_DATA_DIR` / `PERSISTENT_MOUNT` | var | Host path for the SQLite DB | +| `DOCKERHUB_USERNAME` / `DOCKERHUB_TOKEN` | secret | Optional; for authenticated pulls | + +## Configuration + +All settings are environment variables (see `.env.example`): + +| Variable | Default | Purpose | +| --- | --- | --- | +| `MODE` | `paper` | `paper` or `live` | +| `EXCHANGE` | `binance` | Market data / execution venue | +| `SYMBOL` | `BTC/USDT` | Default trading pair | +| `TIMEFRAME` | `1m` | Candle interval | +| `POLL_INTERVAL` | `5` | Seconds between ticks | +| `STARTING_BALANCE` | `10000` | Paper account size | +| `FEE_BPS` / `SLIPPAGE_BPS` | `10` / `5` | Paper fill model | +| `DATABASE_URL` | `sqlite:///./data/trader.db` | Postgres: `postgresql+psycopg://...` | +| `EXCHANGE_API_KEY` / `EXCHANGE_API_SECRET` | — | Required only for live | +| `EXCHANGE_SANDBOX` | `false` | Use Binance testnet | + +## Adding a strategy + +Drop a file in `backend/app/engine/strategies/` — it is auto-discovered and +appears in the UI immediately. See **[AGENTS.md](AGENTS.md)** for the full +template and the `StrategyContext` API. Two examples ship in the repo: +`sma_cross` and `rsi_reversion`. + +## API + +| Method | Path | Description | +| --- | --- | --- | +| `GET` | `/api/status` | Bot status, equity, P&L | +| `GET` | `/api/strategies` | Available strategies + parameter specs | +| `POST` | `/api/bot/start` | Start a strategy | +| `POST` | `/api/bot/stop` | Stop the bot | +| `POST` | `/api/backtest` | Backtest a strategy on historical candles | +| `GET` | `/api/positions` | Positions (with unrealized P&L when open) | +| `GET` | `/api/orders` | Order history | +| `GET` | `/api/events` | Activity log | +| `GET` | `/api/equity` | Equity curve points | +| `POST` | `/api/orders` | Manual market order | +| `POST` | `/api/positions/{id}/close` | Close the open position | +| `WS` | `/api/ws` | Live status + event stream | + +## Tests & lint + +```bash +make test # backend pytest + frontend vitest +make lint # ruff +make build # frontend production build +``` + +## Project layout + +``` +backend/ + app/ + engine/ trading loop, indicators, strategies/ + broker/ paper + binance execution + market/ binance + fake data feeds + api/routes.py REST + WebSocket + models.py positions, orders, equity, events + store.py persistence + tests/ +frontend/ + src/ Vue dashboard +``` diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..743a029 --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,11 @@ +.venv/ +tests/ +__pycache__/ +*.py[cod] +.pytest_cache/ +.ruff_cache/ +data/ +*.db +*.sqlite3 +.env +.git/ diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..db97c8c --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,17 @@ +FROM python:3.12-slim + +ENV PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY app ./app + +RUN mkdir -p /data + +EXPOSE 8000 + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/api/routes.py b/backend/app/api/routes.py new file mode 100644 index 0000000..3e054b6 --- /dev/null +++ b/backend/app/api/routes.py @@ -0,0 +1,155 @@ +from __future__ import annotations + +import asyncio +from typing import Annotated + +import httpx +from fastapi import APIRouter, Depends, HTTPException, Request, WebSocket, WebSocketDisconnect + +from app.engine.strategies import list_strategies, strategy_spec +from app.models import OrderSide, Position, PositionStatus +from app.runtime import Runtime +from app.schemas import ( + BacktestRequest, + BacktestResult, + ClosePositionRequest, + EquityPointOut, + EventOut, + ManualOrderRequest, + OrderOut, + PositionOut, + StartRequest, + StatusOut, + StrategyOut, +) + +router = APIRouter(prefix="/api") + + +def get_runtime(request: Request) -> Runtime: + return request.app.state.runtime + + +RuntimeDep = Annotated[Runtime, Depends(get_runtime)] + + +@router.get("/health") +def health() -> dict[str, str]: + return {"status": "ok"} + + +@router.get("/strategies", response_model=list[StrategyOut]) +def strategies() -> list[StrategyOut]: + return [strategy_spec(cls) for cls in list_strategies()] + + +@router.get("/status", response_model=StatusOut) +def status(runtime: RuntimeDep) -> StatusOut: + return runtime.status() + + +@router.post("/bot/start", response_model=StatusOut) +async def start_bot(payload: StartRequest, runtime: RuntimeDep) -> StatusOut: + try: + return await runtime.start( + payload.strategy_key, + payload.params, + symbol=payload.symbol, + timeframe=payload.timeframe, + starting_balance=payload.starting_balance, + ) + except KeyError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + except (ValueError, RuntimeError) as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + +@router.post("/bot/stop", response_model=StatusOut) +async def stop_bot(runtime: RuntimeDep) -> StatusOut: + return await runtime.stop() + + +@router.post("/backtest", response_model=BacktestResult) +async def run_backtest(payload: BacktestRequest, runtime: RuntimeDep) -> BacktestResult: + try: + return await runtime.run_backtest(payload) + except KeyError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + except httpx.HTTPError as exc: + raise HTTPException(status_code=502, detail=f"market data error: {exc}") from exc + + +def _position_out(position: Position, price: float | None) -> PositionOut: + out = PositionOut.model_validate(position) + if position.status == PositionStatus.OPEN and price is not None: + sign = 1 if position.side.value == "long" else -1 + out.unrealized_pnl = sign * (price - position.entry_price) * position.qty + return out + + +@router.get("/positions", response_model=list[PositionOut]) +def positions(runtime: RuntimeDep, limit: int = 50) -> list[PositionOut]: + price = runtime.engine.last_price if runtime.engine else None + return [_position_out(position, price) for position in runtime.store.list_positions(limit)] + + +@router.get("/orders", response_model=list[OrderOut]) +def orders(runtime: RuntimeDep, limit: int = 50) -> list[OrderOut]: + return [OrderOut.model_validate(order) for order in runtime.store.list_orders(limit)] + + +@router.get("/events", response_model=list[EventOut]) +def events(runtime: RuntimeDep, limit: int = 50) -> list[EventOut]: + return [EventOut.model_validate(event) for event in runtime.store.list_events(limit)] + + +@router.get("/equity", response_model=list[EquityPointOut]) +def equity(runtime: RuntimeDep, limit: int = 500) -> list[EquityPointOut]: + return [EquityPointOut.model_validate(point) for point in runtime.store.list_equity(limit)] + + +@router.post("/orders", response_model=StatusOut) +async def manual_order(payload: ManualOrderRequest, runtime: RuntimeDep) -> StatusOut: + engine = runtime.engine + if engine is None or not engine.running: + raise HTTPException(status_code=409, detail="engine is not running") + side = OrderSide.BUY if payload.side == "buy" else OrderSide.SELL + await engine.manual_order(side, payload.qty, payload.quote_amount, payload.reason) + return engine.status() + + +@router.post("/positions/{position_id}/close", response_model=StatusOut) +async def close_position( + position_id: int, payload: ClosePositionRequest, runtime: RuntimeDep +) -> StatusOut: + engine = runtime.engine + if engine is None or not engine.running: + raise HTTPException(status_code=409, detail="engine is not running") + if engine.position is None or engine.position.id != position_id: + raise HTTPException(status_code=404, detail="no open position with that id") + await engine.close_current_position(payload.reason) + return engine.status() + + +@router.websocket("/ws") +async def websocket_endpoint(websocket: WebSocket) -> None: + runtime: Runtime = websocket.app.state.runtime + await websocket.accept() + queue = runtime.subscribe() + try: + await websocket.send_json( + {"type": "status", "data": runtime.status().model_dump(mode="json")} + ) + while True: + try: + payload = await asyncio.wait_for(queue.get(), timeout=15.0) + except TimeoutError: + await websocket.send_json({"type": "ping"}) + continue + await websocket.send_json(payload) + except WebSocketDisconnect: + pass + finally: + runtime.unsubscribe(queue) diff --git a/backend/app/backtest/__init__.py b/backend/app/backtest/__init__.py new file mode 100644 index 0000000..61794e6 --- /dev/null +++ b/backend/app/backtest/__init__.py @@ -0,0 +1,6 @@ +from __future__ import annotations + +from app.backtest.backtester import Backtester +from app.backtest.metrics import compute_metrics, max_drawdown_pct, sharpe_ratio + +__all__ = ["Backtester", "compute_metrics", "max_drawdown_pct", "sharpe_ratio"] diff --git a/backend/app/backtest/backtester.py b/backend/app/backtest/backtester.py new file mode 100644 index 0000000..9b7b3ff --- /dev/null +++ b/backend/app/backtest/backtester.py @@ -0,0 +1,172 @@ +from __future__ import annotations + +from collections.abc import Sequence + +from app.backtest.metrics import compute_metrics +from app.broker.paper import PaperBroker +from app.engine.portfolio import ( + OpenPosition, + account_equity, + apply_close, + apply_open, + order_qty, + realized_pnl, + risk_exit, +) +from app.engine.strategies.base import Strategy, StrategyContext +from app.models import OrderSide, PositionSide +from app.schemas import BacktestPointOut, BacktestResult, BacktestTradeOut, Candle + + +def _open_side(side: PositionSide) -> OrderSide: + return OrderSide.BUY if side == PositionSide.LONG else OrderSide.SELL + + +def _close_side(side: PositionSide) -> OrderSide: + return OrderSide.SELL if side == PositionSide.LONG else OrderSide.BUY + + +class Backtester: + """Runs a strategy bar-by-bar over historical candles. + + Reuses the very same ``Strategy``/``StrategyContext`` interface and the same + portfolio math as the live engine, so results carry over to paper/live. + """ + + def __init__( + self, + *, + fee_bps: float = 10.0, + slippage_bps: float = 5.0, + starting_balance: float = 10_000.0, + ) -> None: + self.broker = PaperBroker(fee_bps=fee_bps, slippage_bps=slippage_bps) + self.starting_balance = starting_balance + + def run( + self, + candles: Sequence[Candle], + strategy: Strategy, + symbol: str, + timeframe: str, + ) -> BacktestResult: + starting = self.starting_balance + cash = starting + position: OpenPosition | None = None + trades: list[BacktestTradeOut] = [] + curve: list[BacktestPointOut] = [] + error: str | None = None + first_close = candles[0].close if candles else 0.0 + + def close_at(level: float, ts: int, reason: str) -> None: + nonlocal cash, position + assert position is not None + fill = self.broker.fill(symbol, _close_side(position.side), position.qty, level) + cash = apply_close(cash, position.side, fill.qty, fill.price, fill.fee) + pnl = realized_pnl( + position.side, + position.entry_price, + fill.price, + position.qty, + position.entry_fee, + fill.fee, + ) + cost = position.entry_price * position.qty + trades.append( + BacktestTradeOut( + side=position.side.value, + entry_ts=position.entry_ts, + exit_ts=ts, + entry_price=position.entry_price, + exit_price=fill.price, + qty=position.qty, + pnl=pnl, + return_pct=(pnl / cost * 100) if cost else 0.0, + reason=reason, + ) + ) + position = None + + try: + for i, candle in enumerate(candles): + mark = candle.close + if position is not None: + hit = risk_exit( + position.side, + position.stop_loss, + position.take_profit, + candle.high, + candle.low, + ) + if hit is not None: + close_at(hit[1], candle.ts, hit[0]) + + equity = ( + account_equity(cash, position.side, position.qty, mark) if position else cash + ) + ctx = StrategyContext(candles[: i + 1], position, equity, cash) + strategy.on_candle(ctx) + + for intent in ctx.take_intents(): + if intent.action == "close": + if position is not None: + close_at(mark, candle.ts, intent.reason or "strategy close") + elif intent.action in {"enter_long", "enter_short"} and position is None: + side = ( + PositionSide.LONG + if intent.action == "enter_long" + else PositionSide.SHORT + ) + size = intent.size if intent.size is not None else 1.0 + qty = order_qty(cash, size, side, cash, mark) + if qty <= 0: + continue + fill = self.broker.fill(symbol, _open_side(side), qty, mark) + cash = apply_open(cash, side, fill.qty, fill.price, fill.fee) + position = OpenPosition( + side=side, + qty=fill.qty, + entry_price=fill.price, + entry_fee=fill.fee, + stop_loss=intent.stop_loss, + take_profit=intent.take_profit, + entry_ts=candle.ts, + ) + + equity = ( + account_equity(cash, position.side, position.qty, mark) if position else cash + ) + curve.append( + BacktestPointOut( + ts=candle.ts, + equity=equity, + buy_hold=starting * (mark / first_close) if first_close else starting, + price=mark, + ) + ) + except Exception as exc: # surface strategy/config errors in the result + error = f"{type(exc).__name__}: {exc}" + + # Force-close any open position at the last close so trades are realized. + if position is not None and candles and error is None: + last = candles[-1] + close_at(last.close, last.ts, "end of data") + if curve: + curve[-1] = BacktestPointOut( + ts=last.ts, + equity=cash, + buy_hold=curve[-1].buy_hold, + price=last.close, + ) + + return BacktestResult( + strategy_key=strategy.key, + strategy_name=strategy.name or strategy.key, + symbol=symbol, + timeframe=timeframe, + starting_balance=starting, + metrics=compute_metrics(curve, trades, starting, timeframe), + equity_curve=curve, + trades=trades, + error=error, + ) diff --git a/backend/app/backtest/metrics.py b/backend/app/backtest/metrics.py new file mode 100644 index 0000000..fc648ee --- /dev/null +++ b/backend/app/backtest/metrics.py @@ -0,0 +1,83 @@ +from __future__ import annotations + +from collections.abc import Sequence + +from app.schemas import BacktestMetricsOut, BacktestPointOut, BacktestTradeOut + +# Bars per year for annualising the Sharpe ratio. Unknown timeframes -> None. +BARS_PER_YEAR: dict[str, float] = { + "1m": 525_600, + "3m": 175_200, + "5m": 105_120, + "15m": 35_040, + "30m": 17_520, + "1h": 8_760, + "2h": 4_380, + "4h": 2_190, + "6h": 1_460, + "12h": 730, + "1d": 365, + "3d": 121, + "1w": 52, +} + + +def max_drawdown_pct(equities: Sequence[float]) -> float: + peak = equities[0] if equities else 0.0 + worst = 0.0 + for value in equities: + peak = max(peak, value) + if peak > 0: + worst = max(worst, (peak - value) / peak) + return worst * 100 + + +def sharpe_ratio(equities: Sequence[float], timeframe: str) -> float | None: + if len(equities) < 3: + return None + returns = [ + equities[i] / equities[i - 1] - 1 for i in range(1, len(equities)) if equities[i - 1] > 0 + ] + if len(returns) < 2: + return None + mean = sum(returns) / len(returns) + variance = sum((r - mean) ** 2 for r in returns) / (len(returns) - 1) + std = variance**0.5 + periods = BARS_PER_YEAR.get(timeframe) + if std == 0 or not periods: + return None + return mean / std * (periods**0.5) + + +def compute_metrics( + curve: Sequence[BacktestPointOut], + trades: Sequence[BacktestTradeOut], + starting_balance: float, + timeframe: str, +) -> BacktestMetricsOut: + equities = [point.equity for point in curve] or [starting_balance] + final_equity = equities[-1] + total_return = (final_equity / starting_balance - 1) * 100 if starting_balance else 0.0 + buy_hold = ( + (curve[-1].buy_hold / starting_balance - 1) * 100 if curve and starting_balance else 0.0 + ) + + returns = [trade.return_pct for trade in trades] + gross_profit = sum(trade.pnl for trade in trades if trade.pnl > 0) + gross_loss = -sum(trade.pnl for trade in trades if trade.pnl < 0) + wins = sum(1 for trade in trades if trade.pnl > 0) + + return BacktestMetricsOut( + initial_equity=starting_balance, + final_equity=final_equity, + total_return_pct=total_return, + buy_hold_return_pct=buy_hold, + max_drawdown_pct=max_drawdown_pct(equities), + num_trades=len(trades), + win_rate_pct=(wins / len(trades) * 100) if trades else 0.0, + profit_factor=(gross_profit / gross_loss) if gross_loss > 0 else None, + avg_trade_pct=(sum(returns) / len(returns)) if returns else 0.0, + best_trade_pct=max(returns) if returns else 0.0, + worst_trade_pct=min(returns) if returns else 0.0, + sharpe=sharpe_ratio(equities, timeframe), + ) diff --git a/backend/app/broker/__init__.py b/backend/app/broker/__init__.py new file mode 100644 index 0000000..2c219cf --- /dev/null +++ b/backend/app/broker/__init__.py @@ -0,0 +1,20 @@ +from __future__ import annotations + +from app.broker.base import Broker, Fill +from app.broker.binance import TESTNET_URL, BinanceBroker +from app.broker.paper import PaperBroker +from app.config import Settings +from app.market.binance import BASE_URL + + +def build_broker(settings: Settings) -> Broker: + if settings.mode == "live": + return BinanceBroker( + api_key=settings.exchange_api_key, + api_secret=settings.exchange_api_secret, + base_url=TESTNET_URL if settings.exchange_sandbox else BASE_URL, + ) + return PaperBroker(fee_bps=settings.fee_bps, slippage_bps=settings.slippage_bps) + + +__all__ = ["Broker", "Fill", "PaperBroker", "build_broker"] diff --git a/backend/app/broker/base.py b/backend/app/broker/base.py new file mode 100644 index 0000000..5be2d15 --- /dev/null +++ b/backend/app/broker/base.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod +from dataclasses import dataclass +from datetime import datetime + +from app.models import OrderSide + + +@dataclass(slots=True) +class Fill: + symbol: str + side: OrderSide + qty: float + price: float + fee: float + ts: datetime + + @property + def notional(self) -> float: + return self.qty * self.price + + +class Broker(ABC): + """Executes market orders. Paper and live brokers share this interface.""" + + @abstractmethod + async def buy(self, symbol: str, qty: float, ref_price: float) -> Fill: + raise NotImplementedError + + @abstractmethod + async def sell(self, symbol: str, qty: float, ref_price: float) -> Fill: + raise NotImplementedError + + async def close(self) -> None: + return None diff --git a/backend/app/broker/binance.py b/backend/app/broker/binance.py new file mode 100644 index 0000000..56c968c --- /dev/null +++ b/backend/app/broker/binance.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +import hashlib +import hmac +import time +from urllib.parse import urlencode + +import httpx + +from app.broker.base import Broker, Fill +from app.market.binance import BASE_URL, to_exchange_symbol +from app.models import OrderSide, utcnow + +TESTNET_URL = "https://testnet.binance.vision" + + +class BinanceBroker(Broker): + """Live Binance spot market-order execution (requires API credentials).""" + + def __init__( + self, + api_key: str, + api_secret: str, + base_url: str = BASE_URL, + timeout: float = 10.0, + ) -> None: + if not api_key or not api_secret: + raise ValueError("Binance broker requires EXCHANGE_API_KEY and EXCHANGE_API_SECRET") + self.api_key = api_key + self.api_secret = api_secret.encode() + self._client = httpx.AsyncClient(base_url=base_url, timeout=timeout) + + def _sign(self, params: dict) -> dict: + params = {**params, "timestamp": int(time.time() * 1000), "recvWindow": 5000} + query = urlencode(params) + signature = hmac.new(self.api_secret, query.encode(), hashlib.sha256).hexdigest() + return {**params, "signature": signature} + + async def _market_order(self, symbol: str, side: OrderSide, qty: float) -> Fill: + params = self._sign( + { + "symbol": to_exchange_symbol(symbol), + "side": side.value.upper(), + "type": "MARKET", + "quantity": f"{qty:.8f}".rstrip("0").rstrip("."), + } + ) + response = await self._client.post( + "/api/v3/order", + params=params, + headers={"X-MBX-APIKEY": self.api_key}, + ) + response.raise_for_status() + data = response.json() + executed = float(data.get("executedQty", qty)) + quote = float(data.get("cummulativeQuoteQty", 0.0)) + price = quote / executed if executed else 0.0 + fee = sum(float(fill.get("commission", 0.0)) for fill in data.get("fills", [])) + return Fill( + symbol=symbol, + side=side, + qty=executed, + price=price, + fee=fee, + ts=utcnow(), + ) + + async def buy(self, symbol: str, qty: float, ref_price: float) -> Fill: + return await self._market_order(symbol, OrderSide.BUY, qty) + + async def sell(self, symbol: str, qty: float, ref_price: float) -> Fill: + return await self._market_order(symbol, OrderSide.SELL, qty) + + async def close(self) -> None: + await self._client.aclose() diff --git a/backend/app/broker/paper.py b/backend/app/broker/paper.py new file mode 100644 index 0000000..f228a04 --- /dev/null +++ b/backend/app/broker/paper.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +from app.broker.base import Broker, Fill +from app.models import OrderSide, utcnow + + +class PaperBroker(Broker): + """Simulated fills against a reference price, with fees and slippage. + + Buys fill slightly above and sells slightly below the reference price to + approximate adverse slippage. + """ + + def __init__(self, fee_bps: float = 10.0, slippage_bps: float = 5.0) -> None: + self.fee_bps = fee_bps + self.slippage_bps = slippage_bps + + def _fee(self, notional: float) -> float: + return abs(notional) * self.fee_bps / 10_000 + + def fill(self, symbol: str, side: OrderSide, qty: float, ref_price: float) -> Fill: + """Synchronous fill used by the backtester (same model as buy/sell).""" + direction = 1 if side == OrderSide.BUY else -1 + price = ref_price * (1 + direction * self.slippage_bps / 10_000) + return Fill( + symbol=symbol, + side=side, + qty=qty, + price=price, + fee=self._fee(qty * price), + ts=utcnow(), + ) + + async def buy(self, symbol: str, qty: float, ref_price: float) -> Fill: + return self.fill(symbol, OrderSide.BUY, qty, ref_price) + + async def sell(self, symbol: str, qty: float, ref_price: float) -> Fill: + return self.fill(symbol, OrderSide.SELL, qty, ref_price) diff --git a/backend/app/config.py b/backend/app/config.py new file mode 100644 index 0000000..0665625 --- /dev/null +++ b/backend/app/config.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +from functools import lru_cache +from pathlib import Path + +from pydantic import field_validator +from pydantic_settings import BaseSettings, SettingsConfigDict + +REPO_ROOT = Path(__file__).resolve().parents[2] +DATA_DIR = REPO_ROOT / "data" + + +class Settings(BaseSettings): + model_config = SettingsConfigDict( + env_file=(REPO_ROOT / ".env", ".env"), + env_file_encoding="utf-8", + extra="ignore", + ) + + app_name: str = "Crypto Trader" + env: str = "dev" + host: str = "0.0.0.0" + port: int = 8000 + + database_url: str = "sqlite:///./data/trader.db" + + mode: str = "paper" + exchange: str = "binance" + symbol: str = "BTC/USDT" + timeframe: str = "1m" + poll_interval: float = 5.0 + candle_limit: int = 200 + + starting_balance: float = 10_000.0 + fee_bps: float = 10.0 + slippage_bps: float = 5.0 + + exchange_api_key: str = "" + exchange_api_secret: str = "" + exchange_sandbox: bool = False + + cors_origins: str = "*" + + @field_validator("mode") + @classmethod + def _validate_mode(cls, value: str) -> str: + value = value.lower().strip() + if value not in {"paper", "live"}: + raise ValueError("mode must be 'paper' or 'live'") + return value + + @property + def cors_origin_list(self) -> list[str]: + return [origin.strip() for origin in self.cors_origins.split(",") if origin.strip()] + + @property + def is_live(self) -> bool: + return self.mode == "live" + + +@lru_cache +def get_settings() -> Settings: + settings = Settings() + DATA_DIR.mkdir(parents=True, exist_ok=True) + return settings diff --git a/backend/app/db.py b/backend/app/db.py new file mode 100644 index 0000000..257a725 --- /dev/null +++ b/backend/app/db.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +from collections.abc import Iterator + +from sqlalchemy import create_engine +from sqlalchemy.orm import Session, sessionmaker + +from app.config import REPO_ROOT, get_settings +from app.models import Base + +_settings = get_settings() + + +def _normalize_url(url: str) -> tuple[str, dict]: + connect_args: dict = {} + if url.startswith("sqlite"): + prefix, _, path = url.partition("sqlite:///") + if path and not path.startswith("/"): + url = f"{prefix}sqlite:///{REPO_ROOT / path}" + connect_args = {"check_same_thread": False} + return url, connect_args + + +_url, _connect_args = _normalize_url(_settings.database_url) +engine = create_engine(_url, connect_args=_connect_args, pool_pre_ping=True) +SessionLocal = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False) + + +def init_db() -> None: + Base.metadata.create_all(bind=engine) + + +def get_session() -> Iterator[Session]: + session = SessionLocal() + try: + yield session + finally: + session.close() diff --git a/backend/app/engine/__init__.py b/backend/app/engine/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/engine/engine.py b/backend/app/engine/engine.py new file mode 100644 index 0000000..a2625d3 --- /dev/null +++ b/backend/app/engine/engine.py @@ -0,0 +1,390 @@ +from __future__ import annotations + +import asyncio +import contextlib +from collections.abc import Callable +from datetime import UTC, datetime +from typing import Any + +from app.broker.base import Broker +from app.config import Settings +from app.engine.portfolio import ( + account_equity, + apply_close, + apply_open, + order_qty, + realized_pnl, + risk_exit, + unrealized_pnl, +) +from app.engine.strategies import Strategy, get_strategy +from app.engine.strategies.base import StrategyContext +from app.market.base import MarketData +from app.models import OrderSide, Position, PositionSide +from app.schemas import Candle, EventOut, StatusOut +from app.store import Store + +Publish = Callable[[dict[str, Any]], None] + + +class TradingEngine: + """Single-strategy trading loop. + + The engine owns portfolio state (cash, open position), calls the strategy + once per candle, turns emitted intents into orders, and enforces stop-loss / + take-profit levels. + """ + + def __init__( + self, + *, + feed: MarketData, + broker: Broker, + store: Store, + settings: Settings, + ) -> None: + self.feed = feed + self.broker = broker + self.store = store + self.settings = settings + + self.running = False + self.error: str | None = None + self.last_tick: datetime | None = None + self.last_price: float | None = None + self.cash: float = settings.starting_balance + self.starting_balance: float = settings.starting_balance + self.realized_pnl: float = 0.0 + self.position: Position | None = None + self.strategy: Strategy | None = None + self.strategy_key: str | None = None + self.symbol: str = settings.symbol + self.timeframe: str = settings.timeframe + + self._task: asyncio.Task | None = None + self._publish_cb: Publish | None = None + + # --- lifecycle ---------------------------------------------------- + def set_publisher(self, publish: Publish | None) -> None: + self._publish_cb = publish + + async def start( + self, + strategy_key: str, + params: dict[str, Any] | None = None, + *, + symbol: str | None = None, + timeframe: str | None = None, + starting_balance: float | None = None, + ) -> None: + if self.running: + raise RuntimeError("engine already running") + + strategy_cls = get_strategy(strategy_key) + self.strategy = strategy_cls(params) + self.strategy_key = strategy_key + self.symbol = symbol or self.settings.symbol + self.timeframe = timeframe or self.settings.timeframe + self.starting_balance = float(starting_balance or self.settings.starting_balance) + + self.store.abandon_open_positions(strategy_key, reason="reset on engine start") + self.position = None + self.cash = self.starting_balance + self.realized_pnl = 0.0 + self.last_price = None + self.error = None + self.last_tick = None + + self.running = True + self._emit("info", f"Started {strategy_cls.name or strategy_key} on {self.symbol}") + self._broadcast_status() + self._task = asyncio.create_task(self._run()) + + async def stop(self) -> None: + if not self.running: + return + self.running = False + if self._task: + self._task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await self._task + self._task = None + self._emit("info", "Engine stopped") + self._broadcast_status() + + async def shutdown(self) -> None: + await self.stop() + await self.feed.close() + await self.broker.close() + + # --- loop --------------------------------------------------------- + async def _run(self) -> None: + while self.running: + try: + await self.tick() + except asyncio.CancelledError: + raise + except Exception as exc: # keep the loop alive on transient errors + self.error = f"{type(exc).__name__}: {exc}" + self._emit("error", f"tick failed: {self.error}") + await asyncio.sleep(self.settings.poll_interval) + + async def tick(self) -> None: + candles = await self.feed.fetch_candles( + self.symbol, self.timeframe, self.settings.candle_limit + ) + if not candles: + return + self.last_price = candles[-1].close + self.last_tick = datetime.now(UTC) + self.error = None + + if self.position is not None: + await self._check_risk(candles[-1]) + + if self.strategy is not None: + ctx = StrategyContext( + candles=candles, + position=self.position, + equity=self.equity, + cash=self.cash, + ) + try: + self.strategy.on_candle(ctx) + except Exception as exc: + self.error = f"strategy error: {type(exc).__name__}: {exc}" + self._emit("error", self.error) + await self._process_intents(ctx) + + self.store.record_equity(self.equity, self.cash, self.last_price) + self._broadcast_status() + + # --- portfolio helpers ------------------------------------------- + @property + def equity(self) -> float: + if self.position is None: + return self.cash + return account_equity( + self.cash, self.position.side, self.position.qty, self.last_price or 0.0 + ) + + @property + def unrealized_pnl(self) -> float: + if self.position is None or self.last_price is None: + return 0.0 + return unrealized_pnl( + self.position.side, self.position.entry_price, self.last_price, self.position.qty + ) + + async def _process_intents(self, ctx: StrategyContext) -> None: + for intent in ctx.take_intents(): + if intent.action == "close": + if self.position is not None: + await self._close_position(intent.reason or "strategy close") + elif intent.action in {"enter_long", "enter_short"}: + if self.position is not None: + continue + side = PositionSide.LONG if intent.action == "enter_long" else PositionSide.SHORT + await self._open_position( + side, + size=intent.size if intent.size is not None else 1.0, + stop_loss=intent.stop_loss, + take_profit=intent.take_profit, + reason=intent.reason or intent.action, + ) + + async def _open_position( + self, + side: PositionSide, + *, + size: float, + stop_loss: float | None, + take_profit: float | None, + reason: str, + qty_override: float | None = None, + ) -> None: + price = self.last_price + if not price or price <= 0: + return + + if qty_override is not None: + qty = qty_override + else: + qty = order_qty(self.equity, size, side, self.cash, price) + if qty <= 0: + return + + fill = ( + await self.broker.buy(self.symbol, qty, price) + if side == PositionSide.LONG + else await self.broker.sell(self.symbol, qty, price) + ) + + self.cash = apply_open(self.cash, side, fill.qty, fill.price, fill.fee) + + self.position = self.store.create_position( + strategy_key=self.strategy_key or "manual", + symbol=self.symbol, + side=side, + qty=fill.qty, + entry_price=fill.price, + entry_fee=fill.fee, + stop_loss=stop_loss, + take_profit=take_profit, + ) + self.store.record_order( + position_id=self.position.id, + strategy_key=self.position.strategy_key, + symbol=self.symbol, + side=fill.side, + qty=fill.qty, + price=fill.price, + fee=fill.fee, + reason=reason, + ) + self._emit( + "trade", + f"Opened {side.value} {fill.qty:.6f} {self.symbol} @ {fill.price:.4f} ({reason})", + ) + + async def _close_position(self, reason: str) -> None: + position = self.position + price = self.last_price + if position is None or not price: + return + + fill = ( + await self.broker.sell(self.symbol, position.qty, price) + if position.side == PositionSide.LONG + else await self.broker.buy(self.symbol, position.qty, price) + ) + + self.cash = apply_close(self.cash, position.side, fill.qty, fill.price, fill.fee) + realized = realized_pnl( + position.side, + position.entry_price, + fill.price, + position.qty, + position.entry_fee, + fill.fee, + ) + self.realized_pnl += realized + + self.store.close_position( + position, + exit_price=fill.price, + exit_fee=fill.fee, + realized_pnl=realized, + ) + self.store.record_order( + position_id=position.id, + strategy_key=position.strategy_key, + symbol=self.symbol, + side=fill.side, + qty=fill.qty, + price=fill.price, + fee=fill.fee, + reason=reason, + ) + self.position = None + self._emit( + "trade", + f"Closed {position.side.value} {position.qty:.6f} {self.symbol} " + f"@ {fill.price:.4f} pnl={realized:.2f} ({reason})", + ) + + async def _check_risk(self, candle: Candle) -> None: + position = self.position + if position is None: + return + hit = risk_exit( + position.side, + position.stop_loss, + position.take_profit, + candle.high, + candle.low, + ) + if hit is not None: + reason, level = hit + self.last_price = level + await self._close_position(reason) + + # --- manual controls ---------------------------------------------- + async def manual_order( + self, side: OrderSide, qty: float | None, quote_amount: float | None, reason: str + ) -> None: + price = self.last_price or await self.feed.fetch_price(self.symbol) + self.last_price = price + if qty is None: + notional = quote_amount if quote_amount is not None else self.cash * 0.99 + qty = notional / price + if qty <= 0: + return + if side == OrderSide.BUY: + if self.position is not None: + return + await self._open_position( + PositionSide.LONG, + size=0.0, + stop_loss=None, + take_profit=None, + reason=reason, + qty_override=qty, + ) + else: + if self.position is not None: + await self._close_position(reason or "manual close") + else: + await self._open_position( + PositionSide.SHORT, + size=0.0, + stop_loss=None, + take_profit=None, + reason=reason, + qty_override=qty, + ) + + async def close_current_position(self, reason: str = "manual") -> None: + if self.position is not None: + if self.last_price is None: + self.last_price = await self.feed.fetch_price(self.symbol) + await self._close_position(reason) + + # --- status / events ---------------------------------------------- + def status(self) -> StatusOut: + strategy_name = None + if self.strategy is not None: + strategy_name = self.strategy.name or self.strategy.key + return StatusOut( + running=self.running, + mode=self.settings.mode, + exchange=self.settings.exchange, + symbol=self.symbol, + timeframe=self.timeframe, + strategy_key=self.strategy_key, + strategy_name=strategy_name, + params=dict(self.strategy.params) if self.strategy else {}, + price=self.last_price, + cash=self.cash, + equity=self.equity, + starting_balance=self.starting_balance, + realized_pnl=self.realized_pnl, + unrealized_pnl=self.unrealized_pnl, + total_pnl=self.equity - self.starting_balance, + open_positions=1 if self.position else 0, + last_tick=self.last_tick, + error=self.error, + ) + + def _emit(self, level: str, message: str) -> None: + event = self.store.record_event(level, message, self.strategy_key) + self._publish( + {"type": "event", "data": EventOut.model_validate(event).model_dump(mode="json")} + ) + + def _broadcast_status(self) -> None: + self._publish({"type": "status", "data": self.status().model_dump(mode="json")}) + + def _publish(self, payload: dict[str, Any]) -> None: + if self._publish_cb is not None: + self._publish_cb(payload) diff --git a/backend/app/engine/indicators.py b/backend/app/engine/indicators.py new file mode 100644 index 0000000..661fe89 --- /dev/null +++ b/backend/app/engine/indicators.py @@ -0,0 +1,120 @@ +from __future__ import annotations + +from collections.abc import Sequence + +from app.schemas import Candle + +Number = float | None + + +def sma(values: Sequence[float], period: int) -> list[Number]: + if period <= 0: + raise ValueError("period must be positive") + out: list[Number] = [None] * len(values) + window_sum = 0.0 + for i, value in enumerate(values): + window_sum += value + if i >= period: + window_sum -= values[i - period] + if i >= period - 1: + out[i] = window_sum / period + return out + + +def ema(values: Sequence[float], period: int) -> list[Number]: + if period <= 0: + raise ValueError("period must be positive") + out: list[Number] = [None] * len(values) + if len(values) < period: + return out + multiplier = 2 / (period + 1) + seed = sum(values[:period]) / period + out[period - 1] = seed + prev = seed + for i in range(period, len(values)): + prev = (values[i] - prev) * multiplier + prev + out[i] = prev + return out + + +def rsi(values: Sequence[float], period: int = 14) -> list[Number]: + if period <= 0: + raise ValueError("period must be positive") + out: list[Number] = [None] * len(values) + if len(values) <= period: + return out + gains = 0.0 + losses = 0.0 + for i in range(1, period + 1): + change = values[i] - values[i - 1] + gains += max(change, 0.0) + losses += max(-change, 0.0) + avg_gain = gains / period + avg_loss = losses / period + out[period] = _rsi_value(avg_gain, avg_loss) + for i in range(period + 1, len(values)): + change = values[i] - values[i - 1] + gain = max(change, 0.0) + loss = max(-change, 0.0) + avg_gain = (avg_gain * (period - 1) + gain) / period + avg_loss = (avg_loss * (period - 1) + loss) / period + out[i] = _rsi_value(avg_gain, avg_loss) + return out + + +def _rsi_value(avg_gain: float, avg_loss: float) -> float: + if avg_loss == 0: + return 100.0 + rs = avg_gain / avg_loss + return 100 - (100 / (1 + rs)) + + +def true_range(candles: Sequence[Candle]) -> list[float]: + out: list[float] = [] + for i, candle in enumerate(candles): + if i == 0: + out.append(candle.high - candle.low) + continue + prev_close = candles[i - 1].close + out.append( + max( + candle.high - candle.low, + abs(candle.high - prev_close), + abs(candle.low - prev_close), + ) + ) + return out + + +def atr(candles: Sequence[Candle], period: int = 14) -> list[Number]: + if period <= 0: + raise ValueError("period must be positive") + tr = true_range(candles) + out: list[Number] = [None] * len(tr) + if len(tr) < period: + return out + prev = sum(tr[:period]) / period + out[period - 1] = prev + for i in range(period, len(tr)): + prev = (prev * (period - 1) + tr[i]) / period + out[i] = prev + return out + + +def rolling_std(values: Sequence[float], period: int) -> list[Number]: + if period <= 0: + raise ValueError("period must be positive") + out: list[Number] = [None] * len(values) + for i in range(period - 1, len(values)): + window = values[i - period + 1 : i + 1] + mean = sum(window) / period + variance = sum((x - mean) ** 2 for x in window) / period + out[i] = variance**0.5 + return out + + +def last(series: Sequence[Number]) -> Number: + for value in reversed(series): + if value is not None: + return value + return None diff --git a/backend/app/engine/portfolio.py b/backend/app/engine/portfolio.py new file mode 100644 index 0000000..b4b4b52 --- /dev/null +++ b/backend/app/engine/portfolio.py @@ -0,0 +1,100 @@ +from __future__ import annotations + +from dataclasses import dataclass + +from app.models import PositionSide + +# All functions here are pure and shared by the live engine and the backtester +# so both compute cash, P&L and risk exits identically. + + +@dataclass +class OpenPosition: + """Lightweight position used by the backtester (the engine uses a DB row).""" + + side: PositionSide + qty: float + entry_price: float + entry_fee: float = 0.0 + stop_loss: float | None = None + take_profit: float | None = None + entry_ts: int = 0 + + +def apply_open(cash: float, side: PositionSide, qty: float, price: float, fee: float) -> float: + """Cash after opening a position (buying long / selling short).""" + notional = qty * price + if side == PositionSide.LONG: + return cash - notional - fee + return cash + notional - fee + + +def apply_close(cash: float, side: PositionSide, qty: float, price: float, fee: float) -> float: + """Cash after closing a position (selling long / buying back short).""" + notional = qty * price + if side == PositionSide.LONG: + return cash + notional - fee + return cash - notional - fee + + +def realized_pnl( + side: PositionSide, + entry_price: float, + exit_price: float, + qty: float, + entry_fee: float, + exit_fee: float, +) -> float: + gross = ( + (exit_price - entry_price) * qty + if side == PositionSide.LONG + else (entry_price - exit_price) * qty + ) + return gross - entry_fee - exit_fee + + +def unrealized_pnl(side: PositionSide, entry_price: float, mark: float, qty: float) -> float: + if side == PositionSide.LONG: + return (mark - entry_price) * qty + return (entry_price - mark) * qty + + +def position_value(side: PositionSide, qty: float, mark: float) -> float: + """Mark-to-market value of the position as an asset (negative for shorts).""" + return qty * mark if side == PositionSide.LONG else -qty * mark + + +def account_equity(cash: float, side: PositionSide, qty: float, mark: float) -> float: + return cash + position_value(side, qty, mark) + + +def order_qty(equity: float, size: float, side: PositionSide, cash: float, price: float) -> float: + """Position size as a fraction of equity, capped by available cash for longs.""" + if price <= 0: + return 0.0 + size = max(0.0, min(size, 1.0)) + notional = equity * size + if side == PositionSide.LONG: + notional = min(notional, cash * 0.99) + return notional / price + + +def risk_exit( + side: PositionSide, + stop_loss: float | None, + take_profit: float | None, + high: float, + low: float, +) -> tuple[str, float] | None: + """Return (reason, exit_price) if a stop/take level was hit within the bar.""" + if side == PositionSide.LONG: + if stop_loss is not None and low <= stop_loss: + return "stop loss", stop_loss + if take_profit is not None and high >= take_profit: + return "take profit", take_profit + else: + if stop_loss is not None and high >= stop_loss: + return "stop loss", stop_loss + if take_profit is not None and low <= take_profit: + return "take profit", take_profit + return None diff --git a/backend/app/engine/strategies/__init__.py b/backend/app/engine/strategies/__init__.py new file mode 100644 index 0000000..b7544c4 --- /dev/null +++ b/backend/app/engine/strategies/__init__.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +import importlib +import pkgutil + +from app.engine.strategies.base import ( + Strategy, + StrategyContext, + get_strategy, + list_strategies, + param_specs, + register, + strategy_spec, +) + + +def discover() -> None: + """Import every module in this package so its strategies self-register.""" + for module_info in pkgutil.iter_modules(__path__): + if module_info.name.startswith("_") or module_info.name in {"base"}: + continue + importlib.import_module(f"{__name__}.{module_info.name}") + + +discover() + +__all__ = [ + "Strategy", + "StrategyContext", + "get_strategy", + "list_strategies", + "param_specs", + "register", + "strategy_spec", +] diff --git a/backend/app/engine/strategies/base.py b/backend/app/engine/strategies/base.py new file mode 100644 index 0000000..e5676bc --- /dev/null +++ b/backend/app/engine/strategies/base.py @@ -0,0 +1,217 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod +from collections.abc import Sequence +from dataclasses import dataclass +from typing import Any, ClassVar, Literal, Protocol + +from app.engine.indicators import atr, ema, rolling_std, rsi, sma +from app.models import PositionSide +from app.schemas import Candle, ParamSpec, StrategyOut + + +class PositionView(Protocol): + """Minimal position shape a strategy can see (DB row or backtest position).""" + + side: PositionSide + qty: float + entry_price: float + + +@dataclass(slots=True) +class Intent: + """An order request emitted by a strategy. Executed by the engine.""" + + action: Literal["enter_long", "enter_short", "close"] + size: float | None = None + stop_loss: float | None = None + take_profit: float | None = None + reason: str = "" + + +class StrategyContext: + """Read-only market/portfolio view plus order intent helpers. + + A fresh context is created for every candle. Strategy code stays synchronous + and side-effect free: it calls ``enter_long`` / ``enter_short`` / ``close`` + and the engine executes the resulting intent after ``on_candle`` returns. + """ + + def __init__( + self, + candles: Sequence[Candle], + position: PositionView | None, + equity: float, + cash: float, + ) -> None: + self._candles = list(candles) + self._position = position + self._equity = equity + self._cash = cash + self._intents: list[Intent] = [] + + # --- market state ------------------------------------------------- + @property + def candles(self) -> list[Candle]: + return self._candles + + @property + def price(self) -> float: + return self._candles[-1].close + + @property + def closes(self) -> list[float]: + return [c.close for c in self._candles] + + def high(self, lookback: int) -> float: + return max(c.high for c in self._candles[-lookback:]) + + def low(self, lookback: int) -> float: + return min(c.low for c in self._candles[-lookback:]) + + # --- portfolio state ---------------------------------------------- + @property + def position(self) -> PositionView | None: + return self._position + + @property + def is_flat(self) -> bool: + return self._position is None + + @property + def side(self) -> PositionSide | None: + return self._position.side if self._position else None + + @property + def equity(self) -> float: + return self._equity + + @property + def cash(self) -> float: + return self._cash + + # --- indicators ---------------------------------------------------- + def sma(self, period: int) -> float | None: + return sma(self.closes, period)[-1] + + def ema(self, period: int) -> float | None: + return ema(self.closes, period)[-1] + + def rsi(self, period: int = 14) -> float | None: + return rsi(self.closes, period)[-1] + + def atr(self, period: int = 14) -> float | None: + return atr(self._candles, period)[-1] + + def std(self, period: int = 20) -> float | None: + return rolling_std(self.closes, period)[-1] + + # --- order intents ------------------------------------------------ + def enter_long( + self, + size: float | None = None, + stop_loss: float | None = None, + take_profit: float | None = None, + reason: str = "", + ) -> None: + self._intents.append(Intent("enter_long", size, stop_loss, take_profit, reason)) + + def enter_short( + self, + size: float | None = None, + stop_loss: float | None = None, + take_profit: float | None = None, + reason: str = "", + ) -> None: + self._intents.append(Intent("enter_short", size, stop_loss, take_profit, reason)) + + def close(self, reason: str = "") -> None: + self._intents.append(Intent("close", reason=reason)) + + def take_intents(self) -> list[Intent]: + intents, self._intents = self._intents, [] + return intents + + +class Strategy(ABC): + """Base class for trading strategies. + + Subclass it, set ``key``/``name``/``default_params`` and implement + ``on_candle``. Save the file under ``app/engine/strategies/`` and it is + auto-discovered. + """ + + key: ClassVar[str] = "" + name: ClassVar[str] = "" + description: ClassVar[str] = "" + default_params: ClassVar[dict[str, Any]] = {} + param_help: ClassVar[dict[str, str]] = {} + param_bounds: ClassVar[dict[str, tuple[float, float]]] = {} + + def __init__(self, params: dict[str, Any] | None = None) -> None: + unknown = set(params or {}) - set(self.default_params) + if unknown: + raise ValueError(f"unknown params for {self.key}: {sorted(unknown)}") + self.params: dict[str, Any] = {**self.default_params, **(params or {})} + + @abstractmethod + def on_candle(self, ctx: StrategyContext) -> None: + raise NotImplementedError + + +def _infer_type(value: Any) -> Literal["int", "float", "bool", "str"]: + if isinstance(value, bool): + return "bool" + if isinstance(value, int): + return "int" + if isinstance(value, float): + return "float" + return "str" + + +def param_specs(strategy_cls: type[Strategy]) -> list[ParamSpec]: + specs: list[ParamSpec] = [] + for name, default in strategy_cls.default_params.items(): + bounds = strategy_cls.param_bounds.get(name) + specs.append( + ParamSpec( + name=name, + type=_infer_type(default), + default=default, + description=strategy_cls.param_help.get(name, ""), + min=bounds[0] if bounds else None, + max=bounds[1] if bounds else None, + ) + ) + return specs + + +_REGISTRY: dict[str, type[Strategy]] = {} + + +def register(strategy_cls: type[Strategy]) -> type[Strategy]: + """Class decorator that makes a strategy discoverable by key.""" + if not strategy_cls.key: + raise ValueError(f"{strategy_cls.__name__} must define a non-empty 'key'") + _REGISTRY[strategy_cls.key] = strategy_cls + return strategy_cls + + +def get_strategy(key: str) -> type[Strategy]: + try: + return _REGISTRY[key] + except KeyError: + raise KeyError(f"unknown strategy '{key}'. available: {sorted(_REGISTRY)}") from None + + +def list_strategies() -> list[type[Strategy]]: + return sorted(_REGISTRY.values(), key=lambda cls: cls.name or cls.key) + + +def strategy_spec(strategy_cls: type[Strategy]) -> StrategyOut: + return StrategyOut( + key=strategy_cls.key, + name=strategy_cls.name or strategy_cls.key, + description=strategy_cls.description, + params=param_specs(strategy_cls), + ) diff --git a/backend/app/engine/strategies/rsi_reversion.py b/backend/app/engine/strategies/rsi_reversion.py new file mode 100644 index 0000000..92283fb --- /dev/null +++ b/backend/app/engine/strategies/rsi_reversion.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +from typing import ClassVar + +from app.engine.strategies.base import Strategy, StrategyContext, register +from app.models import PositionSide + + +@register +class RsiReversion(Strategy): + """Buy oversold RSI, exit on overbought, with an ATR-based stop.""" + + key: ClassVar[str] = "rsi_reversion" + name: ClassVar[str] = "RSI Mean Reversion" + description: ClassVar[str] = ( + "Enter long when RSI drops below the oversold level and exit when it rises " + "above the overbought level. Places an ATR-based stop loss and take profit." + ) + default_params: ClassVar[dict] = { + "period": 14, + "oversold": 30.0, + "overbought": 70.0, + "atr_period": 14, + "atr_stop_mult": 2.0, + "atr_target_mult": 3.0, + "size": 1.0, + } + param_help: ClassVar[dict] = { + "period": "RSI lookback period", + "oversold": "RSI level below which to buy", + "overbought": "RSI level above which to exit", + "atr_period": "ATR lookback for stop sizing", + "atr_stop_mult": "Stop distance as a multiple of ATR", + "atr_target_mult": "Take-profit distance as a multiple of ATR", + "size": "Fraction of equity to allocate per trade", + } + param_bounds: ClassVar[dict] = { + "period": (2, 100), + "oversold": (1, 50), + "overbought": (50, 99), + "atr_stop_mult": (0.5, 10.0), + "atr_target_mult": (0.5, 20.0), + "size": (0.01, 1.0), + } + + def on_candle(self, ctx: StrategyContext) -> None: + period = int(self.params["period"]) + current = ctx.rsi(period) + if current is None: + return + + if ctx.is_flat and current < float(self.params["oversold"]): + stop = target = None + atr_value = ctx.atr(int(self.params["atr_period"])) + if atr_value: + stop = ctx.price - float(self.params["atr_stop_mult"]) * atr_value + target = ctx.price + float(self.params["atr_target_mult"]) * atr_value + ctx.enter_long( + size=float(self.params["size"]), + stop_loss=stop, + take_profit=target, + reason=f"RSI {current:.1f} below oversold", + ) + elif ctx.side == PositionSide.LONG and current > float(self.params["overbought"]): + ctx.close(reason=f"RSI {current:.1f} above overbought") diff --git a/backend/app/engine/strategies/sma_cross.py b/backend/app/engine/strategies/sma_cross.py new file mode 100644 index 0000000..c3bf38e --- /dev/null +++ b/backend/app/engine/strategies/sma_cross.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +from typing import ClassVar + +from app.engine.indicators import sma +from app.engine.strategies.base import Strategy, StrategyContext, register +from app.models import PositionSide + + +@register +class SmaCross(Strategy): + """Classic moving-average crossover, long-only.""" + + key: ClassVar[str] = "sma_cross" + name: ClassVar[str] = "SMA Crossover" + description: ClassVar[str] = ( + "Open a long position when the fast SMA crosses above the slow SMA, " + "close it when the fast SMA crosses back below." + ) + default_params: ClassVar[dict] = {"fast": 10, "slow": 30, "size": 1.0} + param_help: ClassVar[dict] = { + "fast": "Fast SMA period", + "slow": "Slow SMA period", + "size": "Fraction of equity to allocate per trade", + } + param_bounds: ClassVar[dict] = {"fast": (2, 200), "slow": (3, 400), "size": (0.01, 1.0)} + + def on_candle(self, ctx: StrategyContext) -> None: + fast_period = int(self.params["fast"]) + slow_period = int(self.params["slow"]) + closes = ctx.closes + if len(closes) < slow_period + 2: + return + + fast = sma(closes, fast_period) + slow = sma(closes, slow_period) + f_prev, f_now = fast[-2], fast[-1] + s_prev, s_now = slow[-2], slow[-1] + if None in (f_prev, f_now, s_prev, s_now): + return + + crossed_up = f_prev <= s_prev and f_now > s_now + crossed_down = f_prev >= s_prev and f_now < s_now + + if ctx.is_flat and crossed_up: + ctx.enter_long(size=float(self.params["size"]), reason="fast SMA crossed above slow") + elif ctx.side == PositionSide.LONG and crossed_down: + ctx.close(reason="fast SMA crossed below slow") diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..3b9a19a --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager + +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from app.api.routes import router +from app.config import Settings, get_settings +from app.db import init_db +from app.runtime import Runtime + + +def create_app( + settings: Settings | None = None, + runtime: Runtime | None = None, +) -> FastAPI: + settings = settings or get_settings() + runtime = runtime or Runtime(settings) + + @asynccontextmanager + async def lifespan(app: FastAPI) -> AsyncIterator[None]: + init_db() + app.state.runtime = runtime + yield + await runtime.shutdown() + + app = FastAPI(title=settings.app_name, lifespan=lifespan) + app.add_middleware( + CORSMiddleware, + allow_origins=settings.cors_origin_list, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) + app.include_router(router) + + @app.get("/") + def root() -> dict[str, str]: + return {"name": settings.app_name, "docs": "/docs"} + + return app + + +app = create_app() diff --git a/backend/app/market/__init__.py b/backend/app/market/__init__.py new file mode 100644 index 0000000..e90e2b8 --- /dev/null +++ b/backend/app/market/__init__.py @@ -0,0 +1,16 @@ +from __future__ import annotations + +from app.config import Settings +from app.market.base import MarketData +from app.market.binance import BinanceMarketData +from app.market.fake import FakeMarketData + + +def build_market_data(settings: Settings) -> MarketData: + exchange = settings.exchange.lower() + if exchange in {"binance", "binanceus"}: + return BinanceMarketData() + raise ValueError(f"unsupported exchange: {settings.exchange}") + + +__all__ = ["FakeMarketData", "MarketData", "build_market_data"] diff --git a/backend/app/market/base.py b/backend/app/market/base.py new file mode 100644 index 0000000..24321c5 --- /dev/null +++ b/backend/app/market/base.py @@ -0,0 +1,20 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod + +from app.schemas import Candle + + +class MarketData(ABC): + """Read-only market data source (candles + last price).""" + + @abstractmethod + async def fetch_candles(self, symbol: str, timeframe: str, limit: int) -> list[Candle]: + raise NotImplementedError + + @abstractmethod + async def fetch_price(self, symbol: str) -> float: + raise NotImplementedError + + async def close(self) -> None: + return None diff --git a/backend/app/market/binance.py b/backend/app/market/binance.py new file mode 100644 index 0000000..ef307f3 --- /dev/null +++ b/backend/app/market/binance.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +import httpx + +from app.market.base import MarketData +from app.schemas import Candle + +BASE_URL = "https://api.binance.com" + + +def to_exchange_symbol(symbol: str) -> str: + return symbol.replace("/", "").upper() + + +class BinanceMarketData(MarketData): + """Public Binance spot market data over REST (no API key required).""" + + def __init__(self, base_url: str = BASE_URL, timeout: float = 10.0) -> None: + self._client = httpx.AsyncClient(base_url=base_url, timeout=timeout) + + async def fetch_candles(self, symbol: str, timeframe: str, limit: int) -> list[Candle]: + response = await self._client.get( + "/api/v3/klines", + params={ + "symbol": to_exchange_symbol(symbol), + "interval": timeframe, + "limit": limit, + }, + ) + response.raise_for_status() + return [ + Candle( + ts=int(row[0]), + open=float(row[1]), + high=float(row[2]), + low=float(row[3]), + close=float(row[4]), + volume=float(row[5]), + ) + for row in response.json() + ] + + async def fetch_price(self, symbol: str) -> float: + response = await self._client.get( + "/api/v3/ticker/price", + params={"symbol": to_exchange_symbol(symbol)}, + ) + response.raise_for_status() + return float(response.json()["price"]) + + async def close(self) -> None: + await self._client.aclose() diff --git a/backend/app/market/fake.py b/backend/app/market/fake.py new file mode 100644 index 0000000..2ac0f0d --- /dev/null +++ b/backend/app/market/fake.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +import math +from collections.abc import Sequence + +from app.market.base import MarketData +from app.schemas import Candle + + +class FakeMarketData(MarketData): + """In-memory market data for tests and offline demos.""" + + def __init__(self, candles: Sequence[Candle] | None = None) -> None: + self.candles: list[Candle] = list(candles or []) + + @classmethod + def from_closes(cls, closes: Sequence[float]) -> FakeMarketData: + candles = [ + Candle( + ts=1_700_000_000_000 + i * 60_000, + open=close, + high=close, + low=close, + close=close, + volume=1.0, + ) + for i, close in enumerate(closes) + ] + return cls(candles) + + @classmethod + def sine_wave(cls, n: int = 200, amplitude: float = 100.0, period: int = 20) -> FakeMarketData: + base = 1000.0 + closes = [base + amplitude * math.sin(2 * math.pi * i / period) for i in range(n)] + return cls.from_closes(closes) + + def append(self, candle: Candle) -> None: + self.candles.append(candle) + + async def fetch_candles(self, symbol: str, timeframe: str, limit: int) -> list[Candle]: + return self.candles[-limit:] + + async def fetch_price(self, symbol: str) -> float: + if not self.candles: + raise ValueError("no candles available") + return self.candles[-1].close diff --git a/backend/app/models.py b/backend/app/models.py new file mode 100644 index 0000000..c331a2f --- /dev/null +++ b/backend/app/models.py @@ -0,0 +1,121 @@ +from __future__ import annotations + +from datetime import UTC, datetime +from enum import StrEnum +from typing import Any + +from sqlalchemy import ( + JSON, + DateTime, + Float, + ForeignKey, + Integer, + String, +) +from sqlalchemy import ( + Enum as SAEnum, +) +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship + + +def utcnow() -> datetime: + return datetime.now(UTC) + + +class Base(DeclarativeBase): + pass + + +class PositionSide(StrEnum): + LONG = "long" + SHORT = "short" + + +class PositionStatus(StrEnum): + OPEN = "open" + CLOSED = "closed" + + +class OrderSide(StrEnum): + BUY = "buy" + SELL = "sell" + + +class StrategyConfig(Base): + __tablename__ = "strategy_configs" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + strategy_key: Mapped[str] = mapped_column(String(64), index=True) + symbol: Mapped[str] = mapped_column(String(32)) + timeframe: Mapped[str] = mapped_column(String(16)) + params: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) + + +class Position(Base): + __tablename__ = "positions" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + strategy_key: Mapped[str] = mapped_column(String(64), index=True) + symbol: Mapped[str] = mapped_column(String(32), index=True) + side: Mapped[PositionSide] = mapped_column(SAEnum(PositionSide)) + qty: Mapped[float] = mapped_column(Float) + entry_price: Mapped[float] = mapped_column(Float) + status: Mapped[PositionStatus] = mapped_column( + SAEnum(PositionStatus), default=PositionStatus.OPEN, index=True + ) + stop_loss: Mapped[float | None] = mapped_column(Float, nullable=True) + take_profit: Mapped[float | None] = mapped_column(Float, nullable=True) + entry_fee: Mapped[float] = mapped_column(Float, default=0.0) + exit_price: Mapped[float | None] = mapped_column(Float, nullable=True) + exit_fee: Mapped[float] = mapped_column(Float, default=0.0) + realized_pnl: Mapped[float | None] = mapped_column(Float, nullable=True) + opened_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) + closed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + + orders: Mapped[list[Order]] = relationship(back_populates="position") + + +class Order(Base): + __tablename__ = "orders" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + position_id: Mapped[int | None] = mapped_column( + ForeignKey("positions.id"), nullable=True, index=True + ) + strategy_key: Mapped[str] = mapped_column(String(64), index=True) + symbol: Mapped[str] = mapped_column(String(32)) + side: Mapped[OrderSide] = mapped_column(SAEnum(OrderSide)) + qty: Mapped[float] = mapped_column(Float) + price: Mapped[float] = mapped_column(Float) + fee: Mapped[float] = mapped_column(Float, default=0.0) + reason: Mapped[str] = mapped_column(String(255), default="") + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=utcnow, index=True + ) + + position: Mapped[Position | None] = relationship(back_populates="orders") + + +class EquitySnapshot(Base): + __tablename__ = "equity_snapshots" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + equity: Mapped[float] = mapped_column(Float) + cash: Mapped[float] = mapped_column(Float) + price: Mapped[float] = mapped_column(Float) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=utcnow, index=True + ) + + +class Event(Base): + __tablename__ = "events" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + level: Mapped[str] = mapped_column(String(16), default="info") + message: Mapped[str] = mapped_column(String(512)) + strategy_key: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=utcnow, index=True + ) diff --git a/backend/app/runtime.py b/backend/app/runtime.py new file mode 100644 index 0000000..eaa473f --- /dev/null +++ b/backend/app/runtime.py @@ -0,0 +1,140 @@ +from __future__ import annotations + +import asyncio +from collections.abc import Callable +from contextlib import suppress +from typing import Any + +from app.backtest import Backtester +from app.broker import build_broker +from app.broker.base import Broker +from app.config import Settings +from app.engine.engine import TradingEngine +from app.engine.strategies import get_strategy +from app.market import build_market_data +from app.market.base import MarketData +from app.schemas import BacktestRequest, BacktestResult, StatusOut +from app.store import Store + +FeedFactory = Callable[[Settings], MarketData] +BrokerFactory = Callable[[Settings], Broker] + + +class Runtime: + """Owns the active engine and fans out live updates to WebSocket clients.""" + + def __init__( + self, + settings: Settings, + store: Store | None = None, + *, + feed_factory: FeedFactory = build_market_data, + broker_factory: BrokerFactory = build_broker, + ) -> None: + self.settings = settings + self.store = store or Store() + self._feed_factory = feed_factory + self._broker_factory = broker_factory + self.engine: TradingEngine | None = None + self._subscribers: set[asyncio.Queue] = set() + + # --- pub/sub ------------------------------------------------------ + def subscribe(self) -> asyncio.Queue: + queue: asyncio.Queue = asyncio.Queue(maxsize=100) + self._subscribers.add(queue) + return queue + + def unsubscribe(self, queue: asyncio.Queue) -> None: + self._subscribers.discard(queue) + + def _broadcast(self, payload: dict[str, Any]) -> None: + for queue in list(self._subscribers): + if queue.full(): + with suppress(asyncio.QueueEmpty): + queue.get_nowait() + with suppress(asyncio.QueueFull): + queue.put_nowait(payload) + + # --- lifecycle ---------------------------------------------------- + async def start( + self, + strategy_key: str, + params: dict[str, Any] | None = None, + *, + symbol: str | None = None, + timeframe: str | None = None, + starting_balance: float | None = None, + ) -> StatusOut: + if self.engine is not None and self.engine.running: + await self.stop() + engine = TradingEngine( + feed=self._feed_factory(self.settings), + broker=self._broker_factory(self.settings), + store=self.store, + settings=self.settings, + ) + engine.set_publisher(self._broadcast) + self.engine = engine + await engine.start( + strategy_key, + params, + symbol=symbol, + timeframe=timeframe, + starting_balance=starting_balance, + ) + return engine.status() + + async def stop(self) -> StatusOut: + if self.engine is not None: + await self.engine.stop() + return self.engine.status() + return self._idle_status() + + async def run_backtest(self, request: BacktestRequest) -> BacktestResult: + symbol = request.symbol or self.settings.symbol + timeframe = request.timeframe or self.settings.timeframe + strategy_cls = get_strategy(request.strategy_key) + strategy = strategy_cls(request.params) + + feed = self._feed_factory(self.settings) + try: + candles = await feed.fetch_candles(symbol, timeframe, request.limit) + finally: + await feed.close() + + backtester = Backtester( + fee_bps=self.settings.fee_bps, + slippage_bps=self.settings.slippage_bps, + starting_balance=request.starting_balance or self.settings.starting_balance, + ) + return backtester.run(candles, strategy, symbol, timeframe) + + async def shutdown(self) -> None: + if self.engine is not None: + await self.engine.shutdown() + + def status(self) -> StatusOut: + if self.engine is not None: + return self.engine.status() + return self._idle_status() + + def _idle_status(self) -> StatusOut: + return StatusOut( + running=False, + mode=self.settings.mode, + exchange=self.settings.exchange, + symbol=self.settings.symbol, + timeframe=self.settings.timeframe, + strategy_key=None, + strategy_name=None, + params={}, + price=None, + cash=self.settings.starting_balance, + equity=self.settings.starting_balance, + starting_balance=self.settings.starting_balance, + realized_pnl=0.0, + unrealized_pnl=0.0, + total_pnl=0.0, + open_positions=0, + last_tick=None, + ) diff --git a/backend/app/schemas.py b/backend/app/schemas.py new file mode 100644 index 0000000..e2895bc --- /dev/null +++ b/backend/app/schemas.py @@ -0,0 +1,179 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Any, Literal + +from pydantic import BaseModel, ConfigDict, Field + + +class Candle(BaseModel): + ts: int + open: float + high: float + low: float + close: float + volume: float + + +class ParamSpec(BaseModel): + name: str + type: Literal["int", "float", "bool", "str"] + default: Any + description: str = "" + min: float | None = None + max: float | None = None + + +class StrategyOut(BaseModel): + key: str + name: str + description: str + params: list[ParamSpec] + + +class PositionOut(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + strategy_key: str + symbol: str + side: str + qty: float + entry_price: float + status: str + stop_loss: float | None = None + take_profit: float | None = None + exit_price: float | None = None + realized_pnl: float | None = None + unrealized_pnl: float | None = None + opened_at: datetime + closed_at: datetime | None = None + + +class OrderOut(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + position_id: int | None + strategy_key: str + symbol: str + side: str + qty: float + price: float + fee: float + reason: str + created_at: datetime + + +class EventOut(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + level: str + message: str + strategy_key: str | None + created_at: datetime + + +class EquityPointOut(BaseModel): + model_config = ConfigDict(from_attributes=True) + + equity: float + cash: float + price: float + created_at: datetime + + +class StatusOut(BaseModel): + running: bool + mode: str + exchange: str + symbol: str + timeframe: str + strategy_key: str | None + strategy_name: str | None + params: dict[str, Any] = Field(default_factory=dict) + price: float | None + cash: float + equity: float + starting_balance: float + realized_pnl: float + unrealized_pnl: float + total_pnl: float + open_positions: int + last_tick: datetime | None + error: str | None = None + + +class StartRequest(BaseModel): + strategy_key: str + symbol: str | None = None + timeframe: str | None = None + params: dict[str, Any] = Field(default_factory=dict) + starting_balance: float | None = None + + +class ManualOrderRequest(BaseModel): + side: Literal["buy", "sell"] + qty: float | None = None + quote_amount: float | None = None + reason: str = "manual" + + +class ClosePositionRequest(BaseModel): + reason: str = "manual" + + +class BacktestRequest(BaseModel): + strategy_key: str + params: dict[str, Any] = Field(default_factory=dict) + symbol: str | None = None + timeframe: str | None = None + limit: int = Field(default=500, ge=20, le=1000) + starting_balance: float | None = None + + +class BacktestTradeOut(BaseModel): + side: str + entry_ts: int + exit_ts: int + entry_price: float + exit_price: float + qty: float + pnl: float + return_pct: float + reason: str + + +class BacktestPointOut(BaseModel): + ts: int + equity: float + buy_hold: float + price: float + + +class BacktestMetricsOut(BaseModel): + initial_equity: float + final_equity: float + total_return_pct: float + buy_hold_return_pct: float + max_drawdown_pct: float + num_trades: int + win_rate_pct: float + profit_factor: float | None + avg_trade_pct: float + best_trade_pct: float + worst_trade_pct: float + sharpe: float | None + + +class BacktestResult(BaseModel): + strategy_key: str + strategy_name: str + symbol: str + timeframe: str + starting_balance: float + metrics: BacktestMetricsOut + equity_curve: list[BacktestPointOut] + trades: list[BacktestTradeOut] + error: str | None = None diff --git a/backend/app/store.py b/backend/app/store.py new file mode 100644 index 0000000..3653735 --- /dev/null +++ b/backend/app/store.py @@ -0,0 +1,173 @@ +from __future__ import annotations + +from sqlalchemy import select +from sqlalchemy.orm import sessionmaker + +from app.db import SessionLocal +from app.models import ( + EquitySnapshot, + Event, + Order, + OrderSide, + Position, + PositionSide, + PositionStatus, + StrategyConfig, +) + + +class Store: + """Thin persistence layer around the trading tables.""" + + def __init__(self, session_factory: sessionmaker = SessionLocal) -> None: + self._session_factory: sessionmaker = session_factory + + def create_position( + self, + *, + strategy_key: str, + symbol: str, + side: PositionSide, + qty: float, + entry_price: float, + entry_fee: float, + stop_loss: float | None = None, + take_profit: float | None = None, + ) -> Position: + with self._session_factory() as session: + position = Position( + strategy_key=strategy_key, + symbol=symbol, + side=side, + qty=qty, + entry_price=entry_price, + entry_fee=entry_fee, + stop_loss=stop_loss, + take_profit=take_profit, + status=PositionStatus.OPEN, + ) + session.add(position) + session.commit() + return position + + def close_position( + self, position: Position, *, exit_price: float, exit_fee: float, realized_pnl: float + ) -> Position: + from app.models import utcnow + + with self._session_factory() as session: + merged = session.merge(position) + merged.status = PositionStatus.CLOSED + merged.exit_price = exit_price + merged.exit_fee = exit_fee + merged.realized_pnl = realized_pnl + merged.closed_at = utcnow() + session.commit() + return merged + + def abandon_open_positions(self, strategy_key: str, reason: str = "reset") -> int: + """Mark lingering open positions closed at entry price (used on restart).""" + from app.models import utcnow + + with self._session_factory() as session: + positions = list( + session.scalars( + select(Position).where( + Position.strategy_key == strategy_key, + Position.status == PositionStatus.OPEN, + ) + ).all() + ) + for position in positions: + position.status = PositionStatus.CLOSED + position.exit_price = position.entry_price + position.exit_fee = 0.0 + position.realized_pnl = -position.entry_fee + position.closed_at = utcnow() + session.add_all(positions) + session.commit() + return len(positions) + + def open_position_for(self, strategy_key: str) -> Position | None: + with self._session_factory() as session: + return session.scalars( + select(Position) + .where( + Position.strategy_key == strategy_key, + Position.status == PositionStatus.OPEN, + ) + .order_by(Position.id.desc()) + ).first() + + def record_order( + self, + *, + position_id: int | None, + strategy_key: str, + symbol: str, + side: OrderSide, + qty: float, + price: float, + fee: float, + reason: str, + ) -> Order: + with self._session_factory() as session: + order = Order( + position_id=position_id, + strategy_key=strategy_key, + symbol=symbol, + side=side, + qty=qty, + price=price, + fee=fee, + reason=reason, + ) + session.add(order) + session.commit() + return order + + def record_event(self, level: str, message: str, strategy_key: str | None = None) -> Event: + with self._session_factory() as session: + event = Event(level=level, message=message, strategy_key=strategy_key) + session.add(event) + session.commit() + return event + + def record_equity(self, equity: float, cash: float, price: float) -> None: + with self._session_factory() as session: + session.add(EquitySnapshot(equity=equity, cash=cash, price=price)) + session.commit() + + def list_positions(self, limit: int = 50) -> list[Position]: + with self._session_factory() as session: + return list( + session.scalars(select(Position).order_by(Position.id.desc()).limit(limit)).all() + ) + + def list_orders(self, limit: int = 50) -> list[Order]: + with self._session_factory() as session: + return list(session.scalars(select(Order).order_by(Order.id.desc()).limit(limit)).all()) + + def list_events(self, limit: int = 50) -> list[Event]: + with self._session_factory() as session: + return list(session.scalars(select(Event).order_by(Event.id.desc()).limit(limit)).all()) + + def list_equity(self, limit: int = 500) -> list[EquitySnapshot]: + with self._session_factory() as session: + rows = list( + session.scalars( + select(EquitySnapshot).order_by(EquitySnapshot.id.desc()).limit(limit) + ).all() + ) + return list(reversed(rows)) + + def save_strategy_config( + self, strategy_key: str, symbol: str, timeframe: str, params: dict + ) -> StrategyConfig: + with self._session_factory() as session: + config = StrategyConfig( + strategy_key=strategy_key, symbol=symbol, timeframe=timeframe, params=params + ) + session.add(config) + session.commit() + return config diff --git a/backend/pytest.ini b/backend/pytest.ini new file mode 100644 index 0000000..9d555a4 --- /dev/null +++ b/backend/pytest.ini @@ -0,0 +1,4 @@ +[pytest] +asyncio_mode = auto +testpaths = tests +addopts = -q diff --git a/backend/requirements-dev.txt b/backend/requirements-dev.txt new file mode 100644 index 0000000..3f6741d --- /dev/null +++ b/backend/requirements-dev.txt @@ -0,0 +1,5 @@ +-r requirements.txt +pytest>=8.3 +pytest-asyncio>=0.25 +anyio>=4.7 +ruff>=0.8 diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..530e59d --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,11 @@ +# Core web stack +fastapi>=0.115 +uvicorn>=0.34 +pydantic>=2.9 +pydantic-settings>=2.7 +# Persistence (sync SQLAlchemy; SQLite by default, Postgres optional) +SQLAlchemy>=2.0.36 +# HTTP client used for market data + live exchange REST calls +httpx>=0.28 +# WebSocket protocol support for uvicorn / starlette +websockets>=14.0 diff --git a/backend/ruff.toml b/backend/ruff.toml new file mode 100644 index 0000000..724c37b --- /dev/null +++ b/backend/ruff.toml @@ -0,0 +1,9 @@ +line-length = 100 +target-version = "py311" + +[lint] +select = ["E", "F", "I", "UP", "B", "C4", "SIM"] +ignore = ["B008"] + +[format] +quote-style = "double" diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000..f085247 --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,63 @@ +# ruff: noqa: E402, I001 +from __future__ import annotations + +import os + +os.environ["DATABASE_URL"] = "sqlite:///./data/test_trader.db" +os.environ["FEE_BPS"] = "0" +os.environ["SLIPPAGE_BPS"] = "0" +os.environ["POLL_INTERVAL"] = "3600" +os.environ["STARTING_BALANCE"] = "10000" + +from collections.abc import Iterator # noqa: E402 + +import pytest # noqa: E402 + +from app.broker.paper import PaperBroker # noqa: E402 +from app.config import Settings # noqa: E402 +from app.market.fake import FakeMarketData # noqa: E402 +from app.models import Base # noqa: E402 +from app.db import engine as db_engine # noqa: E402 +from app.runtime import Runtime # noqa: E402 +from app.store import Store # noqa: E402 + + +@pytest.fixture +def settings() -> Settings: + return Settings( + mode="paper", + symbol="BTC/USDT", + timeframe="1m", + poll_interval=3600, + starting_balance=10_000, + fee_bps=0, + slippage_bps=0, + ) + + +@pytest.fixture(autouse=True) +def clean_db() -> Iterator[None]: + Base.metadata.drop_all(bind=db_engine) + Base.metadata.create_all(bind=db_engine) + yield + Base.metadata.drop_all(bind=db_engine) + + +@pytest.fixture +def store() -> Store: + return Store() + + +@pytest.fixture +def market() -> FakeMarketData: + return FakeMarketData.from_closes([100.0, 101.0, 102.0]) + + +@pytest.fixture +def runtime(settings: Settings, store: Store, market: FakeMarketData) -> Runtime: + return Runtime( + settings, + store, + feed_factory=lambda _settings: market, + broker_factory=lambda _settings: PaperBroker(fee_bps=0, slippage_bps=0), + ) diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py new file mode 100644 index 0000000..e14da9e --- /dev/null +++ b/backend/tests/test_api.py @@ -0,0 +1,84 @@ +from __future__ import annotations + +from fastapi.testclient import TestClient + +from app.main import create_app +from app.market.fake import FakeMarketData +from app.runtime import Runtime + + +def test_health_and_strategies(runtime: Runtime) -> None: + app = create_app(runtime.settings, runtime) + with TestClient(app) as client: + assert client.get("/api/health").json() == {"status": "ok"} + keys = {s["key"] for s in client.get("/api/strategies").json()} + assert {"sma_cross", "rsi_reversion"} <= keys + + +def test_start_status_stop_flow(runtime: Runtime) -> None: + app = create_app(runtime.settings, runtime) + with TestClient(app) as client: + status = client.get("/api/status").json() + assert status["running"] is False + + started = client.post( + "/api/bot/start", + json={"strategy_key": "sma_cross", "params": {"fast": 2, "slow": 4}}, + ) + assert started.status_code == 200 + assert started.json()["running"] is True + + assert client.get("/api/positions").status_code == 200 + assert client.get("/api/orders").status_code == 200 + assert client.get("/api/events").status_code == 200 + + stopped = client.post("/api/bot/stop").json() + assert stopped["running"] is False + + +def test_unknown_strategy_returns_404(runtime: Runtime) -> None: + app = create_app(runtime.settings, runtime) + with TestClient(app) as client: + response = client.post("/api/bot/start", json={"strategy_key": "does_not_exist"}) + assert response.status_code == 404 + + +def test_backtest_endpoint(runtime: Runtime, market: FakeMarketData) -> None: + market.candles = FakeMarketData.sine_wave(200).candles + app = create_app(runtime.settings, runtime) + with TestClient(app) as client: + response = client.post( + "/api/backtest", + json={"strategy_key": "sma_cross", "params": {"fast": 5, "slow": 20}, "limit": 200}, + ) + assert response.status_code == 200 + data = response.json() + assert data["strategy_key"] == "sma_cross" + assert data["error"] is None + assert len(data["equity_curve"]) == 200 + assert "total_return_pct" in data["metrics"] + assert data["metrics"]["num_trades"] >= 1 + + +def test_backtest_unknown_strategy_404(runtime: Runtime) -> None: + app = create_app(runtime.settings, runtime) + with TestClient(app) as client: + response = client.post("/api/backtest", json={"strategy_key": "nope"}) + assert response.status_code == 404 + + +def test_backtest_limit_validation(runtime: Runtime) -> None: + app = create_app(runtime.settings, runtime) + with TestClient(app) as client: + assert ( + client.post("/api/backtest", json={"strategy_key": "sma_cross", "limit": 5}).status_code + == 422 + ) + + +def test_websocket_sends_initial_status(runtime: Runtime) -> None: + app = create_app(runtime.settings, runtime) + with TestClient(app) as client, client.websocket_connect("/api/ws") as websocket: + message = websocket.receive_json() + assert message["type"] == "status" + assert message["data"]["running"] is False diff --git a/backend/tests/test_backtest.py b/backend/tests/test_backtest.py new file mode 100644 index 0000000..b1a069f --- /dev/null +++ b/backend/tests/test_backtest.py @@ -0,0 +1,142 @@ +from __future__ import annotations + +from typing import ClassVar + +import pytest + +from app.backtest import Backtester +from app.backtest.metrics import max_drawdown_pct, sharpe_ratio +from app.engine.strategies.base import Strategy, StrategyContext +from app.schemas import BacktestPointOut, BacktestTradeOut, Candle + + +class HoldStrategy(Strategy): + """Enter long on the first candle and stay in (closed at end of data).""" + + key: ClassVar[str] = "test_hold" + name: ClassVar[str] = "Test Hold" + default_params: ClassVar[dict] = {} + + def on_candle(self, ctx: StrategyContext) -> None: + if ctx.is_flat: + ctx.enter_long(size=1.0) + + +class NeverStrategy(Strategy): + key: ClassVar[str] = "test_never" + name: ClassVar[str] = "Test Never" + default_params: ClassVar[dict] = {} + + def on_candle(self, ctx: StrategyContext) -> None: + return None + + +class StopStrategy(Strategy): + key: ClassVar[str] = "test_stop" + name: ClassVar[str] = "Test Stop" + default_params: ClassVar[dict] = {} + + def on_candle(self, ctx: StrategyContext) -> None: + if ctx.is_flat and not getattr(self, "_entered", False): + self._entered = True + ctx.enter_long(size=1.0, stop_loss=ctx.price * 0.9) + + +def candles_from_closes(closes: list[float]) -> list[Candle]: + return [Candle(ts=i, open=c, high=c, low=c, close=c, volume=1.0) for i, c in enumerate(closes)] + + +def test_hold_backtest_math() -> None: + backtester = Backtester(fee_bps=0, slippage_bps=0, starting_balance=10_000) + result = backtester.run(candles_from_closes([100, 110, 120]), HoldStrategy(), "BTC/USDT", "1h") + + assert result.error is None + assert result.metrics.num_trades == 1 + # 99 units bought at 100 (cash capped at 99%), closed at 120. + assert result.metrics.final_equity == pytest.approx(11_980.0) + assert result.trades[0].pnl == pytest.approx(1_980.0) + assert result.trades[0].return_pct == pytest.approx(20.0) + assert result.trades[0].reason == "end of data" + assert len(result.equity_curve) == 3 + + +def test_no_trades_leaves_balance_untouched() -> None: + backtester = Backtester(fee_bps=10, slippage_bps=5, starting_balance=5_000) + result = backtester.run( + candles_from_closes([100, 101, 102, 103]), NeverStrategy(), "BTC/USDT", "1m" + ) + assert result.metrics.num_trades == 0 + assert result.metrics.final_equity == pytest.approx(5_000.0) + assert result.metrics.total_return_pct == pytest.approx(0.0) + assert result.metrics.max_drawdown_pct == pytest.approx(0.0) + + +def test_stop_loss_is_honoured() -> None: + backtester = Backtester(fee_bps=0, slippage_bps=0, starting_balance=10_000) + candles = candles_from_closes([100, 100, 80]) + candles[2] = Candle(ts=2, open=100, high=100, low=80, close=80, volume=1.0) + result = backtester.run(candles, StopStrategy(), "BTC/USDT", "1h") + + assert result.metrics.num_trades == 1 + assert "stop loss" in result.trades[0].reason + assert result.metrics.final_equity < 10_000 + + +def test_strategy_error_is_surfaced() -> None: + class Boom(Strategy): + key = "test_boom" + name = "Boom" + default_params: ClassVar[dict] = {} + + def on_candle(self, ctx: StrategyContext) -> None: + raise RuntimeError("kaboom") + + result = Backtester().run(candles_from_closes([1, 2, 3]), Boom(), "BTC/USDT", "1m") + assert result.error is not None and "kaboom" in result.error + + +def test_max_drawdown() -> None: + assert max_drawdown_pct([100, 120, 90, 130]) == pytest.approx(25.0) + assert max_drawdown_pct([100, 100, 100]) == pytest.approx(0.0) + + +def test_sharpe_none_for_unknown_timeframe() -> None: + assert sharpe_ratio([100, 101, 102, 103], "nonsense") is None + + +def _points(equities: list[float]) -> list[BacktestPointOut]: + return [BacktestPointOut(ts=i, equity=e, buy_hold=e, price=e) for i, e in enumerate(equities)] + + +def test_metrics_profit_factor_and_win_rate() -> None: + from app.backtest.metrics import compute_metrics + + trades = [ + BacktestTradeOut( + side="long", + entry_ts=0, + exit_ts=1, + entry_price=100, + exit_price=110, + qty=1, + pnl=10, + return_pct=10, + reason="x", + ), + BacktestTradeOut( + side="long", + entry_ts=2, + exit_ts=3, + entry_price=100, + exit_price=95, + qty=1, + pnl=-5, + return_pct=-5, + reason="x", + ), + ] + metrics = compute_metrics(_points([10_000, 10_010, 10_005]), trades, 10_000, "1h") + assert metrics.win_rate_pct == pytest.approx(50.0) + assert metrics.profit_factor == pytest.approx(2.0) + assert metrics.best_trade_pct == pytest.approx(10.0) + assert metrics.worst_trade_pct == pytest.approx(-5.0) diff --git a/backend/tests/test_engine.py b/backend/tests/test_engine.py new file mode 100644 index 0000000..8bbfc7a --- /dev/null +++ b/backend/tests/test_engine.py @@ -0,0 +1,110 @@ +from __future__ import annotations + +from typing import ClassVar + +import pytest + +from app.broker.paper import PaperBroker +from app.config import Settings +from app.engine.engine import TradingEngine +from app.engine.strategies.base import Strategy, StrategyContext +from app.market.fake import FakeMarketData +from app.models import OrderSide, PositionSide +from app.schemas import Candle +from app.store import Store + + +class ToggleStrategy(Strategy): + key: ClassVar[str] = "test_toggle" + name: ClassVar[str] = "Toggle" + default_params: ClassVar[dict] = {} + + def on_candle(self, ctx: StrategyContext) -> None: + if ctx.is_flat: + ctx.enter_long(size=0.5) + else: + ctx.close() + + +def build_engine(settings: Settings, store: Store, market: FakeMarketData) -> TradingEngine: + return TradingEngine( + feed=market, + broker=PaperBroker(fee_bps=0, slippage_bps=0), + store=store, + settings=settings, + ) + + +async def test_manual_open_and_close_pnl(settings: Settings, store: Store) -> None: + market = FakeMarketData.from_closes([100.0]) + engine = build_engine(settings, store, market) + engine.symbol = "BTC/USDT" + engine.last_price = 100.0 + + await engine.manual_order(OrderSide.BUY, qty=1.0, quote_amount=None, reason="test") + assert engine.position is not None + assert engine.cash == pytest.approx(9_900.0) + assert engine.equity == pytest.approx(10_000.0) + + engine.last_price = 110.0 + assert engine.equity == pytest.approx(10_010.0) + assert engine.unrealized_pnl == pytest.approx(10.0) + + await engine.close_current_position() + assert engine.position is None + assert engine.realized_pnl == pytest.approx(10.0) + assert engine.cash == pytest.approx(10_010.0) + + +async def test_stop_loss_exits_at_level(settings: Settings, store: Store) -> None: + market = FakeMarketData.from_closes([100.0]) + engine = build_engine(settings, store, market) + engine.symbol = "BTC/USDT" + engine.last_price = 100.0 + + await engine._open_position( + PositionSide.LONG, + size=0.0, + stop_loss=95.0, + take_profit=120.0, + reason="entry", + qty_override=1.0, + ) + await engine._check_risk(Candle(ts=0, open=100, high=101, low=94, close=96, volume=1)) + assert engine.position is None + assert engine.realized_pnl == pytest.approx(-5.0) + + +async def test_take_profit_exits_at_level(settings: Settings, store: Store) -> None: + market = FakeMarketData.from_closes([100.0]) + engine = build_engine(settings, store, market) + engine.symbol = "BTC/USDT" + engine.last_price = 100.0 + + await engine._open_position( + PositionSide.LONG, + size=0.0, + stop_loss=95.0, + take_profit=110.0, + reason="entry", + qty_override=1.0, + ) + await engine._check_risk(Candle(ts=0, open=100, high=111, low=100, close=109, volume=1)) + assert engine.position is None + assert engine.realized_pnl == pytest.approx(10.0) + + +async def test_tick_runs_strategy_and_opens_position(settings: Settings, store: Store) -> None: + market = FakeMarketData.from_closes([100.0, 100.0]) + engine = build_engine(settings, store, market) + engine.strategy = ToggleStrategy() + engine.strategy_key = "test_toggle" + engine.symbol = "BTC/USDT" + + await engine.tick() + assert engine.position is not None + assert engine.position.side == PositionSide.LONG + + market.append(Candle(ts=99, open=110, high=110, low=110, close=110, volume=1)) + await engine.tick() + assert engine.position is None diff --git a/backend/tests/test_indicators.py b/backend/tests/test_indicators.py new file mode 100644 index 0000000..ef9ccb0 --- /dev/null +++ b/backend/tests/test_indicators.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +import pytest + +from app.engine.indicators import atr, ema, rolling_std, rsi, sma +from app.schemas import Candle + + +def candle(high: float, low: float, close: float, open_: float | None = None) -> Candle: + return Candle( + ts=0, + open=open_ if open_ is not None else close, + high=high, + low=low, + close=close, + volume=1, + ) + + +def test_sma() -> None: + assert sma([1, 2, 3, 4, 5], 3) == [None, None, 2.0, 3.0, 4.0] + + +def test_ema() -> None: + assert ema([1, 2, 3, 4, 5], 3) == [None, None, 2.0, 3.0, 4.0] + + +def test_rsi_monotonic_is_100() -> None: + values = [float(i) for i in range(1, 20)] + assert rsi(values, 14)[-1] == pytest.approx(100.0) + + +def test_atr_flat_market_is_zero() -> None: + candles = [candle(100, 100, 100) for _ in range(20)] + assert atr(candles, 5)[-1] == pytest.approx(0.0) + + +def test_rolling_std() -> None: + result = rolling_std([1, 1, 1, 1], 2) + assert result[-1] == pytest.approx(0.0) diff --git a/backend/tests/test_portfolio.py b/backend/tests/test_portfolio.py new file mode 100644 index 0000000..ca4a05f --- /dev/null +++ b/backend/tests/test_portfolio.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +import pytest + +from app.engine.portfolio import ( + account_equity, + apply_close, + apply_open, + order_qty, + realized_pnl, + risk_exit, + unrealized_pnl, +) +from app.models import PositionSide + + +def test_apply_open_long_and_short() -> None: + assert apply_open(1000.0, PositionSide.LONG, 1.0, 100.0, 1.0) == pytest.approx(899.0) + assert apply_open(1000.0, PositionSide.SHORT, 1.0, 100.0, 1.0) == pytest.approx(1099.0) + + +def test_apply_close_long_and_short() -> None: + assert apply_close(100.0, PositionSide.LONG, 1.0, 120.0, 1.0) == pytest.approx(219.0) + assert apply_close(2100.0, PositionSide.SHORT, 1.0, 90.0, 1.0) == pytest.approx(2009.0) + + +def test_realized_pnl_includes_fees() -> None: + assert realized_pnl(PositionSide.LONG, 100, 110, 2, 1, 1) == pytest.approx(18.0) + assert realized_pnl(PositionSide.SHORT, 100, 90, 2, 1, 1) == pytest.approx(18.0) + assert realized_pnl(PositionSide.LONG, 100, 90, 2, 1, 1) == pytest.approx(-22.0) + + +def test_unrealized_pnl() -> None: + assert unrealized_pnl(PositionSide.LONG, 100, 110, 3) == pytest.approx(30.0) + assert unrealized_pnl(PositionSide.SHORT, 100, 110, 3) == pytest.approx(-30.0) + + +def test_account_equity_long_and_short() -> None: + assert account_equity(100, PositionSide.LONG, 2, 150) == pytest.approx(400.0) + assert account_equity(300, PositionSide.SHORT, 2, 80) == pytest.approx(140.0) + + +def test_order_qty_caps_longs_by_cash_and_scales_with_size() -> None: + assert order_qty(10_000, 1.0, PositionSide.LONG, 10_000, 100) == pytest.approx(99.0) + assert order_qty(10_000, 0.5, PositionSide.SHORT, 10_000, 100) == pytest.approx(50.0) + assert order_qty(10_000, 1.0, PositionSide.SHORT, 0, 100) == pytest.approx(100.0) + assert order_qty(10_000, 1.0, PositionSide.LONG, 10_000, 0) == 0.0 + + +def test_risk_exit_long() -> None: + assert risk_exit(PositionSide.LONG, 95, 120, high=101, low=94) == ("stop loss", 95) + assert risk_exit(PositionSide.LONG, 95, 120, high=121, low=100) == ("take profit", 120) + assert risk_exit(PositionSide.LONG, 95, 120, high=110, low=100) is None + + +def test_risk_exit_short() -> None: + assert risk_exit(PositionSide.SHORT, 110, 90, high=111, low=100) == ("stop loss", 110) + assert risk_exit(PositionSide.SHORT, 110, 90, high=100, low=89) == ("take profit", 90) + assert risk_exit(PositionSide.SHORT, None, None, high=100, low=95) is None diff --git a/backend/tests/test_strategies.py b/backend/tests/test_strategies.py new file mode 100644 index 0000000..3982e0f --- /dev/null +++ b/backend/tests/test_strategies.py @@ -0,0 +1,68 @@ +from __future__ import annotations + +from app.engine.strategies import get_strategy, list_strategies, strategy_spec +from app.engine.strategies.base import StrategyContext +from app.models import Position, PositionSide +from app.schemas import Candle + + +def make_candles(closes: list[float]) -> list[Candle]: + return [Candle(ts=i, open=c, high=c, low=c, close=c, volume=1.0) for i, c in enumerate(closes)] + + +def test_examples_are_registered() -> None: + keys = {cls.key for cls in list_strategies()} + assert {"sma_cross", "rsi_reversion"} <= keys + + +def test_param_specs_expose_defaults() -> None: + spec = strategy_spec(get_strategy("sma_cross")) + names = {param.name for param in spec.params} + assert {"fast", "slow", "size"} == names + fast = next(param for param in spec.params if param.name == "fast") + assert fast.default == 10 + assert fast.type == "int" + + +def test_unknown_params_rejected() -> None: + cls = get_strategy("sma_cross") + import pytest + + with pytest.raises(ValueError): + cls({"nope": 1}) + + +def test_sma_cross_emits_enter_long() -> None: + strategy = get_strategy("sma_cross")({"fast": 2, "slow": 4, "size": 0.5}) + ctx = StrategyContext( + candles=make_candles([10, 10, 10, 10, 10, 11]), + position=None, + equity=1000, + cash=1000, + ) + strategy.on_candle(ctx) + intents = ctx.take_intents() + assert len(intents) == 1 + assert intents[0].action == "enter_long" + assert intents[0].size == 0.5 + + +def test_sma_cross_emits_close_on_cross_down() -> None: + strategy = get_strategy("sma_cross")({"fast": 2, "slow": 4, "size": 0.5}) + position = Position( + strategy_key="sma_cross", + symbol="BTC/USDT", + side=PositionSide.LONG, + qty=1.0, + entry_price=10.0, + ) + ctx = StrategyContext( + candles=make_candles([10, 10, 10, 10, 10, 11, 8]), + position=position, + equity=1000, + cash=1000, + ) + strategy.on_candle(ctx) + intents = ctx.take_intents() + assert len(intents) == 1 + assert intents[0].action == "close" diff --git a/docker-compose.local.yml b/docker-compose.local.yml new file mode 100644 index 0000000..b11a962 --- /dev/null +++ b/docker-compose.local.yml @@ -0,0 +1,37 @@ +# Local / self-contained stack: no Traefik, publishes ports directly. +# Usage: docker compose -f docker-compose.local.yml up --build +# Dashboard: http://localhost:8080 API docs: http://localhost:8000/docs +name: crypto_trading_local + +services: + backend: + build: ./backend + container_name: crypto-trading-backend-local + restart: unless-stopped + environment: + - MODE=${MODE:-paper} + - EXCHANGE=${EXCHANGE:-binance} + - SYMBOL=${SYMBOL:-BTC/USDT} + - TIMEFRAME=${TIMEFRAME:-1m} + - POLL_INTERVAL=${POLL_INTERVAL:-5} + - STARTING_BALANCE=${STARTING_BALANCE:-10000} + - FEE_BPS=${FEE_BPS:-10} + - SLIPPAGE_BPS=${SLIPPAGE_BPS:-5} + - DATABASE_URL=sqlite:////data/trader.db + - CORS_ORIGINS=* + - EXCHANGE_API_KEY=${EXCHANGE_API_KEY:-} + - EXCHANGE_API_SECRET=${EXCHANGE_API_SECRET:-} + - EXCHANGE_SANDBOX=${EXCHANGE_SANDBOX:-false} + volumes: + - ./data:/data + ports: + - "8000:8000" + + frontend: + build: ./frontend + container_name: crypto-trading-frontend-local + restart: unless-stopped + depends_on: + - backend + ports: + - "8080:80" diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..530c7d8 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,100 @@ +name: crypto_trading + +services: + backend: + build: ./backend + image: crypto_trading-backend + container_name: crypto-trading-backend + restart: unless-stopped + mem_limit: ${CRYPTO_MEM_LIMIT:-1g} + cpus: ${CRYPTO_CPUS:-2} + logging: + driver: json-file + options: + max-size: "10m" + max-file: "5" + security_opt: + - no-new-privileges:true + # Reachable only on the internal network; the frontend nginx proxies to it. + expose: + - "8000" + environment: + - MODE=${CRYPTO_MODE:-paper} + - EXCHANGE=${CRYPTO_EXCHANGE:-binance} + - SYMBOL=${CRYPTO_SYMBOL:-BTC/USDT} + - TIMEFRAME=${CRYPTO_TIMEFRAME:-1m} + - POLL_INTERVAL=${CRYPTO_POLL_INTERVAL:-5} + - STARTING_BALANCE=${CRYPTO_STARTING_BALANCE:-10000} + - FEE_BPS=${CRYPTO_FEE_BPS:-10} + - SLIPPAGE_BPS=${CRYPTO_SLIPPAGE_BPS:-5} + - DATABASE_URL=sqlite:////data/trader.db + - CORS_ORIGINS=${CRYPTO_CORS_ORIGINS:-*} + - EXCHANGE_API_KEY=${CRYPTO_EXCHANGE_API_KEY:-} + - EXCHANGE_API_SECRET=${CRYPTO_EXCHANGE_API_SECRET:-} + - EXCHANGE_SANDBOX=${CRYPTO_EXCHANGE_SANDBOX:-false} + volumes: + # Persist the SQLite database across releases. Prefer an absolute host + # path (CRYPTO_DATA_DIR / PERSISTENT_MOUNT); the repo-relative default + # can be cleaned by runner maintenance. + - ${CRYPTO_DATA_DIR:-./data}:/data + healthcheck: + test: ["CMD-SHELL", "python -c \"import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8000/api/health', timeout=5).status==200 else 1)\""] + interval: 30s + timeout: 10s + retries: 3 + start_period: 20s + networks: + - internal + + frontend: + build: ./frontend + image: crypto_trading-frontend + container_name: crypto-trading-frontend + restart: unless-stopped + mem_limit: ${CRYPTO_MEM_LIMIT:-1g} + cpus: ${CRYPTO_CPUS:-2} + logging: + driver: json-file + options: + max-size: "10m" + max-file: "5" + security_opt: + - no-new-privileges:true + depends_on: + backend: + condition: service_healthy + healthcheck: + test: ["CMD-SHELL", "wget -q --spider http://127.0.0.1:80/ || exit 1"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 10s + networks: + - internal + - docker_default + labels: + - "traefik.enable=true" + - "traefik.docker.network=docker_default" + - "traefik.http.routers.crypto-trading.rule=Host(`crypto.${BASE_DOMAIN:-matitos.org}`)" + - "traefik.http.routers.crypto-trading.entrypoints=websecure" + - "traefik.http.routers.crypto-trading.tls.certresolver=myresolvercd" + - "traefik.http.services.crypto-trading.loadbalancer.server.port=80" + # Basic auth on the WebUI (htpasswd), required: the stack refuses to + # start when CRYPTO_FRONTEND_AUTH_USERS is unset (no admin/admin + # fallback). A per-source rate limit reduces credential brute-forcing. + - "traefik.http.routers.crypto-trading.middlewares=crypto-trading-auth@docker,crypto-trading-sec@docker,crypto-trading-authlimit@docker" + - "traefik.http.middlewares.crypto-trading-auth.basicauth.users=${CRYPTO_FRONTEND_AUTH_USERS:?CRYPTO_FRONTEND_AUTH_USERS is required (htpasswd string, e.g. htpasswd -nbB user pass)}" + - "traefik.http.middlewares.crypto-trading-authlimit.ratelimit.average=${CRYPTO_AUTH_RATE_LIMIT_AVERAGE:-10}" + - "traefik.http.middlewares.crypto-trading-authlimit.ratelimit.burst=${CRYPTO_AUTH_RATE_LIMIT_BURST:-5}" + - "traefik.http.middlewares.crypto-trading-sec.headers.stsSeconds=31536000" + - "traefik.http.middlewares.crypto-trading-sec.headers.stsIncludeSubdomains=true" + - "traefik.http.middlewares.crypto-trading-sec.headers.forceSTSHeader=true" + - "traefik.http.middlewares.crypto-trading-sec.headers.contentTypeNosniff=true" + - "traefik.http.middlewares.crypto-trading-sec.headers.browserXssFilter=true" + - "traefik.http.middlewares.crypto-trading-sec.headers.frameDeny=true" + - "traefik.http.middlewares.crypto-trading-sec.headers.referrerPolicy=strict-origin-when-cross-origin" + +networks: + internal: + docker_default: + external: true diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..4bbe5d4 --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,6 @@ +node_modules/ +dist/ +.vite/ +*.log +.DS_Store +.git/ diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..3f97eb8 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,12 @@ +FROM node:22-alpine AS build + +WORKDIR /app +COPY package.json package-lock.json ./ +RUN npm ci +COPY . . +RUN npm run build + +FROM nginx:alpine +COPY nginx.conf /etc/nginx/conf.d/default.conf +COPY --from=build /app/dist /usr/share/nginx/html +EXPOSE 80 diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..72d8fc5 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + Crypto Trader + + +
+ + + diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..d0631a7 --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,20 @@ +server { + listen 80; + server_name _; + root /usr/share/nginx/html; + index index.html; + + location / { + try_files $uri $uri/ /index.html; + } + + location /api/ { + proxy_pass http://backend:8000; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_read_timeout 3600s; + } +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..8af97c4 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,4906 @@ +{ + "name": "crypto-trading-frontend", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "crypto-trading-frontend", + "version": "0.1.0", + "dependencies": { + "axios": "^1.7.9", + "vue": "^3.5.13" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.2.1", + "@vue/test-utils": "^2.4.6", + "autoprefixer": "^10.4.20", + "jsdom": "^25.0.1", + "postcss": "^8.4.49", + "tailwindcss": "^3.4.17", + "vite": "^6.0.7", + "vitest": "^2.1.9" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.3.0.tgz", + "integrity": "sha512-U4+70Pc5ZS9osnCBCE5Jha/ciHM+Yp+CNMNC/7HvYbNRk1Ldd+f7qO65W5qfhu/TCv+/ozljlXXe9Nj8419DMA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "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==", + "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==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.9", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.9.tgz", + "integrity": "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "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/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.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "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/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@one-ini/wasm": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@one-ini/wasm/-/wasm-0.2.1.tgz", + "integrity": "sha512-TUqERXGNTifZ9y2g3wPxQrw3HpHv/02DsW3D90T9x0hhonrL1ZqpSmNrU2XkoIq0fP1N6gZfVQzy2Fw1ZvGBNg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.3.tgz", + "integrity": "sha512-w3Jnvi1ocaVm/c7yVPpfB98XeSRBMyzp6njL5MVVbGyXjpmUkN+s6Hp4t0PqhGCCaI1ZHMKXt/w0lA1RCaLVcw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.3.tgz", + "integrity": "sha512-uI/ESiaIbbRYAEhzy8PCUWDp1hB0bjAqM06mW9flOoNO4Q8DQpeoREhBR5Hegfl+wpXiguyJv6XSPzEN7OxyHQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.3.tgz", + "integrity": "sha512-oxhrd1jmXLwWZ83eQYDXxuqRdkqkzrjR3JobKeuUyfdNZo11FuQIvqEOZhyIT7OBHxXoGslDDjN0cQcM6T0TqQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.3.tgz", + "integrity": "sha512-7/YiIMghVE8DrxKvNdorAaJVdriOFgOIpdStnPx8ppx5zfTwC3jBCSEAIzB7JD5404m65THl6H93UTTVUvypmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.3.tgz", + "integrity": "sha512-GXFZRRoMAytaI5z6N3Zhfw0WL18Q0M8r95D5hlC4GqE/lGk8pbSJNUBoOWDfbm6dTciqHj2nU87tI5f6XhQiOg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.3.tgz", + "integrity": "sha512-77W+8X3ddYgPxUpB8nZFQs2Mq+wc4HVlcSRtApXLjYBcnPMkttrSnU8VwKQjeWYhMsITHFs5cWBQ8vz1Q+5RHQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.3.tgz", + "integrity": "sha512-FVkwK+iUC+mq+GipVK46rRVticfAPtvPUNlqlGXUDxdVk/UGjQiiiUVPUrEXdSpU2ufU0XxLGyTqDtBidDOVmg==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.3.tgz", + "integrity": "sha512-+aGU1t3398yQOVj1Bz8o3e+KtswxAPvO+mtxtNdfXYMkXIHu7XhhkCD7/DEH9q8tF8uhDnMWvfpUKI8y1sZJsg==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.3.tgz", + "integrity": "sha512-cR0kjpRXR2KJ2oQK8E2KTPtphs+b9hZ8IhTZubNryt/RsqgdOZBQ2Zq0q5UedtiIi0rs3jVhJh55RE1ZHUVGUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.3.tgz", + "integrity": "sha512-y1RYi4Q3/9ByVWSSt9kX2ustE0B7kFYbJ6zZdVZVyqopZs3yhCTwRfrjIX4vezUJInma/Gs6BOFDJg7yZmJ0IQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.3.tgz", + "integrity": "sha512-DNhEA5viIj3Z5bZLE4z4oV8N5ozWqDwyt7T6KG7VdLDJ0nW+rNOYlphBl4/3HQkK75qipPLsVOfStHHOwN9WSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.3.tgz", + "integrity": "sha512-17gQCqrIpXBX2Cmi9/TygnVOqGbzsba/iaqcYSL8FY7lNugg+7AiYNs5c5nKWD+NRQha36Sa0CqkJqH4XVHwnQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.3.tgz", + "integrity": "sha512-6LwVnZRIyINpdku/yOcI8Tm9YqLmhHK5emmlOOnW9tO0SYEm1FmKPcsSAGp0NBlqR2P04xaND4jvN6sTHqhq8A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.3.tgz", + "integrity": "sha512-xMUqkTXlEUtI/p5AAukMwBRr1enU3efsTeF+bskeFfk8t1C9rcC8sLREcZXmTfAXEbvRdJVSonVJez3TMlbR3w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.3.tgz", + "integrity": "sha512-S3E94co9F9WRRqEaUoQZ38K1gCz6KiM+nL7/3ijq7fDGF3OznjS5TasgYITlvl27GQKtu4lOAOsr5MFwkijvOA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.3.tgz", + "integrity": "sha512-1QtRDwG42x5BJI3s9mxu5rEjDnfbSnk20HQ9/ylTAYnSwYwxMVb+Vgu34wzzTQ7ogqBybebgQNUDAvZVQ38DbA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.3.tgz", + "integrity": "sha512-BQhejF6ZXOpxbngiNTP12GCGQeaDVL2QXGeBVViKIYzFHM5RKxTxwUMB1fr1BeNFphFMpnRqC5QSXFSa4z6UQw==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.3.tgz", + "integrity": "sha512-SXagRwnI2Wlwlitllu59UK/nGVbD1CKPcNqDplHwIC4BqJcpXFjD32d1R/RbuISa95HdQrZM3/7v4bKiowFaLA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.3.tgz", + "integrity": "sha512-2IPozoEALRCziGqE8O9KMK60PMu5TS1huv4fwoeCexj+WjmcwFtX9CTOVbfXCUqcELAubEwRFPYlzb/WvwY2HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.3.tgz", + "integrity": "sha512-AoxqosUHT9IX54hFn2TiN6A7d6ZKTtE6pd2bqWtqkkNJ6HJGaU6FRouGX8L1O7R/ZwsnCnpQrHzb4pDEx+UHRQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.3.tgz", + "integrity": "sha512-d+CaftKgmkFBzCwezMqqy1d0QNNYugqLCMcYVQWBy5SS2YfeMP8Q8ripkgx9O8IyBXXLHrJ+aaCV4U96usv6Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.3.tgz", + "integrity": "sha512-xXlDF6nR1eOuXbdDy5Hl5fmtY7teUDevF/k0O7IPoZe4Tpmdv+lgdE5JRsnhQtt37ql9P0VF2kAN9a0OCZdo+Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.3.tgz", + "integrity": "sha512-YtXAgLN+JP7Ay6qG3eWhc7IHMQPzLc8r3uvhAvlJIoCz/4Q32+Bl9Fmnywidh8v1GOIMmymjovfqY9ETAtysvA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.3.tgz", + "integrity": "sha512-WuWtSJRNo549vzcfZyEgfqb6zeSgn1F+UE5kQ+BCjzz0W4MGCjntUHkZVc1VRuAM7+ULaSyhiPxD1spyewFvkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.3.tgz", + "integrity": "sha512-+lIKX7O0+IGe7WuhATaAMMeT7B76vfhXH/l9wLQL+nvyhbw2ohYCKIdWL56JfDu75CWt5oKRP4QFH/jkMtBquA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "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/@vitejs/plugin-vue": { + "version": "5.2.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", + "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@vitest/expect": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", + "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", + "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", + "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "2.1.9", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", + "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "magic-string": "^0.30.12", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", + "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", + "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "loupe": "^3.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.43", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.43.tgz", + "integrity": "sha512-zdiLhnbe1QQqgDT8xZMpNmyqZ3qlI+/Q/FHQco57Kwl/b05HhCzN6eVGN9QU9rbga4CrS0H5SYY8VGHZCt/1Hg==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@vue/shared": "3.5.43", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.43", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.43.tgz", + "integrity": "sha512-PEZoAk3NQmsn/ejMzSOCyTYqwGqczrWm70PuhBKjjv1+TCoQAaO/zOqNwjV+honlNstT5ILxtc+8r8UUfj+iEQ==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.43", + "@vue/shared": "3.5.43" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.43", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.43.tgz", + "integrity": "sha512-FCbrG3XNCRl+js3huuKx4IVHBLTvMkJhVepjbxSPu1gn4yWLaYtGNQdjJGZaMytXB6qb76qQDDmDSLy/vkmleQ==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@vue/compiler-core": "3.5.43", + "@vue/compiler-dom": "3.5.43", + "@vue/compiler-ssr": "3.5.43", + "@vue/shared": "3.5.43", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.28", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.43", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.43.tgz", + "integrity": "sha512-GF62orf7KiJX9RqrHNGrYBudsQGD0OhJ5nDs90O8UiDuS40+YMYomiXu6w6EuvtXuRDc3MSNis3EaaSKAVSWpg==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.43", + "@vue/shared": "3.5.43" + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.43", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.43.tgz", + "integrity": "sha512-G/c9GyOZNI2jVaaS6OX1EF1SSFSv7H0ERqNTl4+DTFMlZmB5eVAB53aLQNam/7NL2NPtaDD7RdVrzf8uJzMuOA==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.43" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.43", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.43.tgz", + "integrity": "sha512-hU6U6VnVhBGQDpvlnnDlIB8ZGJBiOcgk2lh/0InltHiz3D8oSkluvuvY+do1G2H3+udeKFsmaBlgVYP7gXQzEw==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.43", + "@vue/shared": "3.5.43" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.43", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.43.tgz", + "integrity": "sha512-Bb2Jc0YjjJdMt1SJmb9b2L/IWd3I8lIT9x9eS/xvvP9CiVgna0ffua74xKRmt4/uSJ+0r4iN8ex1jrqkhQGWQw==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.43", + "@vue/runtime-core": "3.5.43", + "@vue/shared": "3.5.43", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.43", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.43.tgz", + "integrity": "sha512-l2Ygjv9NehV94PSBxNWsAHC0j/eIIKbn92mBuWXAPGLnn6HfJ6MH5ubsd+Nk0YoZ5FRuxWI1P2VSoh+dbPQhCQ==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.43", + "@vue/runtime-dom": "3.5.43", + "@vue/shared": "3.5.43" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.43", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.43.tgz", + "integrity": "sha512-uksS7YGMR5NZyr4JNq0Rp+QyLns0ueaz20KwzIPW9R0LH1Vnt4E+XUM29PNseEbf1www2gOuhuDi5AKOIXag9Q==", + "license": "MIT" + }, + "node_modules/@vue/test-utils": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@vue/test-utils/-/test-utils-2.5.1.tgz", + "integrity": "sha512-V/4a99odJ7hapr0bRt35XxljGk8hefq31gSOnlKTcEq2fUeoj2qb+FcpJYfQu4qfYhW9Ug+O2+CZ3QmZ7gXDzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-beautify": "^2.0.0", + "vue-component-type-helpers": "^3.0.0" + }, + "peerDependencies": { + "@vue/compiler-dom": "3.x", + "@vue/server-renderer": "3.x", + "vue": "3.x" + }, + "peerDependenciesMeta": { + "@vue/server-renderer": { + "optional": true + } + } + }, + "node_modules/abbrev": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-5.0.0.tgz", + "integrity": "sha512-/XrFJgzQQQHpti1raDJC6m4ws6aNktmjBlhk8Fdlk7LwCEuDoieEJJY9OFHjfiFJFFRM2tK+Ky/IsfbbmlMu1w==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/autoprefixer": { + "version": "10.6.1", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.6.1.tgz", + "integrity": "sha512-cL1Qz6ADZhcEbny/8HPfe99J6HhNoYtpX2LFLIbhgGE7Q1hlQVkYFdetDN7Id3KiQxhDrHwzlHr/YQCnZ8+xSA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.9", + "caniuse-lite": "^1.0.30001810", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/axios": { + "version": "1.20.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.20.0.tgz", + "integrity": "sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.6", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.25", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.25.tgz", + "integrity": "sha512-gMmEShwwq7FJqMwvfRwvCl00v4kN+KOfJqXn+f4nrufak5gNHJOksd/60Dvjuz7sI8Y5WiSFBa8FEYr+zoyqCw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.29.0", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.0.tgz", + "integrity": "sha512-3GSvyjvDI4Dur1Meg2BekJquu5uF+9R9a1+5M1Mde192eZoXbeXjzgOsgqPS2V8D5wrrip0gR5Hf/GhWQ9ZzaA==", + "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.11.23", + "caniuse-lite": "^1.0.30001810", + "electron-to-chromium": "^1.5.427", + "node-releases": "^2.0.55", + "update-browserslist-db": "^1.3.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001810", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz", + "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==", + "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/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", + "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/config-chain": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.13.tgz", + "integrity": "sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ini": "^1.3.4", + "proto-list": "~1.2.1" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/cssstyle/node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT" + }, + "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==", + "license": "MIT" + }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/editorconfig": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/editorconfig/-/editorconfig-3.0.2.tgz", + "integrity": "sha512-T0ix8GhtxyKVfUFEcvdNDt3YGqlwkFHbD4/5bgFUDgFmxhI/cSRAeJ87/Sz//Cq8Eam6JX/e23RkoFO71P7aAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@one-ini/wasm": "0.2.1", + "commander": "^14.0.3", + "minimatch": "~10.2.4", + "semver": "^7.7.4" + }, + "bin": { + "editorconfig": "bin/editorconfig" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.431", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.431.tgz", + "integrity": "sha512-AAVihz2YwJeOdAynX8MUtqpvjY0gaiARp/7+r4kwqgzqXMrfG1qDN7vwT80uxAoOQRPcZH8t73EI+CA/1OJN8A==", + "dev": true, + "license": "ISC" + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "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/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fastq": { + "version": "1.20.3", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.3.tgz", + "integrity": "sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "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/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/http-proxy-agent/node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "dev": true, + "license": "ISC" + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.17.0", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.17.0.tgz", + "integrity": "sha512-J/vG0zBCbIKOQFfufSwyXdMrsohyJIUNkrnmo6WZGzoM7tr/lsbfW5b2BvisL6zsyMzK9UxV9L6c7AoFbyXHOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/js-beautify": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/js-beautify/-/js-beautify-2.0.3.tgz", + "integrity": "sha512-cyFbh3tkPhknnTD/0bLf0T0yy2ZIbqL05mttzbt4y1Zfr7NxqXQZ62dkBLKs3oHH/lpjmDRAnciJiSUyOy8XwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "config-chain": "^1.1.13", + "editorconfig": "^3.0.2", + "glob": "^13.0.6", + "js-cookie": "^3.0.8", + "nopt": "^10.0.1" + }, + "bin": { + "css-beautify": "js/bin/css-beautify.js", + "html-beautify": "js/bin/html-beautify.js", + "js-beautify": "js/bin/js-beautify.js" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/js-cookie": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.8.tgz", + "integrity": "sha512-yeJd4aNAdYZQjaon2bpD/Gb0B/omw7HQOsynXXcOiWVCacbBcPlgn8S/d1X6blFSaHao7ozqtW7NZW19xpCtIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsdom": { + "version": "25.0.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-25.0.1.tgz", + "integrity": "sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssstyle": "^4.1.0", + "data-urls": "^5.0.0", + "decimal.js": "^10.4.3", + "form-data": "^4.0.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.5", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.12", + "parse5": "^7.1.2", + "rrweb-cssom": "^0.7.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.0.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^2.11.2" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/jsdom/node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "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==", + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", + "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.56", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.56.tgz", + "integrity": "sha512-x0InOIyzgdk+eyaWaRJFH5snEtiImgBgblZ2CyPrLmqqcuMQkEvcDPHbzqbD8eDsSeJbVOjn+crzyzHaM4D+/A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/nopt": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-10.0.1.tgz", + "integrity": "sha512-df3sBr/6ax9hSGuC3CspvLlbnX8cP5L5nZwXF8cGN8l0zSWR6BvzmQ6jPUKjvo6+/xdpkNvEcucBNUdBeeV13g==", + "dev": true, + "license": "ISC", + "dependencies": { + "abbrev": "^5.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/nwsapi": { + "version": "2.2.27", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.27.tgz", + "integrity": "sha512-gQPNF78qebCQ6tvVFBYrvJdBNOrYZm90ZlXgpIFm06p6qHDHq/XC4TnJftN6OMbxVE0UTBAoRgcsDeJBBooITw==", + "dev": true, + "license": "MIT" + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "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.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.4.tgz", + "integrity": "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/proto-list": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz", + "integrity": "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==", + "dev": true, + "license": "ISC" + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/read-cache": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.2.tgz", + "integrity": "sha512-/peqiBB/n07gQGLsWaHho3WfvUyRscw0gYTsEFMhrIe/nWLkYaf5SbKYjGYqtRV3aPwykJgF2VEMo1ac4bnsGA==", + "dev": true, + "license": "MIT" + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.3.tgz", + "integrity": "sha512-1i2XreiAoMMXuPGD6Msj2xWrMMkHojNRKivInxGQcg7/1KuPuYlfUutLyh4drnOxUTHX9cHI4wFoat8D/NKaBw==", + "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": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.63.3", + "@rollup/rollup-android-arm64": "4.63.3", + "@rollup/rollup-darwin-arm64": "4.63.3", + "@rollup/rollup-darwin-x64": "4.63.3", + "@rollup/rollup-freebsd-arm64": "4.63.3", + "@rollup/rollup-freebsd-x64": "4.63.3", + "@rollup/rollup-linux-arm-gnueabihf": "4.63.3", + "@rollup/rollup-linux-arm-musleabihf": "4.63.3", + "@rollup/rollup-linux-arm64-gnu": "4.63.3", + "@rollup/rollup-linux-arm64-musl": "4.63.3", + "@rollup/rollup-linux-loong64-gnu": "4.63.3", + "@rollup/rollup-linux-loong64-musl": "4.63.3", + "@rollup/rollup-linux-ppc64-gnu": "4.63.3", + "@rollup/rollup-linux-ppc64-musl": "4.63.3", + "@rollup/rollup-linux-riscv64-gnu": "4.63.3", + "@rollup/rollup-linux-riscv64-musl": "4.63.3", + "@rollup/rollup-linux-s390x-gnu": "4.63.3", + "@rollup/rollup-linux-x64-gnu": "4.63.3", + "@rollup/rollup-linux-x64-musl": "4.63.3", + "@rollup/rollup-openbsd-x64": "4.63.3", + "@rollup/rollup-openharmony-arm64": "4.63.3", + "@rollup/rollup-win32-arm64-msvc": "4.63.3", + "@rollup/rollup-win32-ia32-msvc": "4.63.3", + "@rollup/rollup-win32-x64-gnu": "4.63.3", + "@rollup/rollup-win32-x64-msvc": "4.63.3", + "fsevents": "~2.3.2" + } + }, + "node_modules/rrweb-cssom": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.7.1.tgz", + "integrity": "sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==", + "dev": true, + "license": "MIT" + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "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==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/sucrase/node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tailwindcss": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", + "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "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/tinyglobby/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/tinyglobby/node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", + "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/tough-cookie": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/update-browserslist-db": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.3.tgz", + "integrity": "sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ==", + "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/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "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/vite-node": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", + "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.7", + "es-module-lexer": "^1.5.4", + "pathe": "^1.1.2", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vite-node/node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/vite-node/node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vite/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/vite/node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/vitest": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", + "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "2.1.9", + "@vitest/mocker": "2.1.9", + "@vitest/pretty-format": "^2.1.9", + "@vitest/runner": "2.1.9", + "@vitest/snapshot": "2.1.9", + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "debug": "^4.3.7", + "expect-type": "^1.1.0", + "magic-string": "^0.30.12", + "pathe": "^1.1.2", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.1", + "tinypool": "^1.0.1", + "tinyrainbow": "^1.2.0", + "vite": "^5.0.0", + "vite-node": "2.1.9", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "2.1.9", + "@vitest/ui": "2.1.9", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@vitest/mocker": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", + "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.12" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/vitest/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/vitest/node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vue": { + "version": "3.5.43", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.43.tgz", + "integrity": "sha512-o5qZoksdnjIKvW1srZ3ab7pcDNYAerBjRe54D0LBLfRdCYFrSgBHVXokMas35czQc0//lmx4/tuY4ZNQ+Rf2Ng==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.43", + "@vue/compiler-sfc": "3.5.43", + "@vue/runtime-dom": "3.5.43", + "@vue/server-renderer": "3.5.43", + "@vue/shared": "3.5.43" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-component-type-helpers": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/vue-component-type-helpers/-/vue-component-type-helpers-3.3.11.tgz", + "integrity": "sha512-LwcxzeliO9fkQcpJG0PoX8X5kmAhKmH9wkpDLxNabwzkQ9Zeib2YVHwFV4pcWmMLfXVfjr/dSV+DaJ3cIPgSNA==", + "dev": true, + "license": "MIT" + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ws": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..2039918 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,26 @@ +{ + "name": "crypto-trading-frontend", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview", + "test": "vitest run" + }, + "dependencies": { + "axios": "^1.7.9", + "vue": "^3.5.13" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.2.1", + "@vue/test-utils": "^2.4.6", + "autoprefixer": "^10.4.20", + "jsdom": "^25.0.1", + "postcss": "^8.4.49", + "tailwindcss": "^3.4.17", + "vite": "^6.0.7", + "vitest": "^2.1.9" + } +} diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..2e7af2b --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/frontend/src/App.vue b/frontend/src/App.vue new file mode 100644 index 0000000..0872a75 --- /dev/null +++ b/frontend/src/App.vue @@ -0,0 +1,164 @@ + + + diff --git a/frontend/src/api.js b/frontend/src/api.js new file mode 100644 index 0000000..d041b69 --- /dev/null +++ b/frontend/src/api.js @@ -0,0 +1,45 @@ +import axios from 'axios' + +const http = axios.create({ baseURL: '/api', timeout: 10000 }) + +export const getStatus = () => http.get('/status').then((r) => r.data) +export const getStrategies = () => http.get('/strategies').then((r) => r.data) +export const getPositions = () => http.get('/positions').then((r) => r.data) +export const getOrders = () => http.get('/orders').then((r) => r.data) +export const getEvents = () => http.get('/events').then((r) => r.data) +export const getEquity = () => http.get('/equity').then((r) => r.data) + +export const startBot = (payload) => http.post('/bot/start', payload).then((r) => r.data) +export const stopBot = () => http.post('/bot/stop').then((r) => r.data) +export const closePosition = (id, reason = 'manual') => + http.post(`/positions/${id}/close`, { reason }).then((r) => r.data) +export const placeOrder = (payload) => http.post('/orders', payload).then((r) => r.data) +export const runBacktest = (payload) => + http.post('/backtest', payload, { timeout: 60000 }).then((r) => r.data) + +export function openSocket({ onMessage, onOpen, onClose }) { + const proto = window.location.protocol === 'https:' ? 'wss' : 'ws' + let socket + let retryTimer + let closed = false + + const connect = () => { + socket = new WebSocket(`${proto}://${window.location.host}/api/ws`) + socket.onopen = () => onOpen?.() + socket.onmessage = (event) => onMessage?.(JSON.parse(event.data)) + socket.onclose = () => { + onClose?.() + if (!closed) retryTimer = setTimeout(connect, 2000) + } + socket.onerror = () => socket.close() + } + connect() + + return { + close() { + closed = true + clearTimeout(retryTimer) + socket?.close() + }, + } +} diff --git a/frontend/src/components/BacktestPanel.vue b/frontend/src/components/BacktestPanel.vue new file mode 100644 index 0000000..4e64e96 --- /dev/null +++ b/frontend/src/components/BacktestPanel.vue @@ -0,0 +1,140 @@ + + + diff --git a/frontend/src/components/EquityChart.vue b/frontend/src/components/EquityChart.vue new file mode 100644 index 0000000..f56eaab --- /dev/null +++ b/frontend/src/components/EquityChart.vue @@ -0,0 +1,47 @@ + + + diff --git a/frontend/src/components/EventLog.vue b/frontend/src/components/EventLog.vue new file mode 100644 index 0000000..e02a6c4 --- /dev/null +++ b/frontend/src/components/EventLog.vue @@ -0,0 +1,27 @@ + + + diff --git a/frontend/src/components/OrdersTable.vue b/frontend/src/components/OrdersTable.vue new file mode 100644 index 0000000..2dd41ea --- /dev/null +++ b/frontend/src/components/OrdersTable.vue @@ -0,0 +1,42 @@ + + + diff --git a/frontend/src/components/PositionsTable.vue b/frontend/src/components/PositionsTable.vue new file mode 100644 index 0000000..8d1869b --- /dev/null +++ b/frontend/src/components/PositionsTable.vue @@ -0,0 +1,77 @@ + + + diff --git a/frontend/src/components/StatusCards.vue b/frontend/src/components/StatusCards.vue new file mode 100644 index 0000000..5299f46 --- /dev/null +++ b/frontend/src/components/StatusCards.vue @@ -0,0 +1,32 @@ + + + diff --git a/frontend/src/components/StrategyForm.vue b/frontend/src/components/StrategyForm.vue new file mode 100644 index 0000000..bce7ebc --- /dev/null +++ b/frontend/src/components/StrategyForm.vue @@ -0,0 +1,122 @@ + + + diff --git a/frontend/src/components/StrategyPanel.vue b/frontend/src/components/StrategyPanel.vue new file mode 100644 index 0000000..78b42a8 --- /dev/null +++ b/frontend/src/components/StrategyPanel.vue @@ -0,0 +1,38 @@ + + + diff --git a/frontend/src/format.js b/frontend/src/format.js new file mode 100644 index 0000000..e2f9808 --- /dev/null +++ b/frontend/src/format.js @@ -0,0 +1,36 @@ +export function fmtMoney(value, digits = 2) { + if (value === null || value === undefined || Number.isNaN(value)) return '—' + return Number(value).toLocaleString('en-US', { + minimumFractionDigits: digits, + maximumFractionDigits: digits, + }) +} + +export function fmtPrice(value) { + if (value === null || value === undefined || Number.isNaN(value)) return '—' + const n = Number(value) + return n >= 100 ? fmtMoney(n, 2) : n.toFixed(6) +} + +export function fmtNum(value, digits = 6) { + if (value === null || value === undefined || Number.isNaN(value)) return '—' + return Number(value).toFixed(digits) +} + +export function fmtTime(iso) { + if (!iso) return '—' + return new Date(iso).toLocaleTimeString() +} + +export function pnlClass(value) { + if (value === null || value === undefined) return 'text-slate-300' + if (Number(value) > 0) return 'text-emerald-400' + if (Number(value) < 0) return 'text-rose-400' + return 'text-slate-300' +} + +export function signed(value, digits = 2) { + if (value === null || value === undefined || Number.isNaN(value)) return '—' + const n = Number(value) + return `${n >= 0 ? '+' : ''}${fmtMoney(n, digits)}` +} diff --git a/frontend/src/format.test.js b/frontend/src/format.test.js new file mode 100644 index 0000000..f44d107 --- /dev/null +++ b/frontend/src/format.test.js @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest' +import { fmtMoney, fmtPrice, pnlClass, signed } from './format' + +describe('format helpers', () => { + it('formats money with two decimals', () => { + expect(fmtMoney(10000)).toBe('10,000.00') + }) + + it('handles null', () => { + expect(fmtMoney(null)).toBe('—') + expect(fmtPrice(undefined)).toBe('—') + }) + + it('formats small prices with six decimals', () => { + expect(fmtPrice(0.5)).toBe('0.500000') + }) + + it('signs positive values', () => { + expect(signed(12.346)).toBe('+12.35') + expect(signed(-3)).toBe('-3.00') + }) + + it('classifies pnl colour', () => { + expect(pnlClass(5)).toBe('text-emerald-400') + expect(pnlClass(-5)).toBe('text-rose-400') + expect(pnlClass(0)).toBe('text-slate-300') + }) +}) diff --git a/frontend/src/main.js b/frontend/src/main.js new file mode 100644 index 0000000..fe5bae3 --- /dev/null +++ b/frontend/src/main.js @@ -0,0 +1,5 @@ +import { createApp } from 'vue' +import App from './App.vue' +import './style.css' + +createApp(App).mount('#app') diff --git a/frontend/src/style.css b/frontend/src/style.css new file mode 100644 index 0000000..b2e80be --- /dev/null +++ b/frontend/src/style.css @@ -0,0 +1,30 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer components { + .card { + @apply rounded-xl border border-edge bg-panel p-4 shadow; + } + .label { + @apply text-xs uppercase tracking-wide text-slate-400; + } + .value { + @apply text-2xl font-semibold tabular-nums; + } + .btn { + @apply rounded-lg px-3 py-1.5 text-sm font-medium transition disabled:cursor-not-allowed disabled:opacity-40; + } + .btn-primary { + @apply btn bg-emerald-500 text-slate-900 hover:bg-emerald-400; + } + .btn-danger { + @apply btn bg-rose-500 text-white hover:bg-rose-400; + } + .btn-muted { + @apply btn border border-edge text-slate-200 hover:bg-slate-700; + } + .input { + @apply w-full rounded-lg border border-edge bg-surface px-2 py-1.5 text-sm text-slate-100 outline-none focus:border-emerald-400; + } +} diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js new file mode 100644 index 0000000..e7e3600 --- /dev/null +++ b/frontend/tailwind.config.js @@ -0,0 +1,14 @@ +/** @type {import('tailwindcss').Config} */ +export default { + content: ['./index.html', './src/**/*.{vue,js}'], + theme: { + extend: { + colors: { + surface: '#0f172a', + panel: '#1e293b', + edge: '#334155', + }, + }, + }, + plugins: [], +} diff --git a/frontend/vite.config.js b/frontend/vite.config.js new file mode 100644 index 0000000..c7bc4a4 --- /dev/null +++ b/frontend/vite.config.js @@ -0,0 +1,20 @@ +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' + +export default defineConfig({ + plugins: [vue()], + server: { + port: 5173, + proxy: { + '/api': { + target: 'http://localhost:8000', + changeOrigin: true, + ws: true, + }, + }, + }, + test: { + environment: 'jsdom', + globals: true, + }, +})