adopt: état déployé sur M3U96b adopté comme source de vérité (remote-first, 2026-10-21)
114 changed files +10,766 −0
added
.env.example
+33 −0
@@ -0,0 +1,33 @@ | ||
| 1 | +# ---- ktrade (apps/api) — all variables are prefixed KTRADE_ ---- | |
| 2 | +KTRADE_ENV=dev | |
| 3 | +KTRADE_APP_BASE_URL=http://localhost:8471 # used for same-origin (CSRF) checks and cookies | |
| 4 | +KTRADE_API_HOST=127.0.0.1 | |
| 5 | +KTRADE_API_PORT=8470 | |
| 6 | +KTRADE_DATABASE_URL=postgresql://ktrade:ktrade@127.0.0.1:5432/ktrade | |
| 7 | +KTRADE_REDIS_URL=redis://127.0.0.1:6379/3 | |
| 8 | +KTRADE_SECRET_KEY=change-me-64-hex # signs session cookies: openssl rand -hex 32 | |
| 9 | +KTRADE_OWNER_PASSWORD_HASH= # ktrade set-owner-password → bcrypt hash (never the password) | |
| 10 | +KTRADE_COOKIE_SECURE=false # true behind HTTPS | |
| 11 | + | |
| 12 | +# Kraken — least-privilege keys. DATA key: "Query funds" + "Query open orders & trades" ONLY (no order creation, | |
| 13 | +# never withdrawal). TRADE key: only configured when live mode is being prepared (Phase 5); unused otherwise. | |
| 14 | +KTRADE_KRAKEN_API_KEY= | |
| 15 | +KTRADE_KRAKEN_API_SECRET= | |
| 16 | +KTRADE_KRAKEN_TRADE_API_KEY= | |
| 17 | +KTRADE_KRAKEN_TRADE_API_SECRET= | |
| 18 | +KTRADE_KRAKEN_TIER=starter # starter|intermediate|pro — drives the local rate limiter | |
| 19 | + | |
| 20 | +KTRADE_UNIVERSE=BTC/USD,ETH/USD,SOL/USD,XRP/USD,ADA/USD,LINK/USD,DOT/USD,LTC/USD | |
| 21 | +KTRADE_CANDLE_INTERVALS=1,5,60,1440 # minutes; REST OHLC returns ≤720 bars per poll, history accumulates | |
| 22 | +KTRADE_BACKFILL_POLL_SECONDS=60 | |
| 23 | +KTRADE_BOOK_DEPTH=25 | |
| 24 | +KTRADE_STALE_DATA_SECONDS=120 | |
| 25 | + | |
| 26 | +KTRADE_TRADING_MODE=offline # offline | paper | live (live also needs the flag below + UI activation) | |
| 27 | +KTRADE_LIVE_TRADING_ENABLED=false | |
| 28 | +KTRADE_LOG_LEVEL=INFO | |
| 29 | +KTRADE_LOG_JSON=true | |
| 30 | + | |
| 31 | +# ---- web (apps/web) ---- | |
| 32 | +API_UPSTREAM=http://127.0.0.1:8470 | |
| 33 | +PORT=8471 | |
added
.gitignore
+15 −0
@@ -0,0 +1,15 @@ | ||
| 1 | +.env | |
| 2 | +.env.* | |
| 3 | +!.env.example | |
| 4 | +.venv/ | |
| 5 | +__pycache__/ | |
| 6 | +*.pyc | |
| 7 | +.pytest_cache/ | |
| 8 | +.mypy_cache/ | |
| 9 | +.ruff_cache/ | |
| 10 | +node_modules/ | |
| 11 | +.next/ | |
| 12 | +out/ | |
| 13 | +*.log | |
| 14 | +.DS_Store | |
| 15 | +screenshots/*.png | |
added
CLAUDE.md
+13 −0
@@ -0,0 +1,13 @@ | ||
| 1 | +# CLAUDE.md — Kraken Quantitative Algorithmic Trading Platform | |
| 2 | + | |
| 3 | +See `README.md` for the current state and `docs/` for architecture, threat model, runbook and Kraken API notes. | |
| 4 | +The original product specification (mission, non-negotiable rules, deliverables, phases) is kept verbatim in | |
| 5 | +`docs/SPEC.md`. Operating rules that apply to every change: | |
| 6 | + | |
| 7 | +- Start in offline/backtest and paper modes. Never place live orders without owner configuration, preflight, | |
| 8 | + feature flag and explicit UI activation with second confirmation. | |
| 9 | +- Never print, log, commit or store API secrets client-side. Separate least-privilege keys; no withdrawal permission. | |
| 10 | +- Every order passes `ktrade.risk.gateway.RiskGateway` (default-deny). No bypass, ever. | |
| 11 | +- No profitability claims without out-of-sample evidence, realistic costs and reproducible provenance. Never fabricate data. | |
| 12 | +- Exchange capabilities (pairs, minimums, precision, status, rate limits) are discovered at runtime from Kraken docs/endpoints. | |
| 13 | +- Remote-first deployment on MacLustr: the deployed directory on the node is the git source of truth (`origin` = spbgit). | |
added
Makefile
+10 −0
@@ -0,0 +1,10 @@ | ||
| 1 | +.PHONY: setup api worker web test lint typecheck migrate backfill | |
| 2 | +setup: ; cd apps/api && uv venv --python 3.12 .venv && uv pip install -e ".[dev]" && cd ../web && pnpm install | |
| 3 | +migrate: ; cd apps/api && .venv/bin/ktrade migrate | |
| 4 | +backfill: ; cd apps/api && .venv/bin/ktrade backfill | |
| 5 | +api: ; cd apps/api && .venv/bin/ktrade api | |
| 6 | +worker: ; cd apps/api && .venv/bin/ktrade worker | |
| 7 | +web: ; cd apps/web && pnpm dev -p 8471 | |
| 8 | +test: ; cd apps/api && .venv/bin/python -m pytest -q | |
| 9 | +lint: ; cd apps/api && .venv/bin/ruff check ktrade tests && cd ../web && pnpm lint | |
| 10 | +typecheck: ; cd apps/api && .venv/bin/mypy ktrade && cd ../web && pnpm tsc --noEmit | |
added
README.md
+64 −0
@@ -0,0 +1,64 @@ | ||
| 1 | +# crypto-trade-bot — Kraken quantitative trading platform | |
| 2 | + | |
| 3 | +Self-hosted research, backtesting, paper-trading and (gated) execution platform for Kraken Spot. | |
| 4 | +**Mode is `offline` by default. No live order can be sent unless a trade key is configured, preflight passes, the | |
| 5 | +feature flag is on and the owner activates live mode in the UI with a second confirmation (Phase 5, not shipped).** | |
| 6 | + | |
| 7 | +``` | |
| 8 | +apps/api Python 3.12 · FastAPI · asyncpg · Redis · numpy/pandas/scipy/statsmodels (package `ktrade`) | |
| 9 | +apps/web Next.js · TypeScript · Tailwind v4 · TanStack Query · lightweight-charts | |
| 10 | +migrations plain SQL, applied by `ktrade migrate` (Timescale hypertables if the extension exists) | |
| 11 | +docs ARCHITECTURE · THREAT-MODEL · RUNBOOK · KRAKEN-API-NOTES · RESEARCH-REPORTS | |
| 12 | +deploy Prometheus / Grafana provisioning | |
| 13 | +``` | |
| 14 | + | |
| 15 | +## Quick start (local, Apple Silicon) | |
| 16 | + | |
| 17 | +```bash | |
| 18 | +cp .env.example .env # then set KTRADE_SECRET_KEY and KTRADE_OWNER_PASSWORD_HASH | |
| 19 | +docker compose up -d db redis # or use Homebrew postgresql@17 + redis | |
| 20 | +make setup # uv venv + pip install, pnpm install | |
| 21 | +make migrate && make backfill # schema + first OHLC pull (≤720 bars per pair/interval) | |
| 22 | +make worker & # pair discovery, OHLC accumulation, WebSocket v2 stream | |
| 23 | +make api & # http://127.0.0.1:8470/api/docs | |
| 24 | +make web # http://localhost:8471 (login with the owner password) | |
| 25 | +make test lint typecheck | |
| 26 | +``` | |
| 27 | + | |
| 28 | +Owner password: `cd apps/api && .venv/bin/ktrade set-owner-password` prints a bcrypt hash → `KTRADE_OWNER_PASSWORD_HASH`. | |
| 29 | + | |
| 30 | +## What works today (Phase 1 + Phase 2) | |
| 31 | + | |
| 32 | +- Kraken REST client with the documented signature (test vector in `tests/test_kraken_signing.py`), monotonic | |
| 33 | + nonce, tiered call counter, retries; WebSocket v2 client (ticker/book/trade/ohlc) with CRC32 **book checksum | |
| 34 | + verification** and automatic resubscription on mismatch; reconnect with backoff; heartbeat watchdog. | |
| 35 | +- Pair metadata discovery (ordermin/costmin/tick/lot decimals/status/leverage) — capabilities are read from the | |
| 36 | + exchange, never hard-coded. `XBT`→`BTC` naming normalised to WS v2 symbols. | |
| 37 | +- Postgres schema: candles (open-time keyed, `closed` flag), trades, latest ticker/book, 1-s microstructure | |
| 38 | + samples, data gaps, backtest runs, hash-chained **immutable audit log**, risk state, paper ledger tables. | |
| 39 | +- Event-driven backtester: decisions at bar close, execution at next open (+ optional latency), maker/taker | |
| 40 | + fees, spread, slippage, square-root impact, participation-capped **partial fills**, lot rounding, exchange | |
| 41 | + minimums, independent **risk gateway** on every order. Proven no-lookahead by tests. | |
| 42 | +- Strategy SDK + plugins: time-series momentum (vol-targeted), MA ensemble, z-score mean reversion with OU | |
| 43 | + half-life + regime filter + cost-aware entries; benchmarks buy&hold / cash / simple momentum / equal-risk. | |
| 44 | +- Validation: holdout, anchored/rolling walk-forward with embargo, parameter sensitivity maps, block-bootstrap | |
| 45 | + CIs, deflated Sharpe, stress scenarios (fees ×2, spread ×3, slippage, latency, liquidity collapse, outage | |
| 46 | + gap), shuffled-trade Monte Carlo. Full provenance (commit, dataset hash, params, seed, window, costs). | |
| 47 | +- Web terminal: overview, market explorer (live candles/book/tape), strategy lab, backtest explorer + compare, | |
| 48 | + risk center (limits, kill switch, what-if evaluator, audit), reports, settings (credential status, preflight). | |
| 49 | +- Observability: structured JSON logs with correlation ids, Prometheus metrics at `/api/metrics`, health endpoint. | |
| 50 | + | |
| 51 | +## Known limitations (honest list) | |
| 52 | + | |
| 53 | +- Kraken REST OHLC returns at most 720 bars per pair/interval; history **accumulates from first deployment** | |
| 54 | + (1-min: 12 h, 1-h: 30 days, 1-d: ~2 years at first pull). Long backtests on fine intervals need a licensed | |
| 55 | + external dataset (not included). Results on short windows are statistically weak — the UI says so. | |
| 56 | +- Phases 3–5 are not implemented: cointegration/Kalman, GARCH/HMM regimes, portfolio optimisation, | |
| 57 | + market-making simulation, paper execution loop, live adapter. Pages exist as clearly-labelled placeholders. | |
| 58 | +- WS v2 allows one OHLC interval per symbol per connection → only 1-min bars are streamed; other intervals come | |
| 59 | + from REST polling (≤ 60 s lag). | |
| 60 | +- Kraken has no Spot sandbox; the only dry-run is `AddOrder validate=true` (used by preflight). | |
| 61 | +- No shorting / margin: strategies are long-only spot. | |
| 62 | +- Timescale is optional; without the extension plain B-tree indexes are used (fine at this scale). | |
| 63 | + | |
| 64 | +See `docs/RUNBOOK.md` for operations and `docs/THREAT-MODEL.md` for security assumptions. | |
added
apps/api/Dockerfile
+9 −0
@@ -0,0 +1,9 @@ | ||
| 1 | +FROM python:3.12-slim | |
| 2 | +WORKDIR /app | |
| 3 | +RUN pip install --no-cache-dir uv | |
| 4 | +COPY pyproject.toml ./ | |
| 5 | +COPY ktrade ./ktrade | |
| 6 | +COPY migrations ./migrations | |
| 7 | +RUN uv pip install --system --no-cache . | |
| 8 | +EXPOSE 8470 | |
| 9 | +CMD ["ktrade", "api"] | |
added
apps/api/ktrade/__init__.py
+3 −0
@@ -0,0 +1,3 @@ | ||
| 1 | +"""ktrade — Kraken quantitative trading platform backend.""" | |
| 2 | + | |
| 3 | +__version__ = "0.1.0" | |
added
apps/api/ktrade/api/__init__.py
+0 −0
added
apps/api/ktrade/api/auth.py
+68 −0
@@ -0,0 +1,68 @@ | ||
| 1 | +"""Owner session auth: bcrypt password, signed HttpOnly cookie, same-origin check on mutations, login rate limit.""" | |
| 2 | + | |
| 3 | +from __future__ import annotations | |
| 4 | + | |
| 5 | +import time | |
| 6 | +from collections import defaultdict, deque | |
| 7 | +from urllib.parse import urlparse | |
| 8 | + | |
| 9 | +import bcrypt | |
| 10 | +from fastapi import HTTPException, Request, Response | |
| 11 | +from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer | |
| 12 | + | |
| 13 | +from ktrade.config import Settings | |
| 14 | + | |
| 15 | +COOKIE = "ktrade_session" | |
| 16 | +_attempts: dict[str, deque[float]] = defaultdict(deque) | |
| 17 | + | |
| 18 | + | |
| 19 | +def hash_password(pw: str) -> str: | |
| 20 | + return bcrypt.hashpw(pw.encode(), bcrypt.gensalt(12)).decode() | |
| 21 | + | |
| 22 | + | |
| 23 | +def verify_password(pw: str, hashed: str) -> bool: | |
| 24 | + try: | |
| 25 | + return bcrypt.checkpw(pw.encode(), hashed.encode()) | |
| 26 | + except ValueError: | |
| 27 | + return False | |
| 28 | + | |
| 29 | + | |
| 30 | +def _serializer(settings: Settings) -> URLSafeTimedSerializer: | |
| 31 | + return URLSafeTimedSerializer(settings.secret_key, salt="ktrade-session") | |
| 32 | + | |
| 33 | + | |
| 34 | +def issue_session(response: Response, settings: Settings, role: str = "owner") -> None: | |
| 35 | + token = _serializer(settings).dumps({"role": role, "iat": int(time.time())}) | |
| 36 | + response.set_cookie(COOKIE, token, max_age=settings.session_ttl_seconds, httponly=True, secure=settings.cookie_secure, samesite="lax", path="/") | |
| 37 | + | |
| 38 | + | |
| 39 | +def clear_session(response: Response, settings: Settings) -> None: | |
| 40 | + response.delete_cookie(COOKIE, path="/") | |
| 41 | + | |
| 42 | + | |
| 43 | +def read_session(request: Request, settings: Settings) -> dict | None: | |
| 44 | + tok = request.cookies.get(COOKIE) | |
| 45 | + if not tok: | |
| 46 | + return None | |
| 47 | + try: | |
| 48 | + return _serializer(settings).loads(tok, max_age=settings.session_ttl_seconds) | |
| 49 | + except (BadSignature, SignatureExpired): | |
| 50 | + return None | |
| 51 | + | |
| 52 | + | |
| 53 | +def rate_limit_login(ip: str, limit: int = 8, window: float = 60.0) -> None: | |
| 54 | + q = _attempts[ip] | |
| 55 | + now = time.time() | |
| 56 | + while q and now - q[0] > window: | |
| 57 | + q.popleft() | |
| 58 | + if len(q) >= limit: | |
| 59 | + raise HTTPException(429, "too many login attempts; retry later") | |
| 60 | + q.append(now) | |
| 61 | + | |
| 62 | + | |
| 63 | +def same_origin(request: Request, settings: Settings) -> bool: | |
| 64 | + origin = request.headers.get("origin") or request.headers.get("referer") | |
| 65 | + if not origin: | |
| 66 | + return False | |
| 67 | + allowed = {urlparse(settings.app_base_url).netloc, request.headers.get("host", "")} | |
| 68 | + return urlparse(origin).netloc in allowed | |
added
apps/api/ktrade/api/deps.py
+36 −0
@@ -0,0 +1,36 @@ | ||
| 1 | +from __future__ import annotations | |
| 2 | + | |
| 3 | +from dataclasses import dataclass, field | |
| 4 | +from typing import Any | |
| 5 | + | |
| 6 | +import asyncpg | |
| 7 | +import redis.asyncio as aioredis | |
| 8 | +from fastapi import HTTPException, Request | |
| 9 | + | |
| 10 | +from ktrade.api.auth import read_session, same_origin | |
| 11 | +from ktrade.config import Settings | |
| 12 | +from ktrade.kraken.rest import KrakenREST | |
| 13 | + | |
| 14 | + | |
| 15 | +@dataclass | |
| 16 | +class AppState: | |
| 17 | + settings: Settings | |
| 18 | + pool: asyncpg.Pool | |
| 19 | + redis: aioredis.Redis | |
| 20 | + rest: KrakenREST | |
| 21 | + tasks: dict[str, Any] = field(default_factory=dict) | |
| 22 | + preflight_cache: dict[str, Any] = field(default_factory=dict) | |
| 23 | + | |
| 24 | + | |
| 25 | +def get_state(request: Request) -> AppState: | |
| 26 | + return request.app.state.ktrade | |
| 27 | + | |
| 28 | + | |
| 29 | +def require_owner(request: Request) -> dict: | |
| 30 | + st: AppState = request.app.state.ktrade | |
| 31 | + sess = read_session(request, st.settings) | |
| 32 | + if not sess: | |
| 33 | + raise HTTPException(401, "authentication required") | |
| 34 | + if request.method in ("POST", "PUT", "PATCH", "DELETE") and not same_origin(request, st.settings): | |
| 35 | + raise HTTPException(403, "cross-origin request rejected") | |
| 36 | + return sess | |
added
apps/api/ktrade/api/main.py
+77 −0
@@ -0,0 +1,77 @@ | ||
| 1 | +"""FastAPI application factory.""" | |
| 2 | + | |
| 3 | +from __future__ import annotations | |
| 4 | + | |
| 5 | +import time | |
| 6 | +from contextlib import asynccontextmanager | |
| 7 | + | |
| 8 | +import redis.asyncio as aioredis | |
| 9 | +from fastapi import FastAPI, Request | |
| 10 | +from fastapi.responses import JSONResponse, PlainTextResponse | |
| 11 | +from prometheus_client import CONTENT_TYPE_LATEST, Counter, Histogram, generate_latest | |
| 12 | + | |
| 13 | +from ktrade import __version__ | |
| 14 | +from ktrade.api.deps import AppState | |
| 15 | +from ktrade.api.routes import market, research, risk, system | |
| 16 | +from ktrade.config import get_settings | |
| 17 | +from ktrade.db.pool import create_pool | |
| 18 | +from ktrade.kraken.rest import KrakenREST | |
| 19 | +from ktrade.logging import configure_logging, get_logger, new_correlation_id | |
| 20 | + | |
| 21 | +log = get_logger("api") | |
| 22 | +REQS = Counter("ktrade_http_requests_total", "HTTP requests", ["method", "path", "status"]) | |
| 23 | +LAT = Histogram("ktrade_http_request_seconds", "HTTP latency", ["path"]) | |
| 24 | + | |
| 25 | + | |
| 26 | +@asynccontextmanager | |
| 27 | +async def lifespan(app: FastAPI): | |
| 28 | + s = get_settings() | |
| 29 | + configure_logging(s.log_level, s.log_json) | |
| 30 | + pool = await create_pool(s.database_url) | |
| 31 | + r = aioredis.from_url(s.redis_url, decode_responses=True) | |
| 32 | + rest = KrakenREST(s.kraken_rest_url, s.kraken_api_key, s.kraken_api_secret, s.kraken_tier) | |
| 33 | + app.state.ktrade = AppState(settings=s, pool=pool, redis=r, rest=rest) | |
| 34 | + log.info("api.started", version=__version__, mode=s.trading_mode, universe=s.universe) | |
| 35 | + try: | |
| 36 | + yield | |
| 37 | + finally: | |
| 38 | + await rest.aclose() | |
| 39 | + await r.aclose() | |
| 40 | + await pool.close() | |
| 41 | + | |
| 42 | + | |
| 43 | +def create_app() -> FastAPI: | |
| 44 | + app = FastAPI(title="ktrade API", version=__version__, lifespan=lifespan, docs_url="/api/docs", openapi_url="/api/openapi.json", redoc_url=None) | |
| 45 | + | |
| 46 | + @app.middleware("http") | |
| 47 | + async def _obs(request: Request, call_next): | |
| 48 | + cid = new_correlation_id() | |
| 49 | + t0 = time.perf_counter() | |
| 50 | + try: | |
| 51 | + resp = await call_next(request) | |
| 52 | + except Exception as e: # noqa: BLE001 | |
| 53 | + log.exception("api.unhandled", path=request.url.path, error=str(e)) | |
| 54 | + resp = JSONResponse({"detail": "internal error", "cid": cid}, status_code=500) | |
| 55 | + route = request.scope.get("route") | |
| 56 | + path = getattr(route, "path", None) or request.url.path | |
| 57 | + REQS.labels(request.method, path, str(resp.status_code)).inc() | |
| 58 | + LAT.labels(path).observe(time.perf_counter() - t0) | |
| 59 | + resp.headers["X-Correlation-Id"] = cid | |
| 60 | + resp.headers["X-Content-Type-Options"] = "nosniff" | |
| 61 | + resp.headers["Referrer-Policy"] = "same-origin" | |
| 62 | + return resp | |
| 63 | + | |
| 64 | + @app.get("/api/metrics", response_class=PlainTextResponse, include_in_schema=False) | |
| 65 | + async def metrics(request: Request): | |
| 66 | + if request.client and request.client.host not in ("127.0.0.1", "::1"): | |
| 67 | + return PlainTextResponse("forbidden", status_code=403) | |
| 68 | + return PlainTextResponse(generate_latest(), media_type=CONTENT_TYPE_LATEST) | |
| 69 | + | |
| 70 | + app.include_router(system.router, prefix="/api") | |
| 71 | + app.include_router(market.router, prefix="/api") | |
| 72 | + app.include_router(research.router, prefix="/api") | |
| 73 | + app.include_router(risk.router, prefix="/api") | |
| 74 | + return app | |
| 75 | + | |
| 76 | + | |
| 77 | +app = create_app() | |
added
apps/api/ktrade/api/routes/__init__.py
+0 −0
added
apps/api/ktrade/api/routes/market.py
+93 −0
@@ -0,0 +1,93 @@ | ||
| 1 | +from __future__ import annotations | |
| 2 | + | |
| 3 | +import asyncio | |
| 4 | +import json | |
| 5 | + | |
| 6 | +from fastapi import APIRouter, Depends, HTTPException, Request | |
| 7 | +from fastapi.responses import StreamingResponse | |
| 8 | + | |
| 9 | +from ktrade.api.deps import AppState, get_state, require_owner | |
| 10 | +from ktrade.ingestion.stream import CHANNEL | |
| 11 | +from ktrade.market import service as svc | |
| 12 | + | |
| 13 | +router = APIRouter(dependencies=[Depends(require_owner)]) | |
| 14 | + | |
| 15 | + | |
| 16 | +@router.get("/pairs") | |
| 17 | +async def pairs(q: str | None = None, all: bool = False, st: AppState = Depends(get_state)): | |
| 18 | + return {"items": await svc.list_pairs(st.pool, universe_only=not all, q=q)} | |
| 19 | + | |
| 20 | + | |
| 21 | +@router.get("/market/overview") | |
| 22 | +async def overview(st: AppState = Depends(get_state)): | |
| 23 | + return await svc.overview(st.pool, st.settings.universe, st.settings.stale_data_seconds) | |
| 24 | + | |
| 25 | + | |
| 26 | +@router.get("/market/candles") | |
| 27 | +async def candles(symbol: str, interval: int = 60, limit: int = 1000, start: int | None = None, end: int | None = None, st: AppState = Depends(get_state)): | |
| 28 | + rows = await svc.get_candles(st.pool, symbol, interval, limit, start, end) | |
| 29 | + return {"symbol": symbol, "interval_min": interval, "items": rows} | |
| 30 | + | |
| 31 | + | |
| 32 | +@router.get("/market/ticker") | |
| 33 | +async def ticker(symbol: str | None = None, st: AppState = Depends(get_state)): | |
| 34 | + return {"items": await svc.ticker(st.pool, symbol)} | |
| 35 | + | |
| 36 | + | |
| 37 | +@router.get("/market/book") | |
| 38 | +async def book(symbol: str, st: AppState = Depends(get_state)): | |
| 39 | + b = await svc.book(st.pool, symbol) | |
| 40 | + if not b: | |
| 41 | + raise HTTPException(404, "no book yet for symbol") | |
| 42 | + return b | |
| 43 | + | |
| 44 | + | |
| 45 | +@router.get("/market/trades") | |
| 46 | +async def trades(symbol: str, limit: int = 100, st: AppState = Depends(get_state)): | |
| 47 | + return {"items": await svc.recent_trades(st.pool, symbol, limit)} | |
| 48 | + | |
| 49 | + | |
| 50 | +@router.get("/market/microstructure") | |
| 51 | +async def microstructure(symbol: str, seconds: int = 3600, st: AppState = Depends(get_state)): | |
| 52 | + return {"items": await svc.book_samples(st.pool, symbol, min(seconds, 7 * 86400))} | |
| 53 | + | |
| 54 | + | |
| 55 | +@router.get("/market/quality") | |
| 56 | +async def quality(st: AppState = Depends(get_state)): | |
| 57 | + return {"items": await svc.data_quality(st.pool, st.settings.stale_data_seconds), "gaps": [dict(r) for r in await st.pool.fetch("SELECT symbol, interval_min, gap_start, gap_end, missing_bars, detected_at FROM data_gaps ORDER BY detected_at DESC LIMIT 100")]} | |
| 58 | + | |
| 59 | + | |
| 60 | +@router.get("/market/stream") | |
| 61 | +async def stream(request: Request, symbols: str = "", channels: str = "ticker,book,trade,ohlc", st: AppState = Depends(get_state)): | |
| 62 | + """Server-sent events relay of the Redis market-data channel (filtered by symbol/channel).""" | |
| 63 | + want_syms = {s for s in symbols.split(",") if s} | |
| 64 | + want_ch = {c for c in channels.split(",") if c} | |
| 65 | + | |
| 66 | + async def gen(): | |
| 67 | + pubsub = st.redis.pubsub() | |
| 68 | + await pubsub.subscribe(CHANNEL) | |
| 69 | + yield "retry: 2000\n\n" | |
| 70 | + try: | |
| 71 | + last_ping = asyncio.get_event_loop().time() | |
| 72 | + while True: | |
| 73 | + if await request.is_disconnected(): | |
| 74 | + break | |
| 75 | + msg = await pubsub.get_message(ignore_subscribe_messages=True, timeout=1.0) | |
| 76 | + now = asyncio.get_event_loop().time() | |
| 77 | + if msg is None: | |
| 78 | + if now - last_ping > 15: | |
| 79 | + yield ": ping\n\n" | |
| 80 | + last_ping = now | |
| 81 | + continue | |
| 82 | + ev = json.loads(msg["data"]) | |
| 83 | + if ev["channel"] not in want_ch: | |
| 84 | + continue | |
| 85 | + data = [d for d in ev["data"] if not want_syms or d.get("symbol") in want_syms] | |
| 86 | + if not data: | |
| 87 | + continue | |
| 88 | + yield f"event: {ev['channel']}\ndata: {json.dumps({'type': ev['type'], 'data': data, 'received_at': ev['received_at']})}\n\n" | |
| 89 | + finally: | |
| 90 | + await pubsub.unsubscribe(CHANNEL) | |
| 91 | + await pubsub.aclose() | |
| 92 | + | |
| 93 | + return StreamingResponse(gen(), media_type="text/event-stream", headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}) | |
added
apps/api/ktrade/api/routes/research.py
+117 −0
@@ -0,0 +1,117 @@ | ||
| 1 | +from __future__ import annotations | |
| 2 | + | |
| 3 | +import asyncio | |
| 4 | +import csv | |
| 5 | +import io | |
| 6 | +import uuid | |
| 7 | +from typing import Any | |
| 8 | + | |
| 9 | +from fastapi import APIRouter, Depends, HTTPException | |
| 10 | +from fastapi.responses import PlainTextResponse | |
| 11 | +from pydantic import BaseModel, Field | |
| 12 | + | |
| 13 | +from ktrade.api.deps import AppState, get_state, require_owner | |
| 14 | +from ktrade.backtest.runner import execute_backtest | |
| 15 | +from ktrade.db.audit import audit | |
| 16 | +from ktrade.logging import get_logger | |
| 17 | +from ktrade.strategy.registry import get_strategy, list_strategies | |
| 18 | + | |
| 19 | +log = get_logger("api.research") | |
| 20 | +router = APIRouter(dependencies=[Depends(require_owner)]) | |
| 21 | + | |
| 22 | + | |
| 23 | +@router.get("/strategies") | |
| 24 | +async def strategies(): | |
| 25 | + return {"items": list_strategies()} | |
| 26 | + | |
| 27 | + | |
| 28 | +class BacktestIn(BaseModel): | |
| 29 | + strategy: str | |
| 30 | + symbol: str | |
| 31 | + interval_min: int = 60 | |
| 32 | + params: dict[str, Any] = Field(default_factory=dict) | |
| 33 | + cost_model: dict[str, Any] = Field(default_factory=dict) | |
| 34 | + risk_limits: dict[str, Any] | None = None | |
| 35 | + initial_cash: float = 10_000.0 | |
| 36 | + start_ts: int | None = None | |
| 37 | + end_ts: int | None = None | |
| 38 | + holdout_frac: float = Field(0.0, ge=0, le=0.5) | |
| 39 | + walk_forward: dict[str, Any] | None = None | |
| 40 | + sensitivity: dict[str, Any] | None = None | |
| 41 | + n_trials: int = Field(1, ge=1, description="Number of parameter configurations tried before this one (for deflated Sharpe)") | |
| 42 | + stress: bool = True | |
| 43 | + label: str | None = None | |
| 44 | + | |
| 45 | + | |
| 46 | +@router.post("/backtests", status_code=202) | |
| 47 | +async def create_backtest(body: BacktestIn, st: AppState = Depends(get_state)): | |
| 48 | + try: | |
| 49 | + cls = get_strategy(body.strategy) | |
| 50 | + cls.validate_params(body.params) | |
| 51 | + except (KeyError, ValueError) as e: | |
| 52 | + raise HTTPException(422, str(e)) from e | |
| 53 | + if body.symbol not in st.settings.universe and not await st.pool.fetchval("SELECT 1 FROM pairs WHERE wsname=$1", body.symbol): | |
| 54 | + raise HTTPException(422, f"unknown symbol {body.symbol}") | |
| 55 | + run_id = uuid.uuid4() | |
| 56 | + spec = body.model_dump() | |
| 57 | + await st.pool.execute( | |
| 58 | + "INSERT INTO backtest_runs (id, strategy, symbol, interval_min, params, cost_model, label, status) VALUES ($1,$2,$3,$4,$5,$6,$7,'queued')", | |
| 59 | + run_id, body.strategy, body.symbol, body.interval_min, body.params, body.cost_model, body.label, | |
| 60 | + ) | |
| 61 | + await audit(st.pool, "owner", "backtest.create", str(run_id), {"strategy": body.strategy, "symbol": body.symbol, "interval_min": body.interval_min}) | |
| 62 | + | |
| 63 | + async def _run(): | |
| 64 | + try: | |
| 65 | + await asyncio.to_thread(lambda: None) | |
| 66 | + await execute_backtest(st.pool, run_id, spec) | |
| 67 | + except Exception as e: # noqa: BLE001 | |
| 68 | + log.warning("backtest.failed", id=str(run_id), error=str(e)) | |
| 69 | + finally: | |
| 70 | + st.tasks.pop(str(run_id), None) | |
| 71 | + | |
| 72 | + st.tasks[str(run_id)] = asyncio.create_task(_run()) | |
| 73 | + return {"id": str(run_id), "status": "queued"} | |
| 74 | + | |
| 75 | + | |
| 76 | +@router.get("/backtests") | |
| 77 | +async def list_backtests(limit: int = 100, st: AppState = Depends(get_state)): | |
| 78 | + rows = await st.pool.fetch( | |
| 79 | + """SELECT id, created_at, finished_at, status, strategy, symbol, interval_min, params, label, error, start_ts, end_ts, | |
| 80 | + metrics->>'sharpe' AS sharpe, metrics->>'total_return' AS total_return, metrics->>'max_drawdown' AS max_drawdown, metrics->>'n_trades' AS n_trades | |
| 81 | + FROM backtest_runs ORDER BY created_at DESC LIMIT $1""", min(limit, 500)) | |
| 82 | + return {"items": [{**dict(r), "id": str(r["id"])} for r in rows]} | |
| 83 | + | |
| 84 | + | |
| 85 | +@router.get("/backtests/{run_id}") | |
| 86 | +async def get_backtest(run_id: uuid.UUID, st: AppState = Depends(get_state)): | |
| 87 | + r = await st.pool.fetchrow("SELECT * FROM backtest_runs WHERE id=$1", run_id) | |
| 88 | + if not r: | |
| 89 | + raise HTTPException(404) | |
| 90 | + return {**dict(r), "id": str(r["id"])} | |
| 91 | + | |
| 92 | + | |
| 93 | +@router.delete("/backtests/{run_id}") | |
| 94 | +async def delete_backtest(run_id: uuid.UUID, st: AppState = Depends(get_state)): | |
| 95 | + await st.pool.execute("DELETE FROM backtest_runs WHERE id=$1", run_id) | |
| 96 | + await audit(st.pool, "owner", "backtest.delete", str(run_id)) | |
| 97 | + return {"ok": True} | |
| 98 | + | |
| 99 | + | |
| 100 | +@router.get("/backtests/{run_id}/trades.csv", response_class=PlainTextResponse) | |
| 101 | +async def trades_csv(run_id: uuid.UUID, st: AppState = Depends(get_state)): | |
| 102 | + r = await st.pool.fetchrow("SELECT artifacts FROM backtest_runs WHERE id=$1", run_id) | |
| 103 | + if not r or not r["artifacts"]: | |
| 104 | + raise HTTPException(404) | |
| 105 | + buf = io.StringIO() | |
| 106 | + trades = r["artifacts"].get("trades", []) | |
| 107 | + if trades: | |
| 108 | + w = csv.DictWriter(buf, fieldnames=list(trades[0].keys())) | |
| 109 | + w.writeheader() | |
| 110 | + w.writerows(trades) | |
| 111 | + return PlainTextResponse(buf.getvalue(), media_type="text/csv") | |
| 112 | + | |
| 113 | + | |
| 114 | +@router.post("/backtests/compare") | |
| 115 | +async def compare(ids: list[str], st: AppState = Depends(get_state)): | |
| 116 | + rows = await st.pool.fetch("SELECT id, strategy, symbol, interval_min, params, metrics, artifacts->'equity' AS equity, label FROM backtest_runs WHERE id = ANY($1::uuid[]) AND status='done'", [uuid.UUID(i) for i in ids[:8]]) | |
| 117 | + return {"items": [{**dict(r), "id": str(r["id"])} for r in rows]} | |
added
apps/api/ktrade/api/routes/risk.py
+71 −0
@@ -0,0 +1,71 @@ | ||
| 1 | +from __future__ import annotations | |
| 2 | + | |
| 3 | +import time | |
| 4 | +from typing import Any | |
| 5 | + | |
| 6 | +from fastapi import APIRouter, Depends | |
| 7 | +from pydantic import BaseModel | |
| 8 | + | |
| 9 | +from ktrade.api.deps import AppState, get_state, require_owner | |
| 10 | +from ktrade.db.audit import audit | |
| 11 | +from ktrade.risk.gateway import MarketState, OrderIntent, PortfolioSnapshot, RiskGateway, RiskLimits | |
| 12 | + | |
| 13 | +router = APIRouter(dependencies=[Depends(require_owner)]) | |
| 14 | + | |
| 15 | + | |
| 16 | +async def load_limits(st: AppState) -> RiskLimits: | |
| 17 | + v = await st.pool.fetchval("SELECT value FROM risk_state WHERE key='limits'") | |
| 18 | + return RiskLimits(**(v or {})) | |
| 19 | + | |
| 20 | + | |
| 21 | +async def kill_switch(st: AppState) -> dict[str, Any]: | |
| 22 | + return await st.pool.fetchval("SELECT value FROM risk_state WHERE key='kill_switch'") or {"active": False} | |
| 23 | + | |
| 24 | + | |
| 25 | +@router.get("/risk/limits") | |
| 26 | +async def get_limits(st: AppState = Depends(get_state)): | |
| 27 | + return (await load_limits(st)).model_dump() | |
| 28 | + | |
| 29 | + | |
| 30 | +@router.put("/risk/limits") | |
| 31 | +async def put_limits(body: RiskLimits, st: AppState = Depends(get_state)): | |
| 32 | + await st.pool.execute("UPDATE risk_state SET value=$1, updated_at=now() WHERE key='limits'", body.model_dump()) | |
| 33 | + await audit(st.pool, "owner", "risk.limits_updated", details=body.model_dump()) | |
| 34 | + return body.model_dump() | |
| 35 | + | |
| 36 | + | |
| 37 | +class KillIn(BaseModel): | |
| 38 | + active: bool | |
| 39 | + reason: str | None = None | |
| 40 | + | |
| 41 | + | |
| 42 | +@router.post("/risk/kill-switch") | |
| 43 | +async def set_kill(body: KillIn, st: AppState = Depends(get_state)): | |
| 44 | + v = {"active": body.active, "reason": body.reason, "since": time.time() if body.active else None} | |
| 45 | + await st.pool.execute("UPDATE risk_state SET value=$1, updated_at=now() WHERE key='kill_switch'", v) | |
| 46 | + await st.redis.set("ktrade:kill_switch", "1" if body.active else "0") | |
| 47 | + await audit(st.pool, "owner", "risk.kill_switch", details=v) | |
| 48 | + # Phase 4/5: cancel eligible open paper/live orders here. During an exchange outage cancellations cannot be | |
| 49 | + # guaranteed — the switch still blocks all *new* orders locally. | |
| 50 | + return v | |
| 51 | + | |
| 52 | + | |
| 53 | +@router.get("/risk/status") | |
| 54 | +async def risk_status(st: AppState = Depends(get_state)): | |
| 55 | + limits = await load_limits(st) | |
| 56 | + ks = await kill_switch(st) | |
| 57 | + breaches = [dict(r) for r in await st.pool.fetch("SELECT ts, action, details FROM audit_log WHERE action LIKE 'risk.%' ORDER BY id DESC LIMIT 50")] | |
| 58 | + return {"limits": limits.model_dump(), "kill_switch": ks, "mode": st.settings.trading_mode, "recent_events": breaches} | |
| 59 | + | |
| 60 | + | |
| 61 | +class EvalIn(BaseModel): | |
| 62 | + intent: OrderIntent | |
| 63 | + portfolio: PortfolioSnapshot | |
| 64 | + market: MarketState | |
| 65 | + | |
| 66 | + | |
| 67 | +@router.post("/risk/evaluate") | |
| 68 | +async def evaluate(body: EvalIn, st: AppState = Depends(get_state)): | |
| 69 | + """Dry-run the gateway (what-if); never places anything.""" | |
| 70 | + gw = RiskGateway(await load_limits(st), kill_switch=bool((await kill_switch(st)).get("active"))) | |
| 71 | + return gw.evaluate(body.intent, body.portfolio, body.market).model_dump() | |
added
apps/api/ktrade/api/routes/system.py
+116 −0
@@ -0,0 +1,116 @@ | ||
| 1 | +from __future__ import annotations | |
| 2 | + | |
| 3 | +import json | |
| 4 | +import time | |
| 5 | + | |
| 6 | +from fastapi import APIRouter, Depends, HTTPException, Request, Response | |
| 7 | +from pydantic import BaseModel | |
| 8 | + | |
| 9 | +from ktrade import __version__ | |
| 10 | +from ktrade.api.auth import clear_session, issue_session, rate_limit_login, read_session, verify_password | |
| 11 | +from ktrade.api.deps import AppState, get_state, require_owner | |
| 12 | +from ktrade.db.audit import audit, verify_chain | |
| 13 | +from ktrade.db.pool import has_timescale | |
| 14 | +from ktrade.kraken.rest import KrakenAPIError | |
| 15 | + | |
| 16 | +router = APIRouter() | |
| 17 | + | |
| 18 | + | |
| 19 | +@router.get("/health") | |
| 20 | +async def health(st: AppState = Depends(get_state)): | |
| 21 | + out = {"ok": True, "version": __version__, "env": st.settings.env, "mode": st.settings.trading_mode} | |
| 22 | + try: | |
| 23 | + await st.pool.fetchval("SELECT 1") | |
| 24 | + out["db"] = "ok" | |
| 25 | + except Exception as e: # noqa: BLE001 | |
| 26 | + out["db"] = f"error: {e}" | |
| 27 | + out["ok"] = False | |
| 28 | + try: | |
| 29 | + await st.redis.ping() | |
| 30 | + out["redis"] = "ok" | |
| 31 | + raw = await st.redis.get("ktrade:stream:health") | |
| 32 | + out["stream"] = json.loads(raw) if raw else {"connected": False, "note": "worker not reporting"} | |
| 33 | + except Exception as e: # noqa: BLE001 | |
| 34 | + out["redis"] = f"error: {e}" | |
| 35 | + out["ok"] = False | |
| 36 | + return out | |
| 37 | + | |
| 38 | + | |
| 39 | +class LoginIn(BaseModel): | |
| 40 | + password: str | |
| 41 | + | |
| 42 | + | |
| 43 | +@router.post("/auth/login") | |
| 44 | +async def login(body: LoginIn, request: Request, response: Response, st: AppState = Depends(get_state)): | |
| 45 | + rate_limit_login(request.client.host if request.client else "?") | |
| 46 | + if not st.settings.owner_password_hash: | |
| 47 | + raise HTTPException(503, "owner password not configured (run `ktrade set-owner-password`)") | |
| 48 | + if not verify_password(body.password, st.settings.owner_password_hash): | |
| 49 | + await audit(st.pool, "anonymous", "auth.login_failed", details={"ip": request.client.host if request.client else None}) | |
| 50 | + raise HTTPException(401, "invalid password") | |
| 51 | + issue_session(response, st.settings) | |
| 52 | + await audit(st.pool, "owner", "auth.login", details={"ip": request.client.host if request.client else None}) | |
| 53 | + return {"ok": True, "role": "owner"} | |
| 54 | + | |
| 55 | + | |
| 56 | +@router.post("/auth/logout") | |
| 57 | +async def logout(response: Response, st: AppState = Depends(get_state), _=Depends(require_owner)): | |
| 58 | + clear_session(response, st.settings) | |
| 59 | + return {"ok": True} | |
| 60 | + | |
| 61 | + | |
| 62 | +@router.get("/auth/me") | |
| 63 | +async def me(request: Request, st: AppState = Depends(get_state)): | |
| 64 | + s = read_session(request, st.settings) | |
| 65 | + return {"authenticated": bool(s), "role": s["role"] if s else None, "configured": bool(st.settings.owner_password_hash)} | |
| 66 | + | |
| 67 | + | |
| 68 | +@router.get("/system/status") | |
| 69 | +async def system_status(st: AppState = Depends(get_state), _=Depends(require_owner)): | |
| 70 | + s = st.settings | |
| 71 | + out = { | |
| 72 | + "mode": s.trading_mode, "live_trading_enabled": s.live_trading_enabled, "env": s.env, "version": __version__, | |
| 73 | + "universe": s.universe, "intervals": s.candle_intervals, "timescale": await has_timescale(st.pool), | |
| 74 | + "credentials": {"data_key": s.has_data_key(), "trade_key": s.has_trade_key(), "withdrawal": "never used"}, | |
| 75 | + "preflight": st.preflight_cache or None, | |
| 76 | + } | |
| 77 | + try: | |
| 78 | + t0 = time.time() | |
| 79 | + st_status = await st.rest.system_status() | |
| 80 | + srv = await st.rest.time() | |
| 81 | + out["kraken"] = {"status": st_status.status, "timestamp": st_status.timestamp, "rtt_ms": round((time.time() - t0) * 1000 / 2), "clock_drift_s": round(time.time() - srv, 2)} | |
| 82 | + except Exception as e: # noqa: BLE001 | |
| 83 | + out["kraken"] = {"status": "unreachable", "error": str(e)} | |
| 84 | + raw = await st.redis.get("ktrade:stream:health") | |
| 85 | + out["stream"] = json.loads(raw) if raw else None | |
| 86 | + return out | |
| 87 | + | |
| 88 | + | |
| 89 | +@router.get("/audit") | |
| 90 | +async def audit_list(limit: int = 200, st: AppState = Depends(get_state), _=Depends(require_owner)): | |
| 91 | + rows = await st.pool.fetch("SELECT id, ts, actor, action, entity, details, cid, hash FROM audit_log ORDER BY id DESC LIMIT $1", min(limit, 2000)) | |
| 92 | + ok, n = await verify_chain(st.pool) | |
| 93 | + return {"items": [dict(r) for r in rows], "chain_valid": ok, "chain_length": n} | |
| 94 | + | |
| 95 | + | |
| 96 | +@router.get("/account/balance") | |
| 97 | +async def balance(st: AppState = Depends(get_state), _=Depends(require_owner)): | |
| 98 | + if not st.rest.has_credentials: | |
| 99 | + raise HTTPException(503, "no data/account API key configured") | |
| 100 | + try: | |
| 101 | + bal = await st.rest.balance() | |
| 102 | + except KrakenAPIError as e: | |
| 103 | + raise HTTPException(502, str(e)) from e | |
| 104 | + return {"balances": {k: str(v) for k, v in bal.items() if v != 0}, "fetched_at": time.time()} | |
| 105 | + | |
| 106 | + | |
| 107 | +@router.post("/account/preflight") | |
| 108 | +async def preflight(st: AppState = Depends(get_state), _=Depends(require_owner)): | |
| 109 | + """Probe what the configured keys can do. Trade permission is probed with AddOrder validate=true on an | |
| 110 | + order that could never fill (limit buy at 10% of market, min size, expires in 5 s) — logged to the audit trail.""" | |
| 111 | + from ktrade.execution.preflight import run_preflight | |
| 112 | + | |
| 113 | + res = await run_preflight(st) | |
| 114 | + st.preflight_cache = res | |
| 115 | + await audit(st.pool, "owner", "account.preflight", details=res) | |
| 116 | + return res | |
added
apps/api/ktrade/backtest/__init__.py
+0 −0
added
apps/api/ktrade/backtest/costs.py
+40 −0
@@ -0,0 +1,40 @@ | ||
| 1 | +"""Execution cost & fill model shared by the backtester and the paper simulator.""" | |
| 2 | + | |
| 3 | +from __future__ import annotations | |
| 4 | + | |
| 5 | +import math | |
| 6 | +from decimal import ROUND_DOWN, Decimal | |
| 7 | + | |
| 8 | +from pydantic import BaseModel, Field | |
| 9 | + | |
| 10 | + | |
| 11 | +class CostModel(BaseModel): | |
| 12 | + taker_fee: float = Field(0.0040, description="Kraken spot taker fee at the lowest tier (verify with TradeVolume)") | |
| 13 | + maker_fee: float = Field(0.0025) | |
| 14 | + spread_bps: float = Field(5.0, description="Assumed quoted spread when no book data (bps of mid)") | |
| 15 | + slippage_bps: float = Field(2.0, description="Extra adverse move per order") | |
| 16 | + impact_coeff: float = Field(0.1, description="Impact (bps) = coeff × sqrt(qty / bar_volume) × 1e4") | |
| 17 | + max_participation: float = Field(0.05, description="Max fraction of bar volume filled per bar (partial fills)") | |
| 18 | + latency_bars: int = Field(0, description="Extra bars of delay beyond next-open execution") | |
| 19 | + use_maker: bool = False | |
| 20 | + fee_multiplier: float = 1.0 # stress: fee increases | |
| 21 | + spread_multiplier: float = 1.0 # stress: spread widening | |
| 22 | + | |
| 23 | + | |
| 24 | +def round_lot(qty: float, lot_decimals: int) -> float: | |
| 25 | + if qty <= 0 or not math.isfinite(qty): | |
| 26 | + return 0.0 | |
| 27 | + q = Decimal(str(qty)).quantize(Decimal(1).scaleb(-lot_decimals), rounding=ROUND_DOWN) | |
| 28 | + return float(q) | |
| 29 | + | |
| 30 | + | |
| 31 | +def fill_price(side: str, ref_price: float, qty: float, bar_volume: float, cm: CostModel) -> float: | |
| 32 | + half_spread = ref_price * (cm.spread_bps * cm.spread_multiplier) / 2e4 | |
| 33 | + slip = ref_price * cm.slippage_bps / 1e4 | |
| 34 | + impact = ref_price * cm.impact_coeff * math.sqrt(qty / bar_volume) if bar_volume > 0 else 0.0 | |
| 35 | + adverse = (0.0 if cm.use_maker else half_spread) + slip + impact | |
| 36 | + return ref_price + adverse if side == "buy" else max(ref_price - adverse, 1e-12) | |
| 37 | + | |
| 38 | + | |
| 39 | +def fee_rate(cm: CostModel) -> float: | |
| 40 | + return (cm.maker_fee if cm.use_maker else cm.taker_fee) * cm.fee_multiplier | |
added
apps/api/ktrade/backtest/engine.py
+143 −0
@@ -0,0 +1,143 @@ | ||
| 1 | +"""Event-driven bar backtester. | |
| 2 | + | |
| 3 | +Timeline per bar i (closed bars only): | |
| 4 | + • At close(i) the strategy's target exposure τ(i) is known (features use rows ≤ i). | |
| 5 | + • The resulting order is *submitted* after close(i) and executed at open(i+1+latency) — never on bar i. | |
| 6 | + • Fill price = open ± half-spread ± slippage ± impact ; fee = taker/maker × notional ; partial fills capped at | |
| 7 | + max_participation × volume(i+1) ; qty rounded DOWN to lot_decimals ; orders below ordermin/costmin are dropped. | |
| 8 | + • Every order passes the independent RiskGateway (same class used by paper/live engines). | |
| 9 | + • Equity is marked at close(i+1). Fees, slippage and rejected/partial orders are all accounted in the ledger. | |
| 10 | +""" | |
| 11 | + | |
| 12 | +from __future__ import annotations | |
| 13 | + | |
| 14 | +from dataclasses import dataclass, field | |
| 15 | +from typing import Any, Literal | |
| 16 | + | |
| 17 | +import numpy as np | |
| 18 | +import pandas as pd | |
| 19 | + | |
| 20 | +from ktrade.backtest.costs import CostModel, fee_rate, fill_price, round_lot | |
| 21 | +from ktrade.risk.gateway import MarketState, OrderIntent, PortfolioSnapshot, RiskGateway, RiskLimits | |
| 22 | +from ktrade.strategy.base import Strategy | |
| 23 | + | |
| 24 | + | |
| 25 | +@dataclass | |
| 26 | +class TradeRecord: | |
| 27 | + ts: int | |
| 28 | + side: str | |
| 29 | + qty: float | |
| 30 | + price: float | |
| 31 | + ref_price: float | |
| 32 | + fee: float | |
| 33 | + slippage_cost: float | |
| 34 | + notional: float | |
| 35 | + reason: str | |
| 36 | + strategy_target: float | |
| 37 | + partial: bool = False | |
| 38 | + | |
| 39 | + | |
| 40 | +@dataclass | |
| 41 | +class BacktestResult: | |
| 42 | + equity: pd.DataFrame # ts, equity, cash, position, exposure, price, target | |
| 43 | + trades: list[TradeRecord] | |
| 44 | + rejected: list[dict[str, Any]] | |
| 45 | + fees_paid: float | |
| 46 | + slippage_paid: float | |
| 47 | + turnover: float | |
| 48 | + bars: int | |
| 49 | + diagnostics: dict[str, Any] = field(default_factory=dict) | |
| 50 | + | |
| 51 | + | |
| 52 | +def run_backtest( | |
| 53 | + strategy: Strategy, | |
| 54 | + df: pd.DataFrame, | |
| 55 | + *, | |
| 56 | + interval_min: int, | |
| 57 | + initial_cash: float = 10_000.0, | |
| 58 | + cost_model: CostModel | None = None, | |
| 59 | + risk_limits: RiskLimits | None = None, | |
| 60 | + lot_decimals: int = 8, | |
| 61 | + ordermin: float = 0.0, | |
| 62 | + costmin: float = 0.0, | |
| 63 | + rebalance_threshold: float = 0.05, | |
| 64 | +) -> BacktestResult: | |
| 65 | + cm = cost_model or CostModel() | |
| 66 | + limits = risk_limits or RiskLimits(max_order_notional=1e12, max_position_notional=1e12, max_gross_exposure=1.0, max_concentration=1.0, daily_loss_limit=1.0, max_drawdown_limit=1.0, stale_data_seconds=1e12, max_open_orders=10, max_participation=cm.max_participation) | |
| 67 | + gateway = RiskGateway(limits) | |
| 68 | + df = df.sort_values("ts").reset_index(drop=True).copy() | |
| 69 | + df.attrs["interval_min"] = interval_min | |
| 70 | + res = strategy.run(df) | |
| 71 | + target = res.target.to_numpy(dtype=float) | |
| 72 | + ts = df["ts"].to_numpy(dtype=np.int64) | |
| 73 | + o = df["open"].to_numpy(dtype=float) | |
| 74 | + c = df["close"].to_numpy(dtype=float) | |
| 75 | + v = df["volume"].to_numpy(dtype=float) | |
| 76 | + n = len(df) | |
| 77 | + cash = initial_cash | |
| 78 | + pos = 0.0 | |
| 79 | + fees = 0.0 | |
| 80 | + slip = 0.0 | |
| 81 | + traded_notional = 0.0 | |
| 82 | + peak = initial_cash | |
| 83 | + trades: list[TradeRecord] = [] | |
| 84 | + rejected: list[dict[str, Any]] = [] | |
| 85 | + rows = [] | |
| 86 | + fr = fee_rate(cm) | |
| 87 | + pending: list[tuple[int, float]] = [] # (execute_at_index, target) | |
| 88 | + for i in range(n): | |
| 89 | + # 1) execute orders scheduled for this bar's open (decided at an earlier close) | |
| 90 | + due = [t for (idx, t) in pending if idx == i] | |
| 91 | + pending = [(idx, t) for (idx, t) in pending if idx != i] | |
| 92 | + if due: | |
| 93 | + tgt = due[-1] | |
| 94 | + ref = o[i] | |
| 95 | + equity_open = cash + pos * ref | |
| 96 | + desired_qty = tgt * equity_open / ref | |
| 97 | + delta = desired_qty - pos | |
| 98 | + # avoid churning: skip if the change in exposure is below the threshold | |
| 99 | + if abs(delta * ref) >= rebalance_threshold * equity_open and abs(delta) > 0: | |
| 100 | + side: Literal["buy", "sell"] = "buy" if delta > 0 else "sell" | |
| 101 | + qty = round_lot(abs(delta), lot_decimals) | |
| 102 | + if side == "sell": | |
| 103 | + qty = min(qty, pos) | |
| 104 | + max_fill = cm.max_participation * v[i] if v[i] > 0 else qty | |
| 105 | + partial = qty > max_fill | |
| 106 | + qty = round_lot(min(qty, max_fill), lot_decimals) | |
| 107 | + px = fill_price(side, ref, qty, v[i], cm) if qty > 0 else ref | |
| 108 | + if side == "buy": # cap by cash incl. fee | |
| 109 | + qty = round_lot(min(qty, cash / (px * (1 + fr))), lot_decimals) | |
| 110 | + intent = OrderIntent(symbol="BT", side=side, qty=qty, strategy=strategy.meta.name) | |
| 111 | + pf = PortfolioSnapshot(equity=equity_open, cash=cash, positions={"BT": pos}, marks={"BT": ref}, peak_equity=peak) | |
| 112 | + mkt = MarketState(last_price=ref, recent_bar_volume=v[i] if v[i] > 0 else None, ordermin=ordermin, costmin=costmin) | |
| 113 | + dec = gateway.evaluate(intent, pf, mkt) | |
| 114 | + if qty > 0 and dec.allowed: | |
| 115 | + notional = qty * px | |
| 116 | + fee = notional * fr | |
| 117 | + if side == "buy": | |
| 118 | + cash -= notional + fee | |
| 119 | + pos += qty | |
| 120 | + else: | |
| 121 | + cash += notional - fee | |
| 122 | + pos -= qty | |
| 123 | + fees += fee | |
| 124 | + sc = abs(px - ref) * qty | |
| 125 | + slip += sc | |
| 126 | + traded_notional += notional | |
| 127 | + trades.append(TradeRecord(ts=int(ts[i]), side=side, qty=qty, price=px, ref_price=ref, fee=fee, slippage_cost=sc, notional=notional, reason="rebalance", strategy_target=tgt, partial=partial)) | |
| 128 | + if partial: | |
| 129 | + pending.append((i + 1, tgt)) # keep working the remainder next bar | |
| 130 | + elif qty > 0: | |
| 131 | + rejected.append({"ts": int(ts[i]), "side": side, "qty": qty, "reasons": dec.reasons}) | |
| 132 | + # 2) mark at close | |
| 133 | + equity = cash + pos * c[i] | |
| 134 | + peak = max(peak, equity) | |
| 135 | + rows.append((int(ts[i]), equity, cash, pos, (pos * c[i]) / equity if equity > 0 else 0.0, c[i], float(target[i]) if not np.isnan(target[i]) else 0.0)) | |
| 136 | + # 3) decision at close(i) → execution at open(i+1+latency) | |
| 137 | + if i + 1 + cm.latency_bars < n and not np.isnan(target[i]): | |
| 138 | + pending.append((i + 1 + cm.latency_bars, float(target[i]))) | |
| 139 | + eq = pd.DataFrame(rows, columns=["ts", "equity", "cash", "position", "exposure", "price", "target"]) | |
| 140 | + avg_equity = float(eq["equity"].mean()) if n else initial_cash | |
| 141 | + bars_per_year = 365 * 24 * 60 / interval_min | |
| 142 | + turnover = (traded_notional / avg_equity) / max(n / bars_per_year, 1e-9) if avg_equity > 0 else 0.0 | |
| 143 | + return BacktestResult(equity=eq, trades=trades, rejected=rejected, fees_paid=fees, slippage_paid=slip, turnover=turnover, bars=n, diagnostics={"strategy": res.diagnostics, "n_partial": sum(t.partial for t in trades)}) | |
added
apps/api/ktrade/backtest/metrics.py
+147 −0
@@ -0,0 +1,147 @@ | ||
| 1 | +"""Performance statistics, uncertainty (block bootstrap), multiple-testing (deflated Sharpe), and stress scenarios.""" | |
| 2 | + | |
| 3 | +from __future__ import annotations | |
| 4 | + | |
| 5 | +import math | |
| 6 | +from typing import Any | |
| 7 | + | |
| 8 | +import numpy as np | |
| 9 | +import pandas as pd | |
| 10 | +from scipy import stats | |
| 11 | + | |
| 12 | + | |
| 13 | +def drawdown_series(equity: pd.Series) -> pd.Series: | |
| 14 | + peak = equity.cummax() | |
| 15 | + return equity / peak - 1.0 | |
| 16 | + | |
| 17 | + | |
| 18 | +def compute_metrics(equity: pd.DataFrame, trades: list, interval_min: int, initial_cash: float, fees_paid: float, slippage_paid: float, turnover: float) -> dict[str, Any]: | |
| 19 | + eq = equity["equity"].to_numpy(dtype=float) | |
| 20 | + n = len(eq) | |
| 21 | + if n < 3: | |
| 22 | + return {"bars": n, "error": "not enough bars"} | |
| 23 | + r = np.diff(np.log(np.maximum(eq, 1e-12))) | |
| 24 | + bars_per_year = 365 * 24 * 60 / interval_min | |
| 25 | + years = n / bars_per_year | |
| 26 | + total_return = eq[-1] / eq[0] - 1 | |
| 27 | + cagr = (eq[-1] / eq[0]) ** (1 / years) - 1 if years > 0 and eq[-1] > 0 else float("nan") | |
| 28 | + vol = float(np.std(r, ddof=1) * math.sqrt(bars_per_year)) if n > 2 else float("nan") | |
| 29 | + mean_ann = float(np.mean(r) * bars_per_year) | |
| 30 | + sharpe = mean_ann / vol if vol and vol > 0 else float("nan") | |
| 31 | + downside = r[r < 0] | |
| 32 | + dvol = float(np.sqrt(np.mean(downside**2)) * math.sqrt(bars_per_year)) if len(downside) else float("nan") | |
| 33 | + sortino = mean_ann / dvol if dvol and dvol > 0 else float("nan") | |
| 34 | + dd = drawdown_series(equity["equity"]) | |
| 35 | + max_dd = float(dd.min()) | |
| 36 | + calmar = cagr / abs(max_dd) if max_dd < 0 and not math.isnan(cagr) else float("nan") | |
| 37 | + exposure = float(equity["exposure"].abs().mean()) | |
| 38 | + # trade-level (round trips approximated by consecutive buy→sell pairs) | |
| 39 | + pnl_trades = _round_trips(trades) | |
| 40 | + wins = [p for p in pnl_trades if p > 0] | |
| 41 | + losses = [p for p in pnl_trades if p < 0] | |
| 42 | + hit = len(wins) / len(pnl_trades) if pnl_trades else float("nan") | |
| 43 | + pf = (sum(wins) / abs(sum(losses))) if losses else (float("inf") if wins else float("nan")) | |
| 44 | + var95 = float(np.quantile(r, 0.05)) if n > 20 else float("nan") | |
| 45 | + cvar95 = float(r[r <= var95].mean()) if n > 20 and np.any(r <= var95) else float("nan") | |
| 46 | + fee_drag = fees_paid / initial_cash | |
| 47 | + slip_drag = slippage_paid / initial_cash | |
| 48 | + underwater = int((dd < 0).sum()) | |
| 49 | + return { | |
| 50 | + "bars": n, "years": years, "total_return": total_return, "cagr": cagr, "volatility": vol, "sharpe": sharpe, "sortino": sortino, | |
| 51 | + "max_drawdown": max_dd, "calmar": calmar, "exposure": exposure, "turnover_annual": turnover, "n_trades": len(trades), | |
| 52 | + "n_round_trips": len(pnl_trades), "hit_rate": hit, "profit_factor": pf, "avg_trade_pnl": float(np.mean(pnl_trades)) if pnl_trades else float("nan"), | |
| 53 | + "var_95_bar": var95, "cvar_95_bar": cvar95, "fees_paid": fees_paid, "fee_drag": fee_drag, "slippage_paid": slippage_paid, "slippage_drag": slip_drag, | |
| 54 | + "underwater_bars_pct": underwater / n, "final_equity": float(eq[-1]), "skew": float(stats.skew(r)) if n > 3 else float("nan"), "kurtosis": float(stats.kurtosis(r)) if n > 3 else float("nan"), | |
| 55 | + } | |
| 56 | + | |
| 57 | + | |
| 58 | +def _round_trips(trades: list) -> list[float]: | |
| 59 | + """FIFO round-trip P&L (buy lots closed by sells), fees included.""" | |
| 60 | + lots: list[list[float]] = [] # [qty, price_incl_fee] | |
| 61 | + out: list[float] = [] | |
| 62 | + for t in trades: | |
| 63 | + if t.side == "buy": | |
| 64 | + lots.append([t.qty, (t.notional + t.fee) / t.qty if t.qty else t.price]) | |
| 65 | + else: | |
| 66 | + remaining = t.qty | |
| 67 | + proceeds_per_unit = (t.notional - t.fee) / t.qty if t.qty else t.price | |
| 68 | + pnl = 0.0 | |
| 69 | + while remaining > 1e-12 and lots: | |
| 70 | + lot = lots[0] | |
| 71 | + take = min(lot[0], remaining) | |
| 72 | + pnl += take * (proceeds_per_unit - lot[1]) | |
| 73 | + lot[0] -= take | |
| 74 | + remaining -= take | |
| 75 | + if lot[0] <= 1e-12: | |
| 76 | + lots.pop(0) | |
| 77 | + out.append(pnl) | |
| 78 | + return out | |
| 79 | + | |
| 80 | + | |
| 81 | +def rolling_sharpe(equity: pd.DataFrame, interval_min: int, window_bars: int) -> list[dict[str, float]]: | |
| 82 | + r = np.log(equity["equity"]).diff() | |
| 83 | + bars_per_year = 365 * 24 * 60 / interval_min | |
| 84 | + rs = (r.rolling(window_bars).mean() / r.rolling(window_bars).std(ddof=1)) * math.sqrt(bars_per_year) | |
| 85 | + return [{"ts": int(t), "sharpe": float(s)} for t, s in zip(equity["ts"], rs, strict=False) if not math.isnan(s)] | |
| 86 | + | |
| 87 | + | |
| 88 | +def block_bootstrap_ci(returns: np.ndarray, bars_per_year: float, n_boot: int = 500, block: int | None = None, seed: int = 42) -> dict[str, Any]: | |
| 89 | + """Stationary (circular) block bootstrap of the Sharpe ratio and CAGR.""" | |
| 90 | + rng = np.random.default_rng(seed) | |
| 91 | + n = len(returns) | |
| 92 | + if n < 30: | |
| 93 | + return {"sharpe_ci": [None, None], "cagr_ci": [None, None], "n_boot": 0} | |
| 94 | + block = block or max(5, int(round(n ** (1 / 3)))) | |
| 95 | + sharpes, cagrs = [], [] | |
| 96 | + nb = int(math.ceil(n / block)) | |
| 97 | + for _ in range(n_boot): | |
| 98 | + starts = rng.integers(0, n, size=nb) | |
| 99 | + idx = (starts[:, None] + np.arange(block)[None, :]).ravel() % n | |
| 100 | + s = returns[idx[:n]] | |
| 101 | + sd = s.std(ddof=1) | |
| 102 | + sharpes.append(s.mean() / sd * math.sqrt(bars_per_year) if sd > 0 else 0.0) | |
| 103 | + cagrs.append(math.exp(s.sum() * (bars_per_year / n)) - 1) | |
| 104 | + return {"sharpe_ci": [float(np.quantile(sharpes, 0.025)), float(np.quantile(sharpes, 0.975))], "cagr_ci": [float(np.quantile(cagrs, 0.025)), float(np.quantile(cagrs, 0.975))], "n_boot": n_boot, "block": block} | |
| 105 | + | |
| 106 | + | |
| 107 | +def deflated_sharpe(sharpe_annual: float, n_obs: int, bars_per_year: float, n_trials: int, skew: float, kurt_excess: float, sharpe_var_trials: float | None = None) -> dict[str, Any]: | |
| 108 | + """Bailey & López de Prado (2014) Deflated Sharpe Ratio: probability that the observed SR exceeds the expected max SR of N trials under H0.""" | |
| 109 | + if n_obs < 10 or not math.isfinite(sharpe_annual): | |
| 110 | + return {"dsr": None, "sr0": None} | |
| 111 | + sr = sharpe_annual / math.sqrt(bars_per_year) # per-bar Sharpe | |
| 112 | + var_trials = sharpe_var_trials if sharpe_var_trials is not None else (0.5 / bars_per_year) # conservative default spread of trial SRs (per bar) | |
| 113 | + em = 0.5772156649 | |
| 114 | + n_trials = max(n_trials, 1) | |
| 115 | + if n_trials == 1: | |
| 116 | + sr0 = 0.0 | |
| 117 | + else: | |
| 118 | + sr0 = math.sqrt(var_trials) * ((1 - em) * stats.norm.ppf(1 - 1 / n_trials) + em * stats.norm.ppf(1 - 1 / (n_trials * math.e))) | |
| 119 | + denom = math.sqrt(max(1 - skew * sr + (kurt_excess + 3 - 1) / 4 * sr**2, 1e-12)) | |
| 120 | + z = (sr - sr0) * math.sqrt(n_obs - 1) / denom | |
| 121 | + return {"dsr": float(stats.norm.cdf(z)), "sr0_annual": sr0 * math.sqrt(bars_per_year), "n_trials": n_trials} | |
| 122 | + | |
| 123 | + | |
| 124 | +def stress_scenarios(run_fn, base_cost, equity_df: pd.DataFrame) -> dict[str, Any]: | |
| 125 | + """Re-run the backtest under adverse cost/market assumptions. `run_fn(cost_model, df_override=None)` → metrics dict.""" | |
| 126 | + out: dict[str, Any] = {} | |
| 127 | + cm = base_cost | |
| 128 | + out["fees_x2"] = run_fn(cm.model_copy(update={"fee_multiplier": 2.0})) | |
| 129 | + out["spread_x3"] = run_fn(cm.model_copy(update={"spread_multiplier": 3.0})) | |
| 130 | + out["slippage_plus_20bps"] = run_fn(cm.model_copy(update={"slippage_bps": cm.slippage_bps + 20})) | |
| 131 | + out["latency_plus_1_bar"] = run_fn(cm.model_copy(update={"latency_bars": cm.latency_bars + 1})) | |
| 132 | + out["liquidity_collapse"] = run_fn(cm.model_copy(update={"max_participation": max(cm.max_participation / 10, 1e-4), "impact_coeff": cm.impact_coeff * 5})) | |
| 133 | + return out | |
| 134 | + | |
| 135 | + | |
| 136 | +def shuffled_trades_test(trade_pnls: list[float], n: int = 1000, seed: int = 7) -> dict[str, Any]: | |
| 137 | + """Monte-Carlo reordering of trade P&Ls: distribution of max drawdown of the cumulative P&L path.""" | |
| 138 | + if len(trade_pnls) < 5: | |
| 139 | + return {"n": 0} | |
| 140 | + rng = np.random.default_rng(seed) | |
| 141 | + arr = np.array(trade_pnls) | |
| 142 | + mdds = [] | |
| 143 | + for _ in range(n): | |
| 144 | + p = rng.permutation(arr).cumsum() | |
| 145 | + peak = np.maximum.accumulate(np.concatenate([[0.0], p]))[1:] | |
| 146 | + mdds.append(float((p - peak).min())) | |
| 147 | + return {"n": n, "max_dd_p50": float(np.median(mdds)), "max_dd_p95": float(np.quantile(mdds, 0.05)), "max_dd_worst": float(min(mdds)), "total_pnl": float(arr.sum())} | |
added
apps/api/ktrade/backtest/runner.py
+165 −0
@@ -0,0 +1,165 @@ | ||
| 1 | +"""Backtest orchestration: provenance, benchmarks, uncertainty, stress tests, walk-forward; stores results in Postgres.""" | |
| 2 | + | |
| 3 | +from __future__ import annotations | |
| 4 | + | |
| 5 | +import hashlib | |
| 6 | +import math | |
| 7 | +import subprocess | |
| 8 | +import time | |
| 9 | +import uuid | |
| 10 | +from pathlib import Path | |
| 11 | +from typing import Any | |
| 12 | + | |
| 13 | +import asyncpg | |
| 14 | +import numpy as np | |
| 15 | +import pandas as pd | |
| 16 | + | |
| 17 | +from ktrade import __version__ | |
| 18 | +from ktrade.backtest.costs import CostModel | |
| 19 | +from ktrade.backtest.engine import run_backtest | |
| 20 | +from ktrade.backtest.metrics import ( | |
| 21 | + block_bootstrap_ci, | |
| 22 | + compute_metrics, | |
| 23 | + deflated_sharpe, | |
| 24 | + drawdown_series, | |
| 25 | + rolling_sharpe, | |
| 26 | + shuffled_trades_test, | |
| 27 | + stress_scenarios, | |
| 28 | +) | |
| 29 | +from ktrade.backtest.validation import sensitivity_map, walk_forward | |
| 30 | +from ktrade.market.service import candles_frame | |
| 31 | +from ktrade.risk.gateway import RiskLimits | |
| 32 | +from ktrade.strategy.registry import get_strategy | |
| 33 | + | |
| 34 | +ROOT = Path(__file__).resolve().parents[3] | |
| 35 | + | |
| 36 | + | |
| 37 | +def _git_commit() -> str | None: | |
| 38 | + try: | |
| 39 | + return subprocess.check_output(["git", "-C", str(ROOT), "rev-parse", "HEAD"], text=True, stderr=subprocess.DEVNULL).strip() | |
| 40 | + except Exception: # noqa: BLE001 | |
| 41 | + return None | |
| 42 | + | |
| 43 | + | |
| 44 | +def dataset_hash(df: pd.DataFrame) -> str: | |
| 45 | + h = hashlib.sha256() | |
| 46 | + h.update(pd.util.hash_pandas_object(df[["ts", "open", "high", "low", "close", "volume"]], index=False).to_numpy().tobytes()) | |
| 47 | + return h.hexdigest()[:16] | |
| 48 | + | |
| 49 | + | |
| 50 | +def _clean(o: Any) -> Any: | |
| 51 | + if isinstance(o, dict): | |
| 52 | + return {k: _clean(v) for k, v in o.items()} | |
| 53 | + if isinstance(o, list | tuple): | |
| 54 | + return [_clean(v) for v in o] | |
| 55 | + if isinstance(o, float) and not math.isfinite(o): | |
| 56 | + return None | |
| 57 | + if isinstance(o, np.bool_): | |
| 58 | + return bool(o) | |
| 59 | + if isinstance(o, np.floating | np.integer): | |
| 60 | + return _clean(o.item()) | |
| 61 | + return o | |
| 62 | + | |
| 63 | + | |
| 64 | +def _downsample(eq: pd.DataFrame, max_points: int = 3000) -> list[dict[str, Any]]: | |
| 65 | + if len(eq) > max_points: | |
| 66 | + step = int(math.ceil(len(eq) / max_points)) | |
| 67 | + eq = pd.concat([eq.iloc[::step], eq.iloc[[-1]]]).drop_duplicates("ts") | |
| 68 | + dd = drawdown_series(eq["equity"]) | |
| 69 | + return [{"ts": int(t), "equity": float(e), "drawdown": float(d), "exposure": float(x), "price": float(p)} for t, e, d, x, p in zip(eq["ts"], eq["equity"], dd, eq["exposure"], eq["price"], strict=False)] | |
| 70 | + | |
| 71 | + | |
| 72 | +async def execute_backtest(pool: asyncpg.Pool, run_id: uuid.UUID, spec: dict[str, Any]) -> None: | |
| 73 | + await pool.execute("UPDATE backtest_runs SET status='running' WHERE id=$1", run_id) | |
| 74 | + t0 = time.time() | |
| 75 | + try: | |
| 76 | + strategy_cls = get_strategy(spec["strategy"]) | |
| 77 | + interval = int(spec["interval_min"]) | |
| 78 | + df = await candles_frame(pool, spec["symbol"], interval, spec.get("start_ts"), spec.get("end_ts")) | |
| 79 | + if len(df) < strategy_cls.meta.min_bars: | |
| 80 | + raise ValueError(f"only {len(df)} closed bars available; strategy needs {strategy_cls.meta.min_bars}") | |
| 81 | + pair = await pool.fetchrow("SELECT lot_decimals, ordermin, costmin FROM pairs WHERE wsname=$1", spec["symbol"]) | |
| 82 | + bt_kwargs: dict[str, Any] = {"lot_decimals": pair["lot_decimals"] if pair else 8, "ordermin": float(pair["ordermin"]) if pair else 0.0, "costmin": float(pair["costmin"]) if pair else 0.0} | |
| 83 | + cm = CostModel(**spec.get("cost_model", {})) | |
| 84 | + initial_cash = float(spec.get("initial_cash", 10_000.0)) | |
| 85 | + limits = RiskLimits(**spec["risk_limits"]) if spec.get("risk_limits") else None | |
| 86 | + holdout_frac = float(spec.get("holdout_frac", 0.0)) | |
| 87 | + df_main = df.iloc[: int(len(df) * (1 - holdout_frac))] if holdout_frac > 0 else df | |
| 88 | + df_hold = df.iloc[int(len(df) * (1 - holdout_frac)) - strategy_cls.meta.min_bars * 2 :] if holdout_frac > 0 else None | |
| 89 | + | |
| 90 | + strat = strategy_cls(spec.get("params", {})) | |
| 91 | + res = run_backtest(strat, df_main, interval_min=interval, initial_cash=initial_cash, cost_model=cm, risk_limits=limits, **bt_kwargs) | |
| 92 | + metrics = compute_metrics(res.equity, res.trades, interval, initial_cash, res.fees_paid, res.slippage_paid, res.turnover) | |
| 93 | + bars_per_year = 365 * 24 * 60 / interval | |
| 94 | + r = np.diff(np.log(np.maximum(res.equity["equity"].to_numpy(), 1e-12))) | |
| 95 | + metrics["bootstrap"] = block_bootstrap_ci(r, bars_per_year) | |
| 96 | + metrics["deflated_sharpe"] = deflated_sharpe(metrics.get("sharpe", float("nan")), len(r), bars_per_year, int(spec.get("n_trials", 1)), metrics.get("skew", 0.0) or 0.0, metrics.get("kurtosis", 0.0) or 0.0) | |
| 97 | + | |
| 98 | + # benchmarks on the same window & costs | |
| 99 | + benchmarks = {} | |
| 100 | + for b in ("buy_and_hold", "cash", "simple_momentum", "equal_risk"): | |
| 101 | + br = run_backtest(get_strategy(b)(), df_main, interval_min=interval, initial_cash=initial_cash, cost_model=cm, **bt_kwargs) | |
| 102 | + benchmarks[b] = {"metrics": compute_metrics(br.equity, br.trades, interval, initial_cash, br.fees_paid, br.slippage_paid, br.turnover), "equity": _downsample(br.equity, 800)} | |
| 103 | + | |
| 104 | + def rerun(cost: CostModel) -> dict[str, Any]: | |
| 105 | + rr = run_backtest(strategy_cls(spec.get("params", {})), df_main, interval_min=interval, initial_cash=initial_cash, cost_model=cost, risk_limits=limits, **bt_kwargs) | |
| 106 | + mm = compute_metrics(rr.equity, rr.trades, interval, initial_cash, rr.fees_paid, rr.slippage_paid, rr.turnover) | |
| 107 | + return {k: mm.get(k) for k in ("sharpe", "total_return", "max_drawdown", "cagr", "fee_drag")} | |
| 108 | + | |
| 109 | + stress = stress_scenarios(rerun, cm, res.equity) if spec.get("stress", True) else {} | |
| 110 | + # gap / outage scenario: drop a random 5% contiguous block of bars (exchange outage) and re-run | |
| 111 | + if spec.get("stress", True) and len(df_main) > 400: | |
| 112 | + cut = len(df_main) // 20 | |
| 113 | + start = len(df_main) // 2 | |
| 114 | + df_gap = pd.concat([df_main.iloc[:start], df_main.iloc[start + cut :]]).reset_index(drop=True) | |
| 115 | + gr = run_backtest(strategy_cls(spec.get("params", {})), df_gap, interval_min=interval, initial_cash=initial_cash, cost_model=cm, risk_limits=limits, **bt_kwargs) | |
| 116 | + gm = compute_metrics(gr.equity, gr.trades, interval, initial_cash, gr.fees_paid, gr.slippage_paid, gr.turnover) | |
| 117 | + stress["exchange_outage_gap"] = {k: gm.get(k) for k in ("sharpe", "total_return", "max_drawdown")} | |
| 118 | + from ktrade.backtest.metrics import _round_trips | |
| 119 | + | |
| 120 | + shuffled = shuffled_trades_test(_round_trips(res.trades)) if spec.get("stress", True) else {} | |
| 121 | + | |
| 122 | + wf = None | |
| 123 | + if spec.get("walk_forward"): | |
| 124 | + wfc = spec["walk_forward"] | |
| 125 | + wf = walk_forward(strategy_cls, df_main, interval_min=interval, grid=wfc.get("grid", {}), n_folds=int(wfc.get("n_folds", 4)), train_frac=float(wfc.get("train_frac", 0.6)), anchored=bool(wfc.get("anchored", True)), cost_model=cm, initial_cash=initial_cash, embargo_bars=int(wfc.get("embargo_bars", 0)), **bt_kwargs) | |
| 126 | + sens = None | |
| 127 | + if spec.get("sensitivity"): | |
| 128 | + sc = spec["sensitivity"] | |
| 129 | + sens = sensitivity_map(strategy_cls, df_main, interval_min=interval, x=(sc["x"], sc["x_values"]), y=(sc["y"], sc["y_values"]), base_params=spec.get("params", {}), cost_model=cm, initial_cash=initial_cash, metric=sc.get("metric", "sharpe"), **bt_kwargs) | |
| 130 | + holdout = None | |
| 131 | + if df_hold is not None and len(df_hold) > strategy_cls.meta.min_bars * 2: | |
| 132 | + hr = run_backtest(strategy_cls(spec.get("params", {})), df_hold, interval_min=interval, initial_cash=initial_cash, cost_model=cm, risk_limits=limits, **bt_kwargs) | |
| 133 | + start_ts = int(df.iloc[int(len(df) * (1 - holdout_frac))]["ts"]) | |
| 134 | + heq = hr.equity[hr.equity["ts"] >= start_ts].reset_index(drop=True) | |
| 135 | + if len(heq) > 2: | |
| 136 | + heq = heq.assign(equity=heq["equity"] / heq["equity"].iloc[0] * initial_cash) | |
| 137 | + holdout = {"start_ts": start_ts, "metrics": compute_metrics(heq, [t for t in hr.trades if t.ts >= start_ts], interval, initial_cash, 0.0, 0.0, hr.turnover), "equity": _downsample(heq, 800)} | |
| 138 | + | |
| 139 | + provenance = { | |
| 140 | + "code_version": __version__, "git_commit": _git_commit(), "dataset_hash": dataset_hash(df_main), "n_bars": int(len(df_main)), | |
| 141 | + "window": [int(df_main["ts"].iloc[0]), int(df_main["ts"].iloc[-1])], "params": strat.params, "cost_model": cm.model_dump(), "seed": 42, | |
| 142 | + "initial_cash": initial_cash, "risk_limits": limits.model_dump() if limits else None, "holdout_frac": holdout_frac, "runtime_s": round(time.time() - t0, 2), | |
| 143 | + "data_source": "Kraken REST OHLC (≤720 bars/poll, accumulated) + WS ohlc", "evaluation_note": "Targets decided at bar close, executed at next open; fees/spread/slippage/impact/partial fills modelled.", | |
| 144 | + } | |
| 145 | + artifacts = { | |
| 146 | + "equity": _downsample(res.equity), "rolling_sharpe": rolling_sharpe(res.equity, interval, max(30, min(int(bars_per_year / 12), len(res.equity) // 4))), | |
| 147 | + "trades": [t.__dict__ for t in res.trades[-2000:]], "rejected": res.rejected[-200:], "benchmarks": benchmarks, "stress": stress, "shuffled_trades": shuffled, | |
| 148 | + "walk_forward": wf, "sensitivity": sens, "holdout": holdout, "features_tail": _features_tail(strat, df_main), | |
| 149 | + } | |
| 150 | + await pool.execute( | |
| 151 | + "UPDATE backtest_runs SET status='done', finished_at=now(), metrics=$2, provenance=$3, artifacts=$4, start_ts=$5, end_ts=$6 WHERE id=$1", | |
| 152 | + run_id, _clean(metrics), _clean(provenance), _clean(artifacts), int(df_main["ts"].iloc[0]), int(df_main["ts"].iloc[-1]), | |
| 153 | + ) | |
| 154 | + except Exception as e: # noqa: BLE001 | |
| 155 | + await pool.execute("UPDATE backtest_runs SET status='failed', finished_at=now(), error=$2 WHERE id=$1", run_id, f"{type(e).__name__}: {e}") | |
| 156 | + raise | |
| 157 | + | |
| 158 | + | |
| 159 | +def _features_tail(strat, df: pd.DataFrame, n: int = 500) -> list[dict[str, Any]]: | |
| 160 | + try: | |
| 161 | + f = strat.features(df.assign().copy()).tail(n) | |
| 162 | + cols = [c for c in f.columns if f[c].dtype.kind in "fi"][:8] | |
| 163 | + return _clean(f[cols].round(6).to_dict(orient="records")) | |
| 164 | + except Exception: # noqa: BLE001 | |
| 165 | + return [] | |
added
apps/api/ktrade/backtest/validation.py
+97 −0
@@ -0,0 +1,97 @@ | ||
| 1 | +"""Walk-forward evaluation, parameter sensitivity maps and holdout handling.""" | |
| 2 | + | |
| 3 | +from __future__ import annotations | |
| 4 | + | |
| 5 | +import itertools | |
| 6 | +import math | |
| 7 | +from typing import Any | |
| 8 | + | |
| 9 | +import numpy as np | |
| 10 | +import pandas as pd | |
| 11 | + | |
| 12 | +from ktrade.backtest.costs import CostModel | |
| 13 | +from ktrade.backtest.engine import run_backtest | |
| 14 | +from ktrade.backtest.metrics import compute_metrics | |
| 15 | +from ktrade.strategy.base import Strategy | |
| 16 | + | |
| 17 | + | |
| 18 | +def _score(m: dict[str, Any]) -> float: | |
| 19 | + s = m.get("sharpe") | |
| 20 | + return s if s is not None and math.isfinite(s) else -1e9 | |
| 21 | + | |
| 22 | + | |
| 23 | +def param_grid(strategy_cls: type[Strategy], grid: dict[str, list[Any]]) -> list[dict[str, Any]]: | |
| 24 | + keys = list(grid) | |
| 25 | + combos = [] | |
| 26 | + for values in itertools.product(*(grid[k] for k in keys)): | |
| 27 | + p = dict(zip(keys, values, strict=True)) | |
| 28 | + try: | |
| 29 | + strategy_cls.validate_params(p) | |
| 30 | + combos.append(p) | |
| 31 | + except ValueError: | |
| 32 | + continue | |
| 33 | + return combos | |
| 34 | + | |
| 35 | + | |
| 36 | +def walk_forward( | |
| 37 | + strategy_cls: type[Strategy], df: pd.DataFrame, *, interval_min: int, grid: dict[str, list[Any]], n_folds: int = 4, | |
| 38 | + train_frac: float = 0.6, anchored: bool = True, cost_model: CostModel | None = None, initial_cash: float = 10_000.0, embargo_bars: int = 0, **bt_kwargs, | |
| 39 | +) -> dict[str, Any]: | |
| 40 | + """Split the series into n_folds consecutive test windows; for each, pick the grid point with the best IS Sharpe on | |
| 41 | + the preceding train window (anchored = growing train, else rolling of fixed length), then evaluate OOS. | |
| 42 | + An embargo of `embargo_bars` between train and test avoids leakage through overlapping feature windows.""" | |
| 43 | + n = len(df) | |
| 44 | + combos = param_grid(strategy_cls, grid) | |
| 45 | + if n < 300 or not combos: | |
| 46 | + return {"folds": [], "error": "not enough data or empty grid", "n_trials": len(combos)} | |
| 47 | + first_test = int(n * train_frac) | |
| 48 | + test_len = (n - first_test) // n_folds | |
| 49 | + folds = [] | |
| 50 | + oos_returns: list[np.ndarray] = [] | |
| 51 | + for k in range(n_folds): | |
| 52 | + test_start = first_test + k * test_len | |
| 53 | + test_end = n if k == n_folds - 1 else test_start + test_len | |
| 54 | + train_start = 0 if anchored else max(0, test_start - first_test) | |
| 55 | + train_end = max(train_start + 50, test_start - embargo_bars) | |
| 56 | + train = df.iloc[train_start:train_end] | |
| 57 | + # the test window gets a warm-up prefix (features need history) but metrics are computed on the test range only | |
| 58 | + warm = min(train_end, strategy_cls.meta.min_bars * 3) | |
| 59 | + test_with_warm = df.iloc[max(0, test_start - warm):test_end] | |
| 60 | + best, best_m = None, None | |
| 61 | + for p in combos: | |
| 62 | + r = run_backtest(strategy_cls(p), train, interval_min=interval_min, initial_cash=initial_cash, cost_model=cost_model, **bt_kwargs) | |
| 63 | + m = compute_metrics(r.equity, r.trades, interval_min, initial_cash, r.fees_paid, r.slippage_paid, r.turnover) | |
| 64 | + if best_m is None or _score(m) > _score(best_m): | |
| 65 | + best, best_m = p, m | |
| 66 | + r = run_backtest(strategy_cls(best), test_with_warm, interval_min=interval_min, initial_cash=initial_cash, cost_model=cost_model, **bt_kwargs) | |
| 67 | + eq = r.equity[r.equity["ts"] >= int(df.iloc[test_start]["ts"])].reset_index(drop=True) | |
| 68 | + if len(eq) > 2: | |
| 69 | + eq = eq.assign(equity=eq["equity"] / eq["equity"].iloc[0] * initial_cash) | |
| 70 | + m_oos = compute_metrics(eq, [t for t in r.trades if t.ts >= int(df.iloc[test_start]["ts"])], interval_min, initial_cash, 0.0, 0.0, r.turnover) if len(eq) > 2 else {"error": "short"} | |
| 71 | + oos_returns.append(np.diff(np.log(np.maximum(eq["equity"].to_numpy(), 1e-12))) if len(eq) > 2 else np.array([])) | |
| 72 | + folds.append({ | |
| 73 | + "fold": k, "train": [int(df.iloc[train_start]["ts"]), int(df.iloc[train_end - 1]["ts"])], "test": [int(df.iloc[test_start]["ts"]), int(df.iloc[test_end - 1]["ts"])], | |
| 74 | + "best_params": best, "is_sharpe": best_m.get("sharpe") if best_m else None, "oos_sharpe": m_oos.get("sharpe"), "oos_return": m_oos.get("total_return"), "oos_max_dd": m_oos.get("max_drawdown"), | |
| 75 | + }) | |
| 76 | + all_oos = np.concatenate([x for x in oos_returns if len(x)]) if any(len(x) for x in oos_returns) else np.array([]) | |
| 77 | + bars_per_year = 365 * 24 * 60 / interval_min | |
| 78 | + stitched = {"sharpe": float(all_oos.mean() / all_oos.std(ddof=1) * math.sqrt(bars_per_year)) if len(all_oos) > 10 and all_oos.std(ddof=1) > 0 else None, "total_return": float(math.exp(all_oos.sum()) - 1) if len(all_oos) else None, "bars": int(len(all_oos))} | |
| 79 | + return {"folds": folds, "anchored": anchored, "n_trials": len(combos), "grid": grid, "embargo_bars": embargo_bars, "stitched_oos": stitched} | |
| 80 | + | |
| 81 | + | |
| 82 | +def sensitivity_map(strategy_cls: type[Strategy], df: pd.DataFrame, *, interval_min: int, x: tuple[str, list[Any]], y: tuple[str, list[Any]], base_params: dict[str, Any], cost_model: CostModel | None = None, initial_cash: float = 10_000.0, metric: str = "sharpe", **bt_kwargs) -> dict[str, Any]: | |
| 83 | + xs, ys = x[1], y[1] | |
| 84 | + z: list[list[float | None]] = [] | |
| 85 | + for yv in ys: | |
| 86 | + row: list[float | None] = [] | |
| 87 | + for xv in xs: | |
| 88 | + p = {**base_params, x[0]: xv, y[0]: yv} | |
| 89 | + try: | |
| 90 | + r = run_backtest(strategy_cls(p), df, interval_min=interval_min, initial_cash=initial_cash, cost_model=cost_model, **bt_kwargs) | |
| 91 | + m = compute_metrics(r.equity, r.trades, interval_min, initial_cash, r.fees_paid, r.slippage_paid, r.turnover) | |
| 92 | + val = m.get(metric) | |
| 93 | + row.append(float(val) if val is not None and math.isfinite(val) else None) | |
| 94 | + except ValueError: | |
| 95 | + row.append(None) | |
| 96 | + z.append(row) | |
| 97 | + return {"x": {"name": x[0], "values": xs}, "y": {"name": y[0], "values": ys}, "metric": metric, "z": z} | |
added
apps/api/ktrade/cli.py
+195 −0
@@ -0,0 +1,195 @@ | ||
| 1 | +"""ktrade command line.""" | |
| 2 | + | |
| 3 | +from __future__ import annotations | |
| 4 | + | |
| 5 | +import asyncio | |
| 6 | +import getpass | |
| 7 | +import json | |
| 8 | +import uuid | |
| 9 | + | |
| 10 | +import typer | |
| 11 | + | |
| 12 | +from ktrade.config import get_settings | |
| 13 | +from ktrade.logging import configure_logging | |
| 14 | + | |
| 15 | +app = typer.Typer(help="Kraken quant trading platform — operator CLI", no_args_is_help=True) | |
| 16 | + | |
| 17 | + | |
| 18 | +@app.command() | |
| 19 | +def migrate() -> None: | |
| 20 | + """Apply SQL migrations.""" | |
| 21 | + from ktrade.db.pool import migrate as _m | |
| 22 | + | |
| 23 | + s = get_settings() | |
| 24 | + configure_logging(s.log_level, False) | |
| 25 | + applied = asyncio.run(_m(s.database_url)) | |
| 26 | + typer.echo(f"applied: {applied or 'nothing (up to date)'}") | |
| 27 | + | |
| 28 | + | |
| 29 | +@app.command() | |
| 30 | +def api(host: str | None = None, port: int | None = None, reload: bool = False) -> None: | |
| 31 | + """Run the FastAPI server.""" | |
| 32 | + import uvicorn | |
| 33 | + | |
| 34 | + s = get_settings() | |
| 35 | + uvicorn.run("ktrade.api.main:app", host=host or s.api_host, port=port or s.api_port, reload=reload, log_level=s.log_level.lower(), proxy_headers=True) | |
| 36 | + | |
| 37 | + | |
| 38 | +@app.command() | |
| 39 | +def worker() -> None: | |
| 40 | + """Run the market-data ingestion worker (pairs, OHLC accumulation, WebSocket stream).""" | |
| 41 | + from ktrade.workers.ingest import run_worker | |
| 42 | + | |
| 43 | + s = get_settings() | |
| 44 | + configure_logging(s.log_level, s.log_json) | |
| 45 | + asyncio.run(run_worker(s)) | |
| 46 | + | |
| 47 | + | |
| 48 | +@app.command() | |
| 49 | +def backfill() -> None: | |
| 50 | + """One-shot pair refresh + OHLC poll for the universe.""" | |
| 51 | + from ktrade.db.pool import create_pool | |
| 52 | + from ktrade.ingestion.backfill import backfill_all | |
| 53 | + from ktrade.ingestion.pairs import refresh_pairs | |
| 54 | + from ktrade.kraken.rest import KrakenREST | |
| 55 | + | |
| 56 | + s = get_settings() | |
| 57 | + configure_logging(s.log_level, False) | |
| 58 | + | |
| 59 | + async def _go(): | |
| 60 | + pool = await create_pool(s.database_url) | |
| 61 | + rest = KrakenREST(s.kraken_rest_url, tier=s.kraken_tier) | |
| 62 | + pairs = await refresh_pairs(rest, pool, s.universe) | |
| 63 | + res = await backfill_all(rest, pool, pairs, s.candle_intervals) | |
| 64 | + await rest.aclose() | |
| 65 | + await pool.close() | |
| 66 | + return res | |
| 67 | + | |
| 68 | + typer.echo(json.dumps(asyncio.run(_go()), indent=1)) | |
| 69 | + | |
| 70 | + | |
| 71 | +@app.command("set-owner-password") | |
| 72 | +def set_owner_password(password: str | None = typer.Option(None, help="omit to be prompted")) -> None: | |
| 73 | + """Print the bcrypt hash to put in KTRADE_OWNER_PASSWORD_HASH (never stores the password).""" | |
| 74 | + from ktrade.api.auth import hash_password | |
| 75 | + | |
| 76 | + pw = password or getpass.getpass("owner password: ") | |
| 77 | + typer.echo(hash_password(pw)) | |
| 78 | + | |
| 79 | + | |
| 80 | +@app.command() | |
| 81 | +def preflight() -> None: | |
| 82 | + """Probe configured Kraken keys (read endpoints + validate-only order). Never places orders.""" | |
| 83 | + import redis.asyncio as aioredis | |
| 84 | + | |
| 85 | + from ktrade.api.deps import AppState | |
| 86 | + from ktrade.db.pool import create_pool | |
| 87 | + from ktrade.execution.preflight import run_preflight | |
| 88 | + from ktrade.kraken.rest import KrakenREST | |
| 89 | + | |
| 90 | + s = get_settings() | |
| 91 | + configure_logging(s.log_level, False) | |
| 92 | + | |
| 93 | + async def _go(): | |
| 94 | + pool = await create_pool(s.database_url) | |
| 95 | + st = AppState(settings=s, pool=pool, redis=aioredis.from_url(s.redis_url), rest=KrakenREST(s.kraken_rest_url, s.kraken_api_key, s.kraken_api_secret, s.kraken_tier)) | |
| 96 | + try: | |
| 97 | + return await run_preflight(st) | |
| 98 | + finally: | |
| 99 | + await st.rest.aclose() | |
| 100 | + await pool.close() | |
| 101 | + | |
| 102 | + typer.echo(json.dumps(asyncio.run(_go()), indent=1, default=str)) | |
| 103 | + | |
| 104 | + | |
| 105 | +@app.command() | |
| 106 | +def backtest(strategy: str, symbol: str = "BTC/USD", interval: int = 60, params: str = "{}", holdout: float = 0.2, cash: float = 10000.0) -> None: | |
| 107 | + """Run a backtest from the CLI and print the metrics (stored in backtest_runs).""" | |
| 108 | + from ktrade.backtest.runner import execute_backtest | |
| 109 | + from ktrade.db.pool import create_pool | |
| 110 | + | |
| 111 | + s = get_settings() | |
| 112 | + configure_logging(s.log_level, False) | |
| 113 | + run_id = uuid.uuid4() | |
| 114 | + spec = {"strategy": strategy, "symbol": symbol, "interval_min": interval, "params": json.loads(params), "holdout_frac": holdout, "initial_cash": cash, "stress": True} | |
| 115 | + | |
| 116 | + async def _go(): | |
| 117 | + pool = await create_pool(s.database_url) | |
| 118 | + await pool.execute("INSERT INTO backtest_runs (id, strategy, symbol, interval_min, params, label) VALUES ($1,$2,$3,$4,$5,'cli')", run_id, strategy, symbol, interval, spec["params"]) | |
| 119 | + await execute_backtest(pool, run_id, spec) | |
| 120 | + row = await pool.fetchrow("SELECT metrics, status, error FROM backtest_runs WHERE id=$1", run_id) | |
| 121 | + await pool.close() | |
| 122 | + return dict(row) | |
| 123 | + | |
| 124 | + out = asyncio.run(_go()) | |
| 125 | + typer.echo(f"run {run_id}: {out['status']} {out.get('error') or ''}") | |
| 126 | + typer.echo(json.dumps(out["metrics"], indent=1, default=str)) | |
| 127 | + | |
| 128 | + | |
| 129 | +@app.command() | |
| 130 | +def reproduce(run_id: str) -> None: | |
| 131 | + """Re-run a stored backtest from its provenance (same window, params, costs, seed) and compare metrics.""" | |
| 132 | + import math | |
| 133 | + | |
| 134 | + from ktrade.backtest.costs import CostModel | |
| 135 | + from ktrade.backtest.engine import run_backtest | |
| 136 | + from ktrade.backtest.metrics import compute_metrics | |
| 137 | + from ktrade.backtest.runner import dataset_hash | |
| 138 | + from ktrade.db.pool import create_pool | |
| 139 | + from ktrade.market.service import candles_frame | |
| 140 | + from ktrade.risk.gateway import RiskLimits | |
| 141 | + from ktrade.strategy.registry import get_strategy | |
| 142 | + | |
| 143 | + s = get_settings() | |
| 144 | + | |
| 145 | + async def _go(): | |
| 146 | + pool = await create_pool(s.database_url) | |
| 147 | + try: | |
| 148 | + r = await pool.fetchrow("SELECT strategy, symbol, interval_min, metrics, provenance FROM backtest_runs WHERE id=$1 AND status='done'", uuid.UUID(run_id)) | |
| 149 | + if not r: | |
| 150 | + raise typer.BadParameter("run not found or not done") | |
| 151 | + pv = r["provenance"] | |
| 152 | + df = await candles_frame(pool, r["symbol"], r["interval_min"], pv["window"][0], pv["window"][1]) | |
| 153 | + pair = await pool.fetchrow("SELECT lot_decimals, ordermin, costmin FROM pairs WHERE wsname=$1", r["symbol"]) | |
| 154 | + same_data = dataset_hash(df) == pv["dataset_hash"] | |
| 155 | + res = run_backtest(get_strategy(r["strategy"])(pv["params"]), df, interval_min=r["interval_min"], initial_cash=pv["initial_cash"], cost_model=CostModel(**pv["cost_model"]), | |
| 156 | + risk_limits=RiskLimits(**pv["risk_limits"]) if pv.get("risk_limits") else None, | |
| 157 | + lot_decimals=pair["lot_decimals"] if pair else 8, ordermin=float(pair["ordermin"]) if pair else 0.0, costmin=float(pair["costmin"]) if pair else 0.0) | |
| 158 | + m = compute_metrics(res.equity, res.trades, r["interval_min"], pv["initial_cash"], res.fees_paid, res.slippage_paid, res.turnover) | |
| 159 | + return same_data, r["metrics"], m | |
| 160 | + finally: | |
| 161 | + await pool.close() | |
| 162 | + | |
| 163 | + same_data, old, new = asyncio.run(_go()) | |
| 164 | + typer.echo(f"dataset hash identical: {same_data}") | |
| 165 | + ok = True | |
| 166 | + for k in ("total_return", "sharpe", "max_drawdown", "n_trades", "fees_paid"): | |
| 167 | + a, b = old.get(k), new.get(k) | |
| 168 | + match = (a == b) if isinstance(a, int) else (a is not None and b is not None and math.isclose(a, b, rel_tol=1e-9, abs_tol=1e-9)) | |
| 169 | + ok &= bool(match) | |
| 170 | + typer.echo(f" {k:14s} stored={a!r:<24} rerun={b!r:<24} {'OK' if match else 'MISMATCH'}") | |
| 171 | + typer.echo("REPRODUCIBLE" if ok and same_data else "NOT REPRODUCIBLE (data changed or code drift)") | |
| 172 | + raise typer.Exit(0 if ok and same_data else 1) | |
| 173 | + | |
| 174 | + | |
| 175 | +@app.command("audit-verify") | |
| 176 | +def audit_verify() -> None: | |
| 177 | + from ktrade.db.audit import verify_chain | |
| 178 | + from ktrade.db.pool import create_pool | |
| 179 | + | |
| 180 | + s = get_settings() | |
| 181 | + | |
| 182 | + async def _go(): | |
| 183 | + pool = await create_pool(s.database_url) | |
| 184 | + try: | |
| 185 | + return await verify_chain(pool) | |
| 186 | + finally: | |
| 187 | + await pool.close() | |
| 188 | + | |
| 189 | + ok, n = asyncio.run(_go()) | |
| 190 | + typer.echo(f"audit chain valid={ok} entries={n}") | |
| 191 | + raise typer.Exit(0 if ok else 1) | |
| 192 | + | |
| 193 | + | |
| 194 | +if __name__ == "__main__": | |
| 195 | + app() | |
added
apps/api/ktrade/config.py
+79 −0
@@ -0,0 +1,79 @@ | ||
| 1 | +"""Runtime configuration (environment-injected; never log secrets).""" | |
| 2 | + | |
| 3 | +from __future__ import annotations | |
| 4 | + | |
| 5 | +from functools import lru_cache | |
| 6 | +from typing import Annotated, Literal | |
| 7 | + | |
| 8 | +from pydantic import Field, field_validator | |
| 9 | +from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict | |
| 10 | + | |
| 11 | +TradingMode = Literal["offline", "paper", "live"] | |
| 12 | + | |
| 13 | + | |
| 14 | +class Settings(BaseSettings): | |
| 15 | + model_config = SettingsConfigDict(env_prefix="KTRADE_", env_file=".env", extra="ignore") | |
| 16 | + | |
| 17 | + env: str = "dev" | |
| 18 | + app_base_url: str = "http://127.0.0.1:8471" | |
| 19 | + api_host: str = "127.0.0.1" | |
| 20 | + api_port: int = 8470 | |
| 21 | + log_level: str = "INFO" | |
| 22 | + log_json: bool = True | |
| 23 | + | |
| 24 | + database_url: str = "postgresql+asyncpg://ktrade:ktrade@127.0.0.1:5432/ktrade" | |
| 25 | + redis_url: str = "redis://127.0.0.1:6379/3" | |
| 26 | + | |
| 27 | + # Session / auth | |
| 28 | + secret_key: str = Field(default="dev-insecure-change-me", description="Signs session cookies") | |
| 29 | + owner_password_hash: str = Field(default="", description="bcrypt hash of the owner password") | |
| 30 | + session_ttl_seconds: int = 12 * 3600 | |
| 31 | + cookie_secure: bool = False | |
| 32 | + | |
| 33 | + # Kraken — three least-privilege key pairs. Data/account keys must NOT have trade permission; | |
| 34 | + # the trade key is only read when live mode is armed. Withdrawal permission is never used. | |
| 35 | + kraken_rest_url: str = "https://api.kraken.com" | |
| 36 | + kraken_ws_url: str = "wss://ws.kraken.com/v2" | |
| 37 | + kraken_ws_auth_url: str = "wss://ws-auth.kraken.com/v2" | |
| 38 | + kraken_api_key: str = "" | |
| 39 | + kraken_api_secret: str = "" | |
| 40 | + kraken_trade_api_key: str = "" | |
| 41 | + kraken_trade_api_secret: str = "" | |
| 42 | + kraken_tier: Literal["starter", "intermediate", "pro"] = "starter" | |
| 43 | + | |
| 44 | + # Universe & ingestion | |
| 45 | + universe: Annotated[list[str], NoDecode] = Field(default_factory=lambda: ["BTC/USD", "ETH/USD", "SOL/USD", "XRP/USD", "ADA/USD", "LINK/USD", "DOT/USD", "LTC/USD"]) | |
| 46 | + candle_intervals: Annotated[list[int], NoDecode] = Field(default_factory=lambda: [1, 5, 60, 1440]) | |
| 47 | + backfill_poll_seconds: int = 60 | |
| 48 | + book_depth: int = 25 | |
| 49 | + stale_data_seconds: int = 120 | |
| 50 | + | |
| 51 | + # Trading mode gates | |
| 52 | + trading_mode: TradingMode = "offline" | |
| 53 | + live_trading_enabled: bool = False # feature flag; UI activation also required (Phase 5) | |
| 54 | + | |
| 55 | + # Observability | |
| 56 | + metrics_enabled: bool = True | |
| 57 | + | |
| 58 | + @field_validator("universe", "candle_intervals", mode="before") | |
| 59 | + @classmethod | |
| 60 | + def _split_csv(cls, v): # noqa: ANN001 | |
| 61 | + if isinstance(v, str): | |
| 62 | + parts = [p.strip() for p in v.split(",") if p.strip()] | |
| 63 | + return [int(p) if p.isdigit() else p for p in parts] | |
| 64 | + return v | |
| 65 | + | |
| 66 | + @property | |
| 67 | + def sync_database_url(self) -> str: | |
| 68 | + return self.database_url.replace("+asyncpg", "") | |
| 69 | + | |
| 70 | + def has_data_key(self) -> bool: | |
| 71 | + return bool(self.kraken_api_key and self.kraken_api_secret) | |
| 72 | + | |
| 73 | + def has_trade_key(self) -> bool: | |
| 74 | + return bool(self.kraken_trade_api_key and self.kraken_trade_api_secret) | |
| 75 | + | |
| 76 | + | |
| 77 | +@lru_cache | |
| 78 | +def get_settings() -> Settings: | |
| 79 | + return Settings() | |
added
apps/api/ktrade/db/__init__.py
+0 −0
added
apps/api/ktrade/db/audit.py
+37 −0
@@ -0,0 +1,37 @@ | ||
| 1 | +"""Immutable, hash-chained audit trail. Every risk decision, mode change, order and kill-switch flip goes here.""" | |
| 2 | + | |
| 3 | +from __future__ import annotations | |
| 4 | + | |
| 5 | +import hashlib | |
| 6 | +import json | |
| 7 | +from typing import Any | |
| 8 | + | |
| 9 | +import asyncpg | |
| 10 | + | |
| 11 | +from ktrade.logging import correlation_id | |
| 12 | + | |
| 13 | + | |
| 14 | +def _hash(prev: str | None, actor: str, action: str, entity: str | None, details: dict[str, Any]) -> str: | |
| 15 | + payload = json.dumps({"p": prev, "a": actor, "x": action, "e": entity, "d": details}, sort_keys=True, default=str) | |
| 16 | + return hashlib.sha256(payload.encode()).hexdigest() | |
| 17 | + | |
| 18 | + | |
| 19 | +async def audit(pool: asyncpg.Pool, actor: str, action: str, entity: str | None = None, details: dict[str, Any] | None = None) -> int: | |
| 20 | + details = details or {} | |
| 21 | + async with pool.acquire() as conn, conn.transaction(): | |
| 22 | + prev = await conn.fetchval("SELECT hash FROM audit_log ORDER BY id DESC LIMIT 1 FOR UPDATE") | |
| 23 | + h = _hash(prev, actor, action, entity, details) | |
| 24 | + return await conn.fetchval( | |
| 25 | + "INSERT INTO audit_log (actor, action, entity, details, cid, prev_hash, hash) VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING id", | |
| 26 | + actor, action, entity, json.loads(json.dumps(details, default=str)), correlation_id.get(), prev, h, | |
| 27 | + ) | |
| 28 | + | |
| 29 | + | |
| 30 | +async def verify_chain(pool: asyncpg.Pool, limit: int = 10000) -> tuple[bool, int]: | |
| 31 | + rows = await pool.fetch("SELECT actor, action, entity, details, prev_hash, hash FROM audit_log ORDER BY id ASC LIMIT $1", limit) | |
| 32 | + prev = None | |
| 33 | + for i, r in enumerate(rows): | |
| 34 | + if r["prev_hash"] != prev or _hash(prev, r["actor"], r["action"], r["entity"], r["details"]) != r["hash"]: | |
| 35 | + return False, i | |
| 36 | + prev = r["hash"] | |
| 37 | + return True, len(rows) | |
added
apps/api/ktrade/db/pool.py
+82 −0
@@ -0,0 +1,82 @@ | ||
| 1 | +"""asyncpg pool + tiny migrator (plain SQL files in migrations/, tracked in schema_migrations).""" | |
| 2 | + | |
| 3 | +from __future__ import annotations | |
| 4 | + | |
| 5 | +import json | |
| 6 | +import re | |
| 7 | +from pathlib import Path | |
| 8 | +from typing import Any | |
| 9 | + | |
| 10 | +import asyncpg | |
| 11 | + | |
| 12 | +from ktrade.logging import get_logger | |
| 13 | + | |
| 14 | +log = get_logger("db") | |
| 15 | +MIGRATIONS_DIR = Path(__file__).resolve().parents[2] / "migrations" | |
| 16 | + | |
| 17 | + | |
| 18 | +def dsn(url: str) -> str: | |
| 19 | + return re.sub(r"^postgresql\+\w+://", "postgresql://", url) | |
| 20 | + | |
| 21 | + | |
| 22 | +def _json_default(o: Any) -> Any: | |
| 23 | + import datetime as _dt | |
| 24 | + import decimal | |
| 25 | + import uuid | |
| 26 | + | |
| 27 | + import numpy as np | |
| 28 | + | |
| 29 | + if isinstance(o, np.bool_): | |
| 30 | + return bool(o) | |
| 31 | + if isinstance(o, np.integer): | |
| 32 | + return int(o) | |
| 33 | + if isinstance(o, np.floating): | |
| 34 | + v = float(o) | |
| 35 | + return v if v == v and v not in (float("inf"), float("-inf")) else None | |
| 36 | + if isinstance(o, np.ndarray): | |
| 37 | + return o.tolist() | |
| 38 | + if isinstance(o, decimal.Decimal | uuid.UUID): | |
| 39 | + return str(o) | |
| 40 | + if isinstance(o, _dt.datetime | _dt.date): | |
| 41 | + return o.isoformat() | |
| 42 | + raise TypeError(f"not JSON serializable: {type(o).__name__}") | |
| 43 | + | |
| 44 | + | |
| 45 | +def json_dumps(o: Any) -> str: | |
| 46 | + return json.dumps(o, default=_json_default, allow_nan=False) | |
| 47 | + | |
| 48 | + | |
| 49 | +async def _init_conn(conn: asyncpg.Connection) -> None: | |
| 50 | + await conn.set_type_codec("jsonb", encoder=json_dumps, decoder=json.loads, schema="pg_catalog") | |
| 51 | + await conn.set_type_codec("json", encoder=json_dumps, decoder=json.loads, schema="pg_catalog") | |
| 52 | + | |
| 53 | + | |
| 54 | +async def create_pool(url: str, min_size: int = 1, max_size: int = 10) -> asyncpg.Pool: | |
| 55 | + return await asyncpg.create_pool(dsn(url), min_size=min_size, max_size=max_size, init=_init_conn, command_timeout=60) | |
| 56 | + | |
| 57 | + | |
| 58 | +async def migrate(url: str) -> list[str]: | |
| 59 | + applied: list[str] = [] | |
| 60 | + conn = await asyncpg.connect(dsn(url)) | |
| 61 | + try: | |
| 62 | + await conn.execute("CREATE TABLE IF NOT EXISTS schema_migrations (version TEXT PRIMARY KEY, applied_at TIMESTAMPTZ NOT NULL DEFAULT now())") | |
| 63 | + done = {r["version"] for r in await conn.fetch("SELECT version FROM schema_migrations")} | |
| 64 | + for f in sorted(MIGRATIONS_DIR.glob("*.sql")): | |
| 65 | + if f.stem in done: | |
| 66 | + continue | |
| 67 | + async with conn.transaction(): | |
| 68 | + await conn.execute(f.read_text()) | |
| 69 | + await conn.execute("INSERT INTO schema_migrations (version) VALUES ($1)", f.stem) | |
| 70 | + applied.append(f.stem) | |
| 71 | + log.info("db.migrated", version=f.stem) | |
| 72 | + finally: | |
| 73 | + await conn.close() | |
| 74 | + return applied | |
| 75 | + | |
| 76 | + | |
| 77 | +async def has_timescale(pool: asyncpg.Pool) -> bool: | |
| 78 | + return bool(await pool.fetchval("SELECT EXISTS (SELECT 1 FROM pg_extension WHERE extname='timescaledb')")) | |
| 79 | + | |
| 80 | + | |
| 81 | +def rows_to_dicts(rows: list[asyncpg.Record]) -> list[dict[str, Any]]: | |
| 82 | + return [dict(r) for r in rows] | |
added
apps/api/ktrade/execution/__init__.py
+0 −0
added
apps/api/ktrade/execution/preflight.py
+61 −0
@@ -0,0 +1,61 @@ | ||
| 1 | +"""Credential preflight: discover what the configured keys can do without ever placing a live order.""" | |
| 2 | + | |
| 3 | +from __future__ import annotations | |
| 4 | + | |
| 5 | +import time | |
| 6 | +from decimal import Decimal | |
| 7 | +from typing import Any | |
| 8 | + | |
| 9 | +from ktrade.api.deps import AppState | |
| 10 | +from ktrade.kraken.rest import KrakenAPIError, KrakenREST | |
| 11 | + | |
| 12 | + | |
| 13 | +async def _probe(rest: KrakenREST, name: str, coro) -> dict[str, Any]: # noqa: ANN001 | |
| 14 | + try: | |
| 15 | + await coro | |
| 16 | + return {"ok": True} | |
| 17 | + except KrakenAPIError as e: | |
| 18 | + return {"ok": False, "permission_denied": e.is_permission, "error": str(e)} | |
| 19 | + except Exception as e: # noqa: BLE001 | |
| 20 | + return {"ok": False, "error": str(e)} | |
| 21 | + | |
| 22 | + | |
| 23 | +async def run_preflight(st: AppState) -> dict[str, Any]: | |
| 24 | + out: dict[str, Any] = {"checked_at": time.time(), "data_key": None, "trade_key": None} | |
| 25 | + s = st.settings | |
| 26 | + if s.has_data_key(): | |
| 27 | + rest = st.rest | |
| 28 | + d: dict[str, Any] = {} | |
| 29 | + d["balance"] = await _probe(rest, "balance", rest.balance()) | |
| 30 | + d["open_orders"] = await _probe(rest, "open_orders", rest.open_orders()) | |
| 31 | + d["trade_volume"] = await _probe(rest, "trade_volume", rest.trade_volume(["XXBTZUSD"])) | |
| 32 | + d["ws_token"] = await _probe(rest, "ws_token", rest.websockets_token()) | |
| 33 | + # Trade-permission probe: validate-only order that could never execute (price = 10% of market, min size, | |
| 34 | + # expires in 5 s). The data key SHOULD fail this with Permission denied (least privilege). | |
| 35 | + d["trade_permission"] = await _trade_probe(rest) | |
| 36 | + if d["trade_permission"].get("has_trade_permission"): | |
| 37 | + d["warning"] = "The data/account key has order permission — least-privilege violated; create a read-only key for data." | |
| 38 | + out["data_key"] = d | |
| 39 | + if s.has_trade_key(): | |
| 40 | + trade_rest = KrakenREST(s.kraken_rest_url, s.kraken_trade_api_key, s.kraken_trade_api_secret, s.kraken_tier) | |
| 41 | + try: | |
| 42 | + out["trade_key"] = {"trade_permission": await _trade_probe(trade_rest), "note": "live mode is disabled (feature flag + UI activation required)"} | |
| 43 | + finally: | |
| 44 | + await trade_rest.aclose() | |
| 45 | + return out | |
| 46 | + | |
| 47 | + | |
| 48 | +async def _trade_probe(rest: KrakenREST) -> dict[str, Any]: | |
| 49 | + try: | |
| 50 | + tick = await rest.ticker(["XXBTZUSD"]) | |
| 51 | + last = Decimal(next(iter(tick.values()))["c"][0]) | |
| 52 | + price = (last * Decimal("0.10")).quantize(Decimal("0.1")) | |
| 53 | + r = await rest.add_order(pair="XXBTZUSD", side="buy", ordertype="limit", volume="0.0001", price=str(price), timeinforce="GTD", validate=True) | |
| 54 | + return {"has_trade_permission": True, "validated": r.get("descr")} | |
| 55 | + except KrakenAPIError as e: | |
| 56 | + if e.is_permission: | |
| 57 | + return {"has_trade_permission": False, "error": str(e)} | |
| 58 | + # any other validation error means the permission check passed | |
| 59 | + return {"has_trade_permission": True, "error": str(e)} | |
| 60 | + except Exception as e: # noqa: BLE001 | |
| 61 | + return {"has_trade_permission": None, "error": str(e)} | |
added
apps/api/ktrade/ingestion/__init__.py
+0 −0
added
apps/api/ktrade/ingestion/backfill.py
+88 −0
@@ -0,0 +1,88 @@ | ||
| 1 | +"""Incremental OHLC accumulation. | |
| 2 | + | |
| 3 | +Kraken REST /public/OHLC returns at most the 720 most recent bars per (pair, interval) — older data cannot | |
| 4 | +be retrieved from the exchange, so history is *accumulated* by polling: each poll stores closed bars, and the | |
| 5 | +still-forming last bar is stored with closed=false (and overwritten on the next poll). Coverage therefore grows | |
| 6 | +from first deployment onward (720 × interval backwards from the first run). Gaps are detected and recorded. | |
| 7 | +""" | |
| 8 | + | |
| 9 | +from __future__ import annotations | |
| 10 | + | |
| 11 | +import time | |
| 12 | + | |
| 13 | +import asyncpg | |
| 14 | + | |
| 15 | +from ktrade.kraken.models import PairMeta | |
| 16 | +from ktrade.kraken.rest import KrakenREST | |
| 17 | +from ktrade.logging import get_logger | |
| 18 | + | |
| 19 | +log = get_logger("ingest.backfill") | |
| 20 | + | |
| 21 | + | |
| 22 | +async def upsert_candles(conn: asyncpg.Connection, symbol: str, interval: int, rows: list[tuple], source: str) -> int: | |
| 23 | + if not rows: | |
| 24 | + return 0 | |
| 25 | + await conn.executemany( | |
| 26 | + """INSERT INTO candles (symbol, interval_min, ts, open, high, low, close, vwap, volume, count, closed, source, ingested_at) | |
| 27 | + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12, now()) | |
| 28 | + ON CONFLICT (symbol, interval_min, ts) DO UPDATE SET | |
| 29 | + open=EXCLUDED.open, high=EXCLUDED.high, low=EXCLUDED.low, close=EXCLUDED.close, vwap=EXCLUDED.vwap, | |
| 30 | + volume=EXCLUDED.volume, count=EXCLUDED.count, closed=EXCLUDED.closed OR candles.closed, source=EXCLUDED.source, ingested_at=now() | |
| 31 | + WHERE candles.closed = false OR EXCLUDED.closed = true""", | |
| 32 | + [(symbol, interval, *r, source) for r in rows], | |
| 33 | + ) | |
| 34 | + return len(rows) | |
| 35 | + | |
| 36 | + | |
| 37 | +async def backfill_pair(rest: KrakenREST, pool: asyncpg.Pool, pair: PairMeta, interval: int) -> int: | |
| 38 | + key = f"ohlc:{pair.wsname}:{interval}" | |
| 39 | + state = await pool.fetchval("SELECT value FROM ingestion_state WHERE key=$1", key) | |
| 40 | + since = int(state["last"]) if state and state.get("last") else None | |
| 41 | + candles, last = await rest.ohlc(pair.pair_id, interval, since) | |
| 42 | + if not candles: | |
| 43 | + return 0 | |
| 44 | + now = int(time.time()) | |
| 45 | + rows = [] | |
| 46 | + for c in candles: | |
| 47 | + closed = c.closed_at(interval) <= now | |
| 48 | + rows.append((c.ts, c.open, c.high, c.low, c.close, c.vwap, c.volume, c.count, closed)) | |
| 49 | + async with pool.acquire() as conn, conn.transaction(): | |
| 50 | + n = await upsert_candles(conn, pair.wsname, interval, rows, "rest") | |
| 51 | + await conn.execute( | |
| 52 | + "INSERT INTO ingestion_state (key, value, updated_at) VALUES ($1, $2, now()) ON CONFLICT (key) DO UPDATE SET value=EXCLUDED.value, updated_at=now()", | |
| 53 | + key, {"last": last, "polled_at": now, "rows": n, "first_ts": min(r[0] for r in rows)}, | |
| 54 | + ) | |
| 55 | + return n | |
| 56 | + | |
| 57 | + | |
| 58 | +async def detect_gaps(pool: asyncpg.Pool, symbol: str, interval: int) -> int: | |
| 59 | + """Record missing bars between consecutive stored bars. Kraken omits bars with zero trades on illiquid pairs, | |
| 60 | + so a 'gap' may be genuine inactivity — they are reported, not silently filled.""" | |
| 61 | + step = interval * 60 | |
| 62 | + rows = await pool.fetch( | |
| 63 | + """WITH s AS (SELECT ts, lag(ts) OVER (ORDER BY ts) AS prev FROM candles WHERE symbol=$1 AND interval_min=$2 AND closed) | |
| 64 | + SELECT prev, ts FROM s WHERE prev IS NOT NULL AND ts - prev > $3""", | |
| 65 | + symbol, interval, step, | |
| 66 | + ) | |
| 67 | + n = 0 | |
| 68 | + async with pool.acquire() as conn: | |
| 69 | + for r in rows: | |
| 70 | + missing = (r["ts"] - r["prev"]) // step - 1 | |
| 71 | + await conn.execute( | |
| 72 | + "INSERT INTO data_gaps (symbol, interval_min, gap_start, gap_end, missing_bars) VALUES ($1,$2,$3,$4,$5) ON CONFLICT (symbol, interval_min, gap_start) DO UPDATE SET gap_end=EXCLUDED.gap_end, missing_bars=EXCLUDED.missing_bars", | |
| 73 | + symbol, interval, r["prev"] + step, r["ts"] - step, int(missing), | |
| 74 | + ) | |
| 75 | + n += 1 | |
| 76 | + return n | |
| 77 | + | |
| 78 | + | |
| 79 | +async def backfill_all(rest: KrakenREST, pool: asyncpg.Pool, pairs: dict[str, PairMeta], intervals: list[int]) -> dict[str, int]: | |
| 80 | + out: dict[str, int] = {} | |
| 81 | + for sym, p in pairs.items(): | |
| 82 | + for iv in intervals: | |
| 83 | + try: | |
| 84 | + out[f"{sym}:{iv}"] = await backfill_pair(rest, pool, p, iv) | |
| 85 | + await detect_gaps(pool, sym, iv) | |
| 86 | + except Exception as e: # noqa: BLE001 | |
| 87 | + log.warning("ingest.backfill.error", symbol=sym, interval=iv, error=str(e)) | |
| 88 | + return out | |
added
apps/api/ktrade/ingestion/pairs.py
+34 −0
@@ -0,0 +1,34 @@ | ||
| 1 | +"""Pair metadata discovery (AssetPairs) → `pairs` table. Capabilities are read from the exchange, never assumed.""" | |
| 2 | + | |
| 3 | +from __future__ import annotations | |
| 4 | + | |
| 5 | +import asyncpg | |
| 6 | + | |
| 7 | +from ktrade.kraken.models import PairMeta | |
| 8 | +from ktrade.kraken.rest import KrakenREST | |
| 9 | +from ktrade.logging import get_logger | |
| 10 | + | |
| 11 | +log = get_logger("ingest.pairs") | |
| 12 | + | |
| 13 | + | |
| 14 | +async def refresh_pairs(rest: KrakenREST, pool: asyncpg.Pool, universe: list[str]) -> dict[str, PairMeta]: | |
| 15 | + pairs = await rest.asset_pairs() | |
| 16 | + uni = set(universe) | |
| 17 | + async with pool.acquire() as conn, conn.transaction(): | |
| 18 | + for k, p in pairs.items(): | |
| 19 | + await conn.execute( | |
| 20 | + """INSERT INTO pairs (pair_id, altname, wsname, base, quote, status, ordermin, costmin, tick_size, pair_decimals, lot_decimals, cost_decimals, | |
| 21 | + leverage_buy, leverage_sell, in_universe, raw, updated_at) | |
| 22 | + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16, now()) | |
| 23 | + ON CONFLICT (pair_id) DO UPDATE SET altname=EXCLUDED.altname, wsname=EXCLUDED.wsname, base=EXCLUDED.base, quote=EXCLUDED.quote, | |
| 24 | + status=EXCLUDED.status, ordermin=EXCLUDED.ordermin, costmin=EXCLUDED.costmin, tick_size=EXCLUDED.tick_size, | |
| 25 | + pair_decimals=EXCLUDED.pair_decimals, lot_decimals=EXCLUDED.lot_decimals, cost_decimals=EXCLUDED.cost_decimals, | |
| 26 | + leverage_buy=EXCLUDED.leverage_buy, leverage_sell=EXCLUDED.leverage_sell, in_universe=EXCLUDED.in_universe, raw=EXCLUDED.raw, updated_at=now()""", | |
| 27 | + k, p.altname, p.wsname, p.base, p.quote, p.status, p.ordermin, p.costmin, p.tick_size, p.pair_decimals, p.lot_decimals, p.cost_decimals, | |
| 28 | + p.leverage_buy, p.leverage_sell, p.wsname in uni, p.model_dump(mode="json"), | |
| 29 | + ) | |
| 30 | + missing = uni - {p.wsname for p in pairs.values()} | |
| 31 | + if missing: | |
| 32 | + log.warning("ingest.pairs.universe_missing", symbols=sorted(missing)) | |
| 33 | + log.info("ingest.pairs.refreshed", count=len(pairs), universe=len(uni - missing)) | |
| 34 | + return {p.wsname: p for p in pairs.values() if p.wsname in uni} | |
added
apps/api/ktrade/ingestion/stream.py
+113 −0
@@ -0,0 +1,113 @@ | ||
| 1 | +"""WebSocket → Redis pub/sub (fan-out to API/SSE) + Postgres (latest ticker/book, trades, 1-s book samples, WS candles).""" | |
| 2 | + | |
| 3 | +from __future__ import annotations | |
| 4 | + | |
| 5 | +import asyncio | |
| 6 | +import json | |
| 7 | +import time | |
| 8 | +from typing import Any | |
| 9 | + | |
| 10 | +import asyncpg | |
| 11 | +import redis.asyncio as aioredis | |
| 12 | + | |
| 13 | +from ktrade.ingestion.backfill import upsert_candles | |
| 14 | +from ktrade.kraken.models import PairMeta | |
| 15 | +from ktrade.kraken.ws import KrakenWS | |
| 16 | +from ktrade.logging import get_logger | |
| 17 | + | |
| 18 | +log = get_logger("ingest.stream") | |
| 19 | +CHANNEL = "ktrade:md" | |
| 20 | + | |
| 21 | + | |
| 22 | +class StreamIngestor: | |
| 23 | + def __init__(self, pool: asyncpg.Pool, redis: aioredis.Redis, ws_url: str, pairs: dict[str, PairMeta], book_depth: int, ohlc_intervals: list[int]) -> None: | |
| 24 | + self.pool = pool | |
| 25 | + self.redis = redis | |
| 26 | + self.pairs = pairs | |
| 27 | + precision = {s: (p.pair_decimals, p.lot_decimals) for s, p in pairs.items()} | |
| 28 | + self.ws = KrakenWS(ws_url, list(pairs), self.on_event, book_depth=book_depth, ohlc_intervals=ohlc_intervals, precision=precision) | |
| 29 | + self._last_book_sample: dict[str, int] = {} | |
| 30 | + self._iso = None | |
| 31 | + self.counts: dict[str, int] = {} | |
| 32 | + | |
| 33 | + async def run(self) -> None: | |
| 34 | + await asyncio.gather(self.ws.run(), self._health_loop()) | |
| 35 | + | |
| 36 | + async def _health_loop(self) -> None: | |
| 37 | + while True: | |
| 38 | + await self.redis.set("ktrade:stream:health", json.dumps({ | |
| 39 | + "connected": self.ws.connected, "last_msg_age": round(time.monotonic() - self.ws.last_msg_at, 1) if self.ws.last_msg_at else None, | |
| 40 | + "reconnects": self.ws.reconnects, "checksum_failures": self.ws.checksum_failures, "messages": self.ws.messages, "counts": self.counts, "ts": time.time(), | |
| 41 | + }), ex=30) | |
| 42 | + await asyncio.sleep(5) | |
| 43 | + | |
| 44 | + async def on_event(self, channel: str, mtype: str, data: list[dict[str, Any]], received_at: float) -> None: | |
| 45 | + self.counts[channel] = self.counts.get(channel, 0) + 1 | |
| 46 | + await self.redis.publish(CHANNEL, json.dumps({"channel": channel, "type": mtype, "data": data, "received_at": received_at})) | |
| 47 | + try: | |
| 48 | + if channel == "ticker": | |
| 49 | + await self._store_ticker(data, received_at) | |
| 50 | + elif channel == "book": | |
| 51 | + await self._store_book(data, received_at) | |
| 52 | + elif channel == "trade": | |
| 53 | + await self._store_trades(data, received_at) | |
| 54 | + elif channel == "ohlc": | |
| 55 | + await self._store_ohlc(data) | |
| 56 | + except Exception as e: # noqa: BLE001 | |
| 57 | + log.warning("ingest.stream.store_error", channel=channel, error=str(e)) | |
| 58 | + | |
| 59 | + async def _store_ticker(self, data: list[dict[str, Any]], received_at: float) -> None: | |
| 60 | + async with self.pool.acquire() as conn: | |
| 61 | + for d in data: | |
| 62 | + await conn.execute( | |
| 63 | + "INSERT INTO ticker_latest (symbol, payload, exchange_ts, received_at) VALUES ($1,$2,$3,$4) ON CONFLICT (symbol) DO UPDATE SET payload=EXCLUDED.payload, exchange_ts=EXCLUDED.exchange_ts, received_at=EXCLUDED.received_at", | |
| 64 | + d["symbol"], d, received_at, received_at, | |
| 65 | + ) | |
| 66 | + | |
| 67 | + async def _store_book(self, data: list[dict[str, Any]], received_at: float) -> None: | |
| 68 | + now = int(received_at) | |
| 69 | + async with self.pool.acquire() as conn: | |
| 70 | + for d in data: | |
| 71 | + await conn.execute( | |
| 72 | + "INSERT INTO book_latest (symbol, payload, exchange_ts, received_at, valid) VALUES ($1,$2,$3,$4,$5) ON CONFLICT (symbol) DO UPDATE SET payload=EXCLUDED.payload, exchange_ts=EXCLUDED.exchange_ts, received_at=EXCLUDED.received_at, valid=EXCLUDED.valid", | |
| 73 | + d["symbol"], d, d.get("timestamp"), received_at, bool(d.get("valid", True)), | |
| 74 | + ) | |
| 75 | + if self._last_book_sample.get(d["symbol"]) != now and d.get("best_bid"): | |
| 76 | + self._last_book_sample[d["symbol"]] = now | |
| 77 | + await conn.execute( | |
| 78 | + "INSERT INTO book_samples (symbol, ts, best_bid, best_ask, spread_bps, bid_depth, ask_depth, imbalance) VALUES ($1,$2,$3,$4,$5,$6,$7,$8) ON CONFLICT DO NOTHING", | |
| 79 | + d["symbol"], now, d["best_bid"], d["best_ask"], d.get("spread_bps"), sum(q for _, q in d["bids"]), sum(q for _, q in d["asks"]), d.get("imbalance"), | |
| 80 | + ) | |
| 81 | + | |
| 82 | + async def _store_trades(self, data: list[dict[str, Any]], received_at: float) -> None: | |
| 83 | + rows = [] | |
| 84 | + for d in data: | |
| 85 | + ts = _parse_ts(d.get("timestamp")) | |
| 86 | + rows.append((d["symbol"], int(d["trade_id"]), ts, float(d["price"]), float(d["qty"]), d["side"], d.get("ord_type", "limit"), received_at)) | |
| 87 | + if rows: | |
| 88 | + async with self.pool.acquire() as conn: | |
| 89 | + await conn.executemany("INSERT INTO trades (symbol, trade_id, ts, price, volume, side, order_type, received_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8) ON CONFLICT DO NOTHING", rows) | |
| 90 | + | |
| 91 | + async def _store_ohlc(self, data: list[dict[str, Any]]) -> None: | |
| 92 | + now = time.time() | |
| 93 | + async with self.pool.acquire() as conn: | |
| 94 | + for d in data: | |
| 95 | + iv = int(d["interval"]) | |
| 96 | + ts = int(_parse_ts(d["interval_begin"])) | |
| 97 | + closed = ts + iv * 60 <= now | |
| 98 | + await upsert_candles(conn, d["symbol"], iv, [(ts, float(d["open"]), float(d["high"]), float(d["low"]), float(d["close"]), float(d.get("vwap", d["close"])), float(d["volume"]), int(d.get("trades", 0)), closed)], "ws") | |
| 99 | + | |
| 100 | + | |
| 101 | +def _parse_ts(s: str | None) -> float: | |
| 102 | + """RFC3339 with up to nanosecond precision → unix seconds (float).""" | |
| 103 | + if not s: | |
| 104 | + return time.time() | |
| 105 | + import re | |
| 106 | + from datetime import datetime | |
| 107 | + | |
| 108 | + m = re.match(r"^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})(?:\.(\d+))?(Z|[+-]\d{2}:\d{2})?$", s) | |
| 109 | + if not m: | |
| 110 | + return datetime.fromisoformat(s.replace("Z", "+00:00")).timestamp() | |
| 111 | + head, frac, tz = m.group(1), m.group(2) or "", m.group(3) or "Z" | |
| 112 | + tz = "+00:00" if tz == "Z" else tz | |
| 113 | + return datetime.fromisoformat(f"{head}.{frac[:6].ljust(6, '0')}{tz}").timestamp() | |
added
apps/api/ktrade/kraken/__init__.py
+1 −0
@@ -0,0 +1 @@ | ||
| 1 | +"""Kraken Spot REST + WebSocket v2 clients (verified against docs.kraken.com on 2026-10-21).""" | |
added
apps/api/ktrade/kraken/checksum.py
+32 −0
@@ -0,0 +1,32 @@ | ||
| 1 | +"""Order book checksum for WebSocket v2 (docs.kraken.com/exchange/guides/websockets/book-checksum-v2). | |
| 2 | + | |
| 3 | +Top 10 asks (low→high) then top 10 bids (high→low); each level encoded as | |
| 4 | +strip_leading_zeros(price without '.') + strip_leading_zeros(qty without '.'); CRC32 as unsigned int. | |
| 5 | +Prices/quantities must be kept as *strings or Decimal* with the exchange's precision. | |
| 6 | +""" | |
| 7 | + | |
| 8 | +from __future__ import annotations | |
| 9 | + | |
| 10 | +import zlib | |
| 11 | +from decimal import Decimal | |
| 12 | + | |
| 13 | + | |
| 14 | +def _enc(x: Decimal | str) -> str: | |
| 15 | + s = str(x) | |
| 16 | + if "E" in s or "e" in s: # Decimal may render exponent notation for tiny numbers | |
| 17 | + s = format(Decimal(s), "f") | |
| 18 | + return s.replace(".", "").lstrip("0") or "0" | |
| 19 | + | |
| 20 | + | |
| 21 | +def book_checksum(asks: list[tuple[Decimal | str, Decimal | str]], bids: list[tuple[Decimal | str, Decimal | str]]) -> int: | |
| 22 | + """asks/bids are (price, qty) with exchange precision already applied (price_decimals / qty_decimals).""" | |
| 23 | + a = sorted(asks, key=lambda lv: Decimal(str(lv[0])))[:10] | |
| 24 | + b = sorted(bids, key=lambda lv: Decimal(str(lv[0])), reverse=True)[:10] | |
| 25 | + s = "".join(_enc(p) + _enc(q) for p, q in a) + "".join(_enc(p) + _enc(q) for p, q in b) | |
| 26 | + return zlib.crc32(s.encode()) & 0xFFFFFFFF | |
| 27 | + | |
| 28 | + | |
| 29 | +def fmt_fixed(x: Decimal, decimals: int) -> str: | |
| 30 | + """Format with exactly `decimals` decimals (what Kraken uses in the checksum string).""" | |
| 31 | + q = Decimal(1).scaleb(-decimals) | |
| 32 | + return format(x.quantize(q), "f") | |
added
apps/api/ktrade/kraken/models.py
+122 −0
@@ -0,0 +1,122 @@ | ||
| 1 | +from __future__ import annotations | |
| 2 | + | |
| 3 | +from decimal import Decimal | |
| 4 | +from typing import Literal | |
| 5 | + | |
| 6 | +from pydantic import BaseModel, Field | |
| 7 | + | |
| 8 | +# REST AssetPairs still reports v1 names (XBT/USD, XDG/USD); WebSocket v2 uses BTC/USD, DOGE/USD. | |
| 9 | +_V2_RENAMES = {"XBT": "BTC", "XDG": "DOGE"} | |
| 10 | + | |
| 11 | + | |
| 12 | +def normalize_symbol(wsname: str) -> str: | |
| 13 | + base, _, quote = wsname.partition("/") | |
| 14 | + return f"{_V2_RENAMES.get(base, base)}/{_V2_RENAMES.get(quote, quote)}" if quote else wsname | |
| 15 | + | |
| 16 | + | |
| 17 | +PairStatus = Literal["online", "cancel_only", "post_only", "limit_only", "reduce_only", "unknown"] | |
| 18 | + | |
| 19 | + | |
| 20 | +class PairMeta(BaseModel): | |
| 21 | + """Tradable pair metadata from /0/public/AssetPairs. Exchange capabilities are *discovered*, not assumed.""" | |
| 22 | + | |
| 23 | + pair_id: str = Field(description="Kraken internal key, e.g. XXBTZUSD") | |
| 24 | + altname: str | |
| 25 | + wsname: str = Field(description="WebSocket v2 symbol, e.g. BTC/USD") | |
| 26 | + base: str | |
| 27 | + quote: str | |
| 28 | + status: PairStatus = "unknown" | |
| 29 | + ordermin: Decimal = Decimal("0") | |
| 30 | + costmin: Decimal = Decimal("0") | |
| 31 | + tick_size: Decimal = Decimal("0") | |
| 32 | + pair_decimals: int = 8 | |
| 33 | + lot_decimals: int = 8 | |
| 34 | + cost_decimals: int = 8 | |
| 35 | + lot_multiplier: int = 1 | |
| 36 | + leverage_buy: list[int] = Field(default_factory=list) | |
| 37 | + leverage_sell: list[int] = Field(default_factory=list) | |
| 38 | + long_position_limit: int | None = None | |
| 39 | + short_position_limit: int | None = None | |
| 40 | + aclass_base: str = "currency" | |
| 41 | + aclass_quote: str = "currency" | |
| 42 | + execution_venue: str | None = None | |
| 43 | + | |
| 44 | + @classmethod | |
| 45 | + def from_kraken(cls, key: str, d: dict) -> PairMeta: | |
| 46 | + return cls( | |
| 47 | + pair_id=key, | |
| 48 | + altname=d.get("altname", key), | |
| 49 | + wsname=normalize_symbol(d.get("wsname", d.get("altname", key))), | |
| 50 | + base=d.get("base", ""), | |
| 51 | + quote=d.get("quote", ""), | |
| 52 | + status=d.get("status", "unknown") if d.get("status") in ("online", "cancel_only", "post_only", "limit_only", "reduce_only") else "unknown", | |
| 53 | + ordermin=Decimal(str(d.get("ordermin", "0"))), | |
| 54 | + costmin=Decimal(str(d.get("costmin", "0"))), | |
| 55 | + tick_size=Decimal(str(d.get("tick_size", "0"))), | |
| 56 | + pair_decimals=int(d.get("pair_decimals", 8)), | |
| 57 | + lot_decimals=int(d.get("lot_decimals", 8)), | |
| 58 | + cost_decimals=int(d.get("cost_decimals", 8)), | |
| 59 | + lot_multiplier=int(d.get("lot_multiplier", 1)), | |
| 60 | + leverage_buy=[int(x) for x in d.get("leverage_buy", [])], | |
| 61 | + leverage_sell=[int(x) for x in d.get("leverage_sell", [])], | |
| 62 | + long_position_limit=d.get("long_position_limit"), | |
| 63 | + short_position_limit=d.get("short_position_limit"), | |
| 64 | + aclass_base=d.get("aclass_base", "currency"), | |
| 65 | + aclass_quote=d.get("aclass_quote", "currency"), | |
| 66 | + execution_venue=d.get("execution_venue"), | |
| 67 | + ) | |
| 68 | + | |
| 69 | + | |
| 70 | +class Candle(BaseModel): | |
| 71 | + """OHLC bar. `ts` is the bar OPEN time (UTC seconds). The bar is only *known* at ts + interval*60.""" | |
| 72 | + | |
| 73 | + ts: int | |
| 74 | + open: float | |
| 75 | + high: float | |
| 76 | + low: float | |
| 77 | + close: float | |
| 78 | + vwap: float | |
| 79 | + volume: float | |
| 80 | + count: int | |
| 81 | + | |
| 82 | + @property | |
| 83 | + def close_time(self) -> int: # requires interval; see Candle.closed_at | |
| 84 | + raise NotImplementedError | |
| 85 | + | |
| 86 | + def closed_at(self, interval_min: int) -> int: | |
| 87 | + return self.ts + interval_min * 60 | |
| 88 | + | |
| 89 | + | |
| 90 | +class Trade(BaseModel): | |
| 91 | + ts: float | |
| 92 | + price: float | |
| 93 | + volume: float | |
| 94 | + side: Literal["buy", "sell"] | |
| 95 | + order_type: Literal["market", "limit"] | |
| 96 | + trade_id: int | None = None | |
| 97 | + | |
| 98 | + | |
| 99 | +class BookLevel(BaseModel): | |
| 100 | + price: Decimal | |
| 101 | + qty: Decimal | |
| 102 | + | |
| 103 | + | |
| 104 | +class Ticker(BaseModel): | |
| 105 | + symbol: str | |
| 106 | + bid: float | |
| 107 | + bid_qty: float | |
| 108 | + ask: float | |
| 109 | + ask_qty: float | |
| 110 | + last: float | |
| 111 | + volume: float | |
| 112 | + vwap: float | |
| 113 | + low: float | |
| 114 | + high: float | |
| 115 | + change: float | |
| 116 | + change_pct: float | |
| 117 | + ts: float | |
| 118 | + | |
| 119 | + | |
| 120 | +class SystemStatus(BaseModel): | |
| 121 | + status: str | |
| 122 | + timestamp: str | |
added
apps/api/ktrade/kraken/rest.py
+278 −0
@@ -0,0 +1,278 @@ | ||
| 1 | +"""Kraken Spot REST client. | |
| 2 | + | |
| 3 | +Verified 2026-10-21 against https://docs.kraken.com/api/docs/guides/spot-rest-auth and | |
| 4 | +https://docs.kraken.com/api/docs/guides/spot-rest-ratelimits: | |
| 5 | + * base URL https://api.kraken.com/0 ; public GET /public/<Method> ; private POST /private/<Method> | |
| 6 | + * API-Sign = base64( HMAC-SHA512( path + SHA256(nonce + POSTDATA), base64decode(secret) ) ) | |
| 7 | + * nonce: strictly increasing uint64 per key (ms timestamp); private call counter 15/20/20 by tier, | |
| 8 | + decaying 0.33/0.5/1 per second; ledger/trade-history calls cost 2 points. | |
| 9 | + * /public/OHLC returns at most 720 most recent candles (older history must be accumulated incrementally | |
| 10 | + or sourced from a licensed dataset) — documented limitation. | |
| 11 | + * AssetPairs `fees`/`fees_maker` are deprecated (empty since 2026-09-08): use /private/TradeVolume. | |
| 12 | +""" | |
| 13 | + | |
| 14 | +from __future__ import annotations | |
| 15 | + | |
| 16 | +import asyncio | |
| 17 | +import base64 | |
| 18 | +import hashlib | |
| 19 | +import hmac | |
| 20 | +import time | |
| 21 | +import urllib.parse | |
| 22 | +from decimal import Decimal | |
| 23 | +from typing import Any | |
| 24 | + | |
| 25 | +import httpx | |
| 26 | + | |
| 27 | +from ktrade.kraken.models import Candle, PairMeta, SystemStatus, Trade | |
| 28 | +from ktrade.logging import get_logger | |
| 29 | + | |
| 30 | +log = get_logger("kraken.rest") | |
| 31 | + | |
| 32 | +OHLC_INTERVALS = (1, 5, 15, 30, 60, 240, 1440, 10080, 21600) | |
| 33 | +OHLC_MAX_ROWS = 720 | |
| 34 | +TIER_LIMITS = {"starter": (15, 0.33), "intermediate": (20, 0.5), "pro": (20, 1.0)} | |
| 35 | + | |
| 36 | + | |
| 37 | +class KrakenAPIError(Exception): | |
| 38 | + def __init__(self, errors: list[str], method: str): | |
| 39 | + super().__init__(f"{method}: {', '.join(errors)}") | |
| 40 | + self.errors = errors | |
| 41 | + self.method = method | |
| 42 | + | |
| 43 | + @property | |
| 44 | + def is_rate_limit(self) -> bool: | |
| 45 | + return any("Rate limit" in e or "Throttled" in e for e in self.errors) | |
| 46 | + | |
| 47 | + @property | |
| 48 | + def is_permission(self) -> bool: | |
| 49 | + return any("Permission denied" in e for e in self.errors) | |
| 50 | + | |
| 51 | + @property | |
| 52 | + def is_nonce(self) -> bool: | |
| 53 | + return any("Invalid nonce" in e for e in self.errors) | |
| 54 | + | |
| 55 | + | |
| 56 | +def sign(path: str, data: dict[str, Any] | str, secret: str, nonce: str | None = None) -> str: | |
| 57 | + """Kraken signature. `data` is either a dict (form-urlencoded, must contain nonce) or the raw JSON body.""" | |
| 58 | + if isinstance(data, dict): | |
| 59 | + nonce = str(data["nonce"]) | |
| 60 | + postdata = urllib.parse.urlencode(data) | |
| 61 | + else: | |
| 62 | + assert nonce is not None | |
| 63 | + postdata = data | |
| 64 | + encoded = (nonce + postdata).encode() | |
| 65 | + message = path.encode() + hashlib.sha256(encoded).digest() | |
| 66 | + mac = hmac.new(base64.b64decode(secret), message, hashlib.sha512) | |
| 67 | + return base64.b64encode(mac.digest()).decode() | |
| 68 | + | |
| 69 | + | |
| 70 | +class NonceSource: | |
| 71 | + """Monotonic nonce (ms) — never goes backwards even if the clock does, and unique within a process.""" | |
| 72 | + | |
| 73 | + def __init__(self) -> None: | |
| 74 | + self._last = 0 | |
| 75 | + self._lock = asyncio.Lock() | |
| 76 | + | |
| 77 | + async def next(self) -> str: | |
| 78 | + async with self._lock: | |
| 79 | + n = int(time.time() * 1000) | |
| 80 | + if n <= self._last: | |
| 81 | + n = self._last + 1 | |
| 82 | + self._last = n | |
| 83 | + return str(n) | |
| 84 | + | |
| 85 | + | |
| 86 | +class CallCounter: | |
| 87 | + """Private REST call counter (points with linear decay) — mirrors Kraken's tiered limiter so we | |
| 88 | + throttle *before* the exchange does.""" | |
| 89 | + | |
| 90 | + def __init__(self, tier: str = "starter", safety: float = 0.8) -> None: | |
| 91 | + self.max_points, self.decay = TIER_LIMITS.get(tier, TIER_LIMITS["starter"]) | |
| 92 | + self.safety = safety | |
| 93 | + self.points = 0.0 | |
| 94 | + self._t = time.monotonic() | |
| 95 | + self._lock = asyncio.Lock() | |
| 96 | + | |
| 97 | + def _refresh(self) -> None: | |
| 98 | + now = time.monotonic() | |
| 99 | + self.points = max(0.0, self.points - (now - self._t) * self.decay) | |
| 100 | + self._t = now | |
| 101 | + | |
| 102 | + async def acquire(self, cost: int = 1) -> None: | |
| 103 | + async with self._lock: | |
| 104 | + self._refresh() | |
| 105 | + budget = self.max_points * self.safety | |
| 106 | + while self.points + cost > budget: | |
| 107 | + wait = (self.points + cost - budget) / self.decay | |
| 108 | + log.info("kraken.ratelimit.wait", seconds=round(wait, 2), points=round(self.points, 2)) | |
| 109 | + await asyncio.sleep(wait) | |
| 110 | + self._refresh() | |
| 111 | + self.points += cost | |
| 112 | + | |
| 113 | + | |
| 114 | +class KrakenREST: | |
| 115 | + def __init__( | |
| 116 | + self, | |
| 117 | + base_url: str = "https://api.kraken.com", | |
| 118 | + api_key: str = "", | |
| 119 | + api_secret: str = "", | |
| 120 | + tier: str = "starter", | |
| 121 | + timeout: float = 15.0, | |
| 122 | + public_min_interval: float = 0.35, | |
| 123 | + ) -> None: | |
| 124 | + self.base_url = base_url.rstrip("/") | |
| 125 | + self.api_key = api_key | |
| 126 | + self.api_secret = api_secret | |
| 127 | + self._client = httpx.AsyncClient(base_url=self.base_url, timeout=timeout, headers={"User-Agent": "ktrade/0.1 (+crypto-trade-bot.app)"}) | |
| 128 | + self._nonce = NonceSource() | |
| 129 | + self._counter = CallCounter(tier) | |
| 130 | + self._pub_lock = asyncio.Lock() | |
| 131 | + self._pub_last = 0.0 | |
| 132 | + self._pub_min_interval = public_min_interval | |
| 133 | + | |
| 134 | + async def aclose(self) -> None: | |
| 135 | + await self._client.aclose() | |
| 136 | + | |
| 137 | + @property | |
| 138 | + def has_credentials(self) -> bool: | |
| 139 | + return bool(self.api_key and self.api_secret) | |
| 140 | + | |
| 141 | + # ---------- transport ---------- | |
| 142 | + async def _public(self, method: str, params: dict[str, Any] | None = None) -> Any: | |
| 143 | + async with self._pub_lock: # gentle pacing of public calls (public limit is undocumented; be polite) | |
| 144 | + dt = time.monotonic() - self._pub_last | |
| 145 | + if dt < self._pub_min_interval: | |
| 146 | + await asyncio.sleep(self._pub_min_interval - dt) | |
| 147 | + self._pub_last = time.monotonic() | |
| 148 | + for attempt in range(4): | |
| 149 | + try: | |
| 150 | + r = await self._client.get(f"/0/public/{method}", params=params or {}) | |
| 151 | + r.raise_for_status() | |
| 152 | + body = r.json() | |
| 153 | + except (httpx.HTTPError, ValueError) as e: | |
| 154 | + if attempt == 3: | |
| 155 | + raise | |
| 156 | + await asyncio.sleep(0.5 * 2**attempt) | |
| 157 | + log.warning("kraken.public.retry", method=method, attempt=attempt, error=str(e)) | |
| 158 | + continue | |
| 159 | + if body.get("error"): | |
| 160 | + err = KrakenAPIError(body["error"], method) | |
| 161 | + if err.is_rate_limit and attempt < 3: | |
| 162 | + await asyncio.sleep(2.0 * 2**attempt) | |
| 163 | + continue | |
| 164 | + raise err | |
| 165 | + return body["result"] | |
| 166 | + raise RuntimeError("unreachable") | |
| 167 | + | |
| 168 | + async def _private(self, method: str, data: dict[str, Any] | None = None, cost: int = 1) -> Any: | |
| 169 | + if not self.has_credentials: | |
| 170 | + raise KrakenAPIError(["EAPI:No credentials configured"], method) | |
| 171 | + await self._counter.acquire(cost) | |
| 172 | + path = f"/0/private/{method}" | |
| 173 | + payload = dict(data or {}) | |
| 174 | + payload["nonce"] = await self._nonce.next() | |
| 175 | + headers = { | |
| 176 | + "API-Key": self.api_key, | |
| 177 | + "API-Sign": sign(path, payload, self.api_secret), | |
| 178 | + "Content-Type": "application/x-www-form-urlencoded", | |
| 179 | + } | |
| 180 | + r = await self._client.post(path, data=payload, headers=headers) | |
| 181 | + r.raise_for_status() | |
| 182 | + body = r.json() | |
| 183 | + if body.get("error"): | |
| 184 | + raise KrakenAPIError(body["error"], method) | |
| 185 | + return body["result"] | |
| 186 | + | |
| 187 | + # ---------- public ---------- | |
| 188 | + async def time(self) -> int: | |
| 189 | + return int((await self._public("Time"))["unixtime"]) | |
| 190 | + | |
| 191 | + async def system_status(self) -> SystemStatus: | |
| 192 | + r = await self._public("SystemStatus") | |
| 193 | + return SystemStatus(status=r["status"], timestamp=r["timestamp"]) | |
| 194 | + | |
| 195 | + async def asset_pairs(self, pairs: list[str] | None = None) -> dict[str, PairMeta]: | |
| 196 | + params = {"pair": ",".join(pairs)} if pairs else None | |
| 197 | + r = await self._public("AssetPairs", params) | |
| 198 | + return {k: PairMeta.from_kraken(k, v) for k, v in r.items()} | |
| 199 | + | |
| 200 | + async def ticker(self, pairs: list[str]) -> dict[str, dict]: | |
| 201 | + return await self._public("Ticker", {"pair": ",".join(pairs)}) | |
| 202 | + | |
| 203 | + async def ohlc(self, pair: str, interval: int = 1, since: int | None = None) -> tuple[list[Candle], int]: | |
| 204 | + if interval not in OHLC_INTERVALS: | |
| 205 | + raise ValueError(f"interval must be one of {OHLC_INTERVALS}") | |
| 206 | + params: dict[str, Any] = {"pair": pair, "interval": interval} | |
| 207 | + if since is not None: | |
| 208 | + params["since"] = since | |
| 209 | + r = await self._public("OHLC", params) | |
| 210 | + last = int(r.pop("last")) | |
| 211 | + rows = next(iter(r.values())) if r else [] | |
| 212 | + candles = [Candle(ts=int(x[0]), open=float(x[1]), high=float(x[2]), low=float(x[3]), close=float(x[4]), vwap=float(x[5]), volume=float(x[6]), count=int(x[7])) for x in rows] | |
| 213 | + return candles, last | |
| 214 | + | |
| 215 | + async def depth(self, pair: str, count: int = 100) -> dict[str, list[tuple[Decimal, Decimal, int]]]: | |
| 216 | + count = max(1, min(500, count)) | |
| 217 | + r = await self._public("Depth", {"pair": pair, "count": count}) | |
| 218 | + book = next(iter(r.values())) | |
| 219 | + return { | |
| 220 | + "asks": [(Decimal(a[0]), Decimal(a[1]), int(a[2])) for a in book["asks"]], | |
| 221 | + "bids": [(Decimal(b[0]), Decimal(b[1]), int(b[2])) for b in book["bids"]], | |
| 222 | + } | |
| 223 | + | |
| 224 | + async def trades(self, pair: str, since: str | None = None, count: int = 1000) -> tuple[list[Trade], str]: | |
| 225 | + params: dict[str, Any] = {"pair": pair, "count": max(1, min(1000, count))} | |
| 226 | + if since: | |
| 227 | + params["since"] = since | |
| 228 | + r = await self._public("Trades", params) | |
| 229 | + last = str(r.pop("last")) | |
| 230 | + rows = next(iter(r.values())) if r else [] | |
| 231 | + out = [ | |
| 232 | + Trade(ts=float(x[2]), price=float(x[0]), volume=float(x[1]), side="buy" if x[3] == "b" else "sell", order_type="market" if x[4] == "m" else "limit", trade_id=int(x[6]) if len(x) > 6 else None) | |
| 233 | + for x in rows | |
| 234 | + ] | |
| 235 | + return out, last | |
| 236 | + | |
| 237 | + # ---------- private (read) ---------- | |
| 238 | + async def balance(self) -> dict[str, Decimal]: | |
| 239 | + r = await self._private("Balance") | |
| 240 | + return {k: Decimal(v) for k, v in r.items()} | |
| 241 | + | |
| 242 | + async def trade_balance(self, asset: str = "ZUSD") -> dict[str, Any]: | |
| 243 | + return await self._private("TradeBalance", {"asset": asset}) | |
| 244 | + | |
| 245 | + async def open_orders(self) -> dict[str, Any]: | |
| 246 | + return await self._private("OpenOrders") | |
| 247 | + | |
| 248 | + async def closed_orders(self, start: int | None = None) -> dict[str, Any]: | |
| 249 | + return await self._private("ClosedOrders", {"start": start} if start else None) | |
| 250 | + | |
| 251 | + async def trades_history(self, start: int | None = None) -> dict[str, Any]: | |
| 252 | + return await self._private("TradesHistory", {"start": start} if start else None, cost=2) | |
| 253 | + | |
| 254 | + async def trade_volume(self, pairs: list[str]) -> dict[str, Any]: | |
| 255 | + return await self._private("TradeVolume", {"pair": ",".join(pairs)}) | |
| 256 | + | |
| 257 | + async def websockets_token(self) -> str: | |
| 258 | + return (await self._private("GetWebSocketsToken"))["token"] | |
| 259 | + | |
| 260 | + # ---------- private (trade) — only reachable through the live adapter, which is gated ---------- | |
| 261 | + async def add_order(self, *, pair: str, side: str, ordertype: str, volume: str, price: str | None = None, cl_ord_id: str | None = None, timeinforce: str = "GTC", oflags: str | None = None, validate: bool = True) -> dict[str, Any]: | |
| 262 | + data: dict[str, Any] = {"pair": pair, "type": side, "ordertype": ordertype, "volume": volume, "timeinforce": timeinforce} | |
| 263 | + if price is not None: | |
| 264 | + data["price"] = price | |
| 265 | + if cl_ord_id: | |
| 266 | + data["cl_ord_id"] = cl_ord_id | |
| 267 | + if oflags: | |
| 268 | + data["oflags"] = oflags | |
| 269 | + if validate: | |
| 270 | + data["validate"] = "true" | |
| 271 | + return await self._private("AddOrder", data) | |
| 272 | + | |
| 273 | + async def cancel_order(self, txid: str | None = None, cl_ord_id: str | None = None) -> dict[str, Any]: | |
| 274 | + data = {"txid": txid} if txid else {"cl_ord_id": cl_ord_id} | |
| 275 | + return await self._private("CancelOrder", data) | |
| 276 | + | |
| 277 | + async def cancel_all(self) -> dict[str, Any]: | |
| 278 | + return await self._private("CancelAll") | |
added
apps/api/ktrade/kraken/ws.py
+186 −0
@@ -0,0 +1,186 @@ | ||
| 1 | +"""Kraken WebSocket v2 public market-data client (wss://ws.kraken.com/v2). | |
| 2 | + | |
| 3 | +Channels: ticker, book (depth 10/25/100/500/1000, CRC32 checksum over top 10), ohlc (interval minutes), | |
| 4 | +trade. Heartbeats arrive ~1/s when idle; we treat >`stale_after` seconds of silence as a dead link and | |
| 5 | +reconnect with exponential backoff. Every message is handed to `on_event(channel, type, data, received_at)` | |
| 6 | +with the exchange timestamp preserved (event-time semantics). Book state is maintained locally and | |
| 7 | +checksum-verified; a mismatch triggers resubscription (never trust a drifting book). | |
| 8 | +""" | |
| 9 | + | |
| 10 | +from __future__ import annotations | |
| 11 | + | |
| 12 | +import asyncio | |
| 13 | +import json | |
| 14 | +import random | |
| 15 | +import time | |
| 16 | +from collections.abc import Awaitable, Callable | |
| 17 | +from decimal import Decimal | |
| 18 | +from typing import Any | |
| 19 | + | |
| 20 | +import websockets | |
| 21 | + | |
| 22 | +from ktrade.kraken.checksum import book_checksum, fmt_fixed | |
| 23 | +from ktrade.logging import get_logger | |
| 24 | + | |
| 25 | +log = get_logger("kraken.ws") | |
| 26 | + | |
| 27 | +EventHandler = Callable[[str, str, list[dict[str, Any]], float], Awaitable[None]] | |
| 28 | + | |
| 29 | + | |
| 30 | +class LocalBook: | |
| 31 | + def __init__(self, symbol: str, depth: int, price_decimals: int, qty_decimals: int) -> None: | |
| 32 | + self.symbol = symbol | |
| 33 | + self.depth = depth | |
| 34 | + self.pd = price_decimals | |
| 35 | + self.qd = qty_decimals | |
| 36 | + self.bids: dict[Decimal, Decimal] = {} | |
| 37 | + self.asks: dict[Decimal, Decimal] = {} | |
| 38 | + self.ts: str | None = None | |
| 39 | + self.valid = False | |
| 40 | + | |
| 41 | + def apply(self, msg_type: str, d: dict[str, Any]) -> bool: | |
| 42 | + if msg_type == "snapshot": | |
| 43 | + self.bids = {Decimal(str(x["price"])): Decimal(str(x["qty"])) for x in d.get("bids", [])} | |
| 44 | + self.asks = {Decimal(str(x["price"])): Decimal(str(x["qty"])) for x in d.get("asks", [])} | |
| 45 | + else: | |
| 46 | + for side, levels in (("bids", self.bids), ("asks", self.asks)): | |
| 47 | + for x in d.get(side, []): | |
| 48 | + p, q = Decimal(str(x["price"])), Decimal(str(x["qty"])) | |
| 49 | + if q == 0: | |
| 50 | + levels.pop(p, None) | |
| 51 | + else: | |
| 52 | + levels[p] = q | |
| 53 | + # truncate to subscribed depth after processing all updates | |
| 54 | + self.bids = dict(sorted(self.bids.items(), key=lambda kv: kv[0], reverse=True)[: self.depth]) | |
| 55 | + self.asks = dict(sorted(self.asks.items(), key=lambda kv: kv[0])[: self.depth]) | |
| 56 | + self.ts = d.get("timestamp") | |
| 57 | + expected = d.get("checksum") | |
| 58 | + if expected is None: | |
| 59 | + self.valid = True | |
| 60 | + return True | |
| 61 | + got = book_checksum( | |
| 62 | + [(fmt_fixed(p, self.pd), fmt_fixed(q, self.qd)) for p, q in self.asks.items()], | |
| 63 | + [(fmt_fixed(p, self.pd), fmt_fixed(q, self.qd)) for p, q in self.bids.items()], | |
| 64 | + ) | |
| 65 | + self.valid = got == int(expected) | |
| 66 | + return self.valid | |
| 67 | + | |
| 68 | + def top(self, n: int = 10) -> dict[str, Any]: | |
| 69 | + bids = sorted(self.bids.items(), key=lambda kv: kv[0], reverse=True)[:n] | |
| 70 | + asks = sorted(self.asks.items(), key=lambda kv: kv[0])[:n] | |
| 71 | + best_bid = float(bids[0][0]) if bids else None | |
| 72 | + best_ask = float(asks[0][0]) if asks else None | |
| 73 | + bid_depth = float(sum(q for _, q in bids)) | |
| 74 | + ask_depth = float(sum(q for _, q in asks)) | |
| 75 | + imb = (bid_depth - ask_depth) / (bid_depth + ask_depth) if (bid_depth + ask_depth) > 0 else 0.0 | |
| 76 | + return { | |
| 77 | + "symbol": self.symbol, | |
| 78 | + "bids": [[float(p), float(q)] for p, q in bids], | |
| 79 | + "asks": [[float(p), float(q)] for p, q in asks], | |
| 80 | + "best_bid": best_bid, | |
| 81 | + "best_ask": best_ask, | |
| 82 | + "spread": (best_ask - best_bid) if best_bid and best_ask else None, | |
| 83 | + "spread_bps": ((best_ask - best_bid) / ((best_ask + best_bid) / 2) * 1e4) if best_bid and best_ask else None, | |
| 84 | + "imbalance": imb, | |
| 85 | + "timestamp": self.ts, | |
| 86 | + "valid": self.valid, | |
| 87 | + } | |
| 88 | + | |
| 89 | + | |
| 90 | +class KrakenWS: | |
| 91 | + def __init__( | |
| 92 | + self, | |
| 93 | + url: str, | |
| 94 | + symbols: list[str], | |
| 95 | + on_event: EventHandler, | |
| 96 | + *, | |
| 97 | + book_depth: int = 25, | |
| 98 | + ohlc_intervals: list[int] | None = None, | |
| 99 | + precision: dict[str, tuple[int, int]] | None = None, | |
| 100 | + stale_after: float = 10.0, | |
| 101 | + ) -> None: | |
| 102 | + self.url = url | |
| 103 | + self.symbols = symbols | |
| 104 | + self.on_event = on_event | |
| 105 | + self.book_depth = book_depth | |
| 106 | + self.ohlc_intervals = ohlc_intervals or [1] | |
| 107 | + self.precision = precision or {} | |
| 108 | + self.stale_after = stale_after | |
| 109 | + self.books: dict[str, LocalBook] = {} | |
| 110 | + self._stop = asyncio.Event() | |
| 111 | + self.connected = False | |
| 112 | + self.last_msg_at = 0.0 | |
| 113 | + self.reconnects = 0 | |
| 114 | + self.checksum_failures = 0 | |
| 115 | + self.messages = 0 | |
| 116 | + | |
| 117 | + def stop(self) -> None: | |
| 118 | + self._stop.set() | |
| 119 | + | |
| 120 | + def _subscriptions(self) -> list[dict[str, Any]]: | |
| 121 | + subs: list[dict[str, Any]] = [ | |
| 122 | + {"method": "subscribe", "params": {"channel": "ticker", "symbol": self.symbols, "event_trigger": "trades"}, "req_id": 1}, | |
| 123 | + {"method": "subscribe", "params": {"channel": "book", "symbol": self.symbols, "depth": self.book_depth, "snapshot": True}, "req_id": 2}, | |
| 124 | + {"method": "subscribe", "params": {"channel": "trade", "symbol": self.symbols, "snapshot": False}, "req_id": 3}, | |
| 125 | + ] | |
| 126 | + for i, iv in enumerate(self.ohlc_intervals): | |
| 127 | + subs.append({"method": "subscribe", "params": {"channel": "ohlc", "symbol": self.symbols, "interval": iv, "snapshot": False}, "req_id": 10 + i}) | |
| 128 | + return subs | |
| 129 | + | |
| 130 | + async def run(self) -> None: | |
| 131 | + backoff = 1.0 | |
| 132 | + while not self._stop.is_set(): | |
| 133 | + try: | |
| 134 | + await self._session() | |
| 135 | + backoff = 1.0 | |
| 136 | + except asyncio.CancelledError: | |
| 137 | + raise | |
| 138 | + except Exception as e: # noqa: BLE001 | |
| 139 | + self.connected = False | |
| 140 | + self.reconnects += 1 | |
| 141 | + log.warning("kraken.ws.disconnected", error=str(e), reconnect_in=round(backoff, 1)) | |
| 142 | + await asyncio.sleep(backoff + random.random()) | |
| 143 | + backoff = min(backoff * 2, 60.0) | |
| 144 | + | |
| 145 | + async def _session(self) -> None: | |
| 146 | + async with websockets.connect(self.url, ping_interval=20, ping_timeout=20, max_size=8 * 1024 * 1024) as ws: | |
| 147 | + self.connected = True | |
| 148 | + self.last_msg_at = time.monotonic() | |
| 149 | + for sub in self._subscriptions(): | |
| 150 | + await ws.send(json.dumps(sub)) | |
| 151 | + log.info("kraken.ws.connected", symbols=self.symbols, depth=self.book_depth) | |
| 152 | + for s in self.symbols: | |
| 153 | + pd, qd = self.precision.get(s, (8, 8)) | |
| 154 | + self.books[s] = LocalBook(s, self.book_depth, pd, qd) | |
| 155 | + while not self._stop.is_set(): | |
| 156 | + try: | |
| 157 | + raw = await asyncio.wait_for(ws.recv(), timeout=self.stale_after) | |
| 158 | + except TimeoutError: | |
| 159 | + raise ConnectionError(f"no message for {self.stale_after}s (heartbeat missing)") from None | |
| 160 | + self.last_msg_at = time.monotonic() | |
| 161 | + self.messages += 1 | |
| 162 | + msg = json.loads(raw) | |
| 163 | + received_at = time.time() | |
| 164 | + if "method" in msg: # subscribe ack / error | |
| 165 | + if not msg.get("success", True): | |
| 166 | + log.error("kraken.ws.subscribe_error", msg=msg) | |
| 167 | + continue | |
| 168 | + ch = msg.get("channel") | |
| 169 | + if ch in (None, "heartbeat", "status"): | |
| 170 | + continue | |
| 171 | + mtype = msg.get("type", "update") | |
| 172 | + data = msg.get("data", []) | |
| 173 | + if ch == "book": | |
| 174 | + for d in data: | |
| 175 | + b = self.books.get(d["symbol"]) | |
| 176 | + if b is None: | |
| 177 | + continue | |
| 178 | + if not b.apply(mtype, d): | |
| 179 | + self.checksum_failures += 1 | |
| 180 | + log.warning("kraken.ws.book_checksum_mismatch", symbol=d["symbol"]) | |
| 181 | + await ws.send(json.dumps({"method": "unsubscribe", "params": {"channel": "book", "symbol": [d["symbol"]], "depth": self.book_depth}})) | |
| 182 | + await ws.send(json.dumps({"method": "subscribe", "params": {"channel": "book", "symbol": [d["symbol"]], "depth": self.book_depth, "snapshot": True}})) | |
| 183 | + continue | |
| 184 | + await self.on_event("book", mtype, [self.books[d["symbol"]].top(self.book_depth) for d in data if d["symbol"] in self.books], received_at) | |
| 185 | + else: | |
| 186 | + await self.on_event(ch, mtype, data, received_at) | |
added
apps/api/ktrade/logging.py
+59 −0
@@ -0,0 +1,59 @@ | ||
| 1 | +"""Structured logging with correlation ids (structlog → JSON lines or console).""" | |
| 2 | + | |
| 3 | +from __future__ import annotations | |
| 4 | + | |
| 5 | +import contextvars | |
| 6 | +import logging | |
| 7 | +import sys | |
| 8 | +import uuid | |
| 9 | +from typing import Any | |
| 10 | + | |
| 11 | +import structlog | |
| 12 | + | |
| 13 | +correlation_id: contextvars.ContextVar[str] = contextvars.ContextVar("correlation_id", default="-") | |
| 14 | + | |
| 15 | +SECRET_KEYS = {"api_key", "api_secret", "secret", "password", "token", "api-sign", "api-key", "authorization"} | |
| 16 | + | |
| 17 | + | |
| 18 | +def _redact(_, __, event_dict): # noqa: ANN001 | |
| 19 | + for k in list(event_dict): | |
| 20 | + if k.lower() in SECRET_KEYS: | |
| 21 | + event_dict[k] = "***" | |
| 22 | + return event_dict | |
| 23 | + | |
| 24 | + | |
| 25 | +def _add_correlation(_, __, event_dict): # noqa: ANN001 | |
| 26 | + event_dict["cid"] = correlation_id.get() | |
| 27 | + return event_dict | |
| 28 | + | |
| 29 | + | |
| 30 | +def new_correlation_id() -> str: | |
| 31 | + cid = uuid.uuid4().hex[:12] | |
| 32 | + correlation_id.set(cid) | |
| 33 | + return cid | |
| 34 | + | |
| 35 | + | |
| 36 | +def configure_logging(level: str = "INFO", json_output: bool = True) -> None: | |
| 37 | + logging.basicConfig(level=level.upper(), stream=sys.stdout, format="%(message)s") | |
| 38 | + for noisy in ("httpx", "httpcore", "websockets", "uvicorn.access"): | |
| 39 | + logging.getLogger(noisy).setLevel(logging.WARNING) | |
| 40 | + processors: list[Any] = [ | |
| 41 | + structlog.contextvars.merge_contextvars, | |
| 42 | + structlog.processors.add_log_level, | |
| 43 | + structlog.processors.TimeStamper(fmt="iso", utc=True), | |
| 44 | + _add_correlation, | |
| 45 | + _redact, | |
| 46 | + structlog.processors.StackInfoRenderer(), | |
| 47 | + structlog.processors.format_exc_info, | |
| 48 | + ] | |
| 49 | + renderer = structlog.processors.JSONRenderer() if json_output else structlog.dev.ConsoleRenderer() | |
| 50 | + structlog.configure( | |
| 51 | + processors=[*processors, renderer], | |
| 52 | + wrapper_class=structlog.make_filtering_bound_logger(logging.getLevelName(level.upper())), | |
| 53 | + logger_factory=structlog.PrintLoggerFactory(sys.stdout), | |
| 54 | + cache_logger_on_first_use=False, | |
| 55 | + ) | |
| 56 | + | |
| 57 | + | |
| 58 | +def get_logger(name: str) -> structlog.stdlib.BoundLogger: | |
| 59 | + return structlog.get_logger(name) | |
added
apps/api/ktrade/market/__init__.py
+0 −0
added
apps/api/ktrade/market/service.py
+127 −0
@@ -0,0 +1,127 @@ | ||
| 1 | +"""Read-side queries for the market API (candles, ticker, book, trades, data quality, features).""" | |
| 2 | + | |
| 3 | +from __future__ import annotations | |
| 4 | + | |
| 5 | +import time | |
| 6 | +from typing import Any | |
| 7 | + | |
| 8 | +import asyncpg | |
| 9 | +import numpy as np | |
| 10 | +import pandas as pd | |
| 11 | + | |
| 12 | +from ktrade.db.pool import rows_to_dicts | |
| 13 | + | |
| 14 | + | |
| 15 | +async def list_pairs(pool: asyncpg.Pool, universe_only: bool = True, q: str | None = None) -> list[dict[str, Any]]: | |
| 16 | + sql = "SELECT pair_id, altname, wsname, base, quote, status, ordermin, costmin, tick_size, pair_decimals, lot_decimals, cost_decimals, leverage_buy, leverage_sell, in_universe, updated_at FROM pairs" | |
| 17 | + cond, args = [], [] | |
| 18 | + if universe_only: | |
| 19 | + cond.append("in_universe") | |
| 20 | + if q: | |
| 21 | + args.append(f"%{q.upper()}%") | |
| 22 | + cond.append(f"(wsname ILIKE ${len(args)} OR altname ILIKE ${len(args)})") | |
| 23 | + if cond: | |
| 24 | + sql += " WHERE " + " AND ".join(cond) | |
| 25 | + sql += " ORDER BY in_universe DESC, wsname LIMIT 500" | |
| 26 | + out = rows_to_dicts(await pool.fetch(sql, *args)) | |
| 27 | + for r in out: | |
| 28 | + for k in ("ordermin", "costmin", "tick_size"): | |
| 29 | + r[k] = float(r[k]) | |
| 30 | + return out | |
| 31 | + | |
| 32 | + | |
| 33 | +async def get_candles(pool: asyncpg.Pool, symbol: str, interval: int, limit: int = 1000, start: int | None = None, end: int | None = None, closed_only: bool = False) -> list[dict[str, Any]]: | |
| 34 | + cond = ["symbol=$1", "interval_min=$2"] | |
| 35 | + args: list[Any] = [symbol, interval] | |
| 36 | + if start is not None: | |
| 37 | + args.append(start) | |
| 38 | + cond.append(f"ts >= ${len(args)}") | |
| 39 | + if end is not None: | |
| 40 | + args.append(end) | |
| 41 | + cond.append(f"ts <= ${len(args)}") | |
| 42 | + if closed_only: | |
| 43 | + cond.append("closed") | |
| 44 | + args.append(min(max(limit, 1), 20000)) | |
| 45 | + rows = await pool.fetch(f"SELECT ts, open, high, low, close, vwap, volume, count, closed FROM candles WHERE {' AND '.join(cond)} ORDER BY ts DESC LIMIT ${len(args)}", *args) | |
| 46 | + return [dict(r) for r in reversed(rows)] | |
| 47 | + | |
| 48 | + | |
| 49 | +async def candles_frame(pool: asyncpg.Pool, symbol: str, interval: int, start: int | None = None, end: int | None = None) -> pd.DataFrame: | |
| 50 | + rows = await get_candles(pool, symbol, interval, limit=20000, start=start, end=end, closed_only=True) | |
| 51 | + if not rows: | |
| 52 | + return pd.DataFrame(columns=["ts", "open", "high", "low", "close", "vwap", "volume", "count"]) | |
| 53 | + df = pd.DataFrame(rows).drop(columns=["closed"]) | |
| 54 | + return df.sort_values("ts").reset_index(drop=True) | |
| 55 | + | |
| 56 | + | |
| 57 | +async def ticker(pool: asyncpg.Pool, symbol: str | None = None) -> list[dict[str, Any]]: | |
| 58 | + if symbol: | |
| 59 | + rows = await pool.fetch("SELECT symbol, payload, received_at FROM ticker_latest WHERE symbol=$1", symbol) | |
| 60 | + else: | |
| 61 | + rows = await pool.fetch("SELECT symbol, payload, received_at FROM ticker_latest ORDER BY symbol") | |
| 62 | + now = time.time() | |
| 63 | + return [{**r["payload"], "received_at": r["received_at"], "age_s": round(now - r["received_at"], 1)} for r in rows] | |
| 64 | + | |
| 65 | + | |
| 66 | +async def book(pool: asyncpg.Pool, symbol: str) -> dict[str, Any] | None: | |
| 67 | + r = await pool.fetchrow("SELECT payload, received_at, valid FROM book_latest WHERE symbol=$1", symbol) | |
| 68 | + if not r: | |
| 69 | + return None | |
| 70 | + return {**r["payload"], "received_at": r["received_at"], "age_s": round(time.time() - r["received_at"], 1), "valid": r["valid"]} | |
| 71 | + | |
| 72 | + | |
| 73 | +async def recent_trades(pool: asyncpg.Pool, symbol: str, limit: int = 100) -> list[dict[str, Any]]: | |
| 74 | + rows = await pool.fetch("SELECT trade_id, ts, price, volume, side, order_type FROM trades WHERE symbol=$1 ORDER BY ts DESC LIMIT $2", symbol, min(limit, 1000)) | |
| 75 | + return rows_to_dicts(rows) | |
| 76 | + | |
| 77 | + | |
| 78 | +async def book_samples(pool: asyncpg.Pool, symbol: str, seconds: int = 3600) -> list[dict[str, Any]]: | |
| 79 | + since = int(time.time()) - seconds | |
| 80 | + rows = await pool.fetch("SELECT ts, best_bid, best_ask, spread_bps, bid_depth, ask_depth, imbalance FROM book_samples WHERE symbol=$1 AND ts>=$2 ORDER BY ts", symbol, since) | |
| 81 | + return rows_to_dicts(rows) | |
| 82 | + | |
| 83 | + | |
| 84 | +async def data_quality(pool: asyncpg.Pool, stale_after: int) -> list[dict[str, Any]]: | |
| 85 | + rows = await pool.fetch( | |
| 86 | + """SELECT c.symbol, c.interval_min, count(*) AS bars, min(ts) AS first_ts, max(ts) AS last_ts, | |
| 87 | + (SELECT count(*) FROM data_gaps g WHERE g.symbol=c.symbol AND g.interval_min=c.interval_min) AS gaps, | |
| 88 | + (SELECT coalesce(sum(missing_bars),0) FROM data_gaps g WHERE g.symbol=c.symbol AND g.interval_min=c.interval_min) AS missing_bars | |
| 89 | + FROM candles c WHERE closed GROUP BY c.symbol, c.interval_min ORDER BY c.symbol, c.interval_min""" | |
| 90 | + ) | |
| 91 | + now = int(time.time()) | |
| 92 | + out = [] | |
| 93 | + for r in rows: | |
| 94 | + d = dict(r) | |
| 95 | + iv = d["interval_min"] | |
| 96 | + expected_last = (now // (iv * 60) - 1) * iv * 60 | |
| 97 | + d["lag_bars"] = max(0, (expected_last - d["last_ts"]) // (iv * 60)) | |
| 98 | + d["stale"] = d["lag_bars"] * iv * 60 > max(stale_after, iv * 60 * 2) | |
| 99 | + d["coverage_days"] = round((d["last_ts"] - d["first_ts"]) / 86400, 2) | |
| 100 | + out.append(d) | |
| 101 | + return out | |
| 102 | + | |
| 103 | + | |
| 104 | +def realized_vol(df: pd.DataFrame, interval_min: int, window: int = 30) -> dict[str, float | None]: | |
| 105 | + if len(df) < window + 2: | |
| 106 | + return {"rv_window": None, "rv_annualized": None} | |
| 107 | + r = np.log(df["close"]).diff().dropna().tail(window) | |
| 108 | + per_year = 365 * 24 * 60 / interval_min | |
| 109 | + rv = float(r.std(ddof=1)) | |
| 110 | + return {"rv_window": rv, "rv_annualized": rv * np.sqrt(per_year)} | |
| 111 | + | |
| 112 | + | |
| 113 | +async def overview(pool: asyncpg.Pool, universe: list[str], stale_after: int) -> dict[str, Any]: | |
| 114 | + tick = {t["symbol"]: t for t in await ticker(pool)} | |
| 115 | + out = [] | |
| 116 | + for s in universe: | |
| 117 | + t = tick.get(s) | |
| 118 | + df = await candles_frame(pool, s, 60) | |
| 119 | + rv = realized_vol(df, 60, 24 * 7) if not df.empty else {"rv_window": None, "rv_annualized": None} | |
| 120 | + out.append({ | |
| 121 | + "symbol": s, "last": t.get("last") if t else None, "change_pct": t.get("change_pct") if t else None, | |
| 122 | + "bid": t.get("bid") if t else None, "ask": t.get("ask") if t else None, "volume": t.get("volume") if t else None, | |
| 123 | + "spread_bps": ((t["ask"] - t["bid"]) / ((t["ask"] + t["bid"]) / 2) * 1e4) if t and t.get("bid") and t.get("ask") else None, | |
| 124 | + "age_s": t.get("age_s") if t else None, "stale": (t is None) or (t["age_s"] > stale_after), "rv_7d_annualized": rv["rv_annualized"], | |
| 125 | + "hourly_bars": int(len(df)), | |
| 126 | + }) | |
| 127 | + return {"symbols": out} | |
added
apps/api/ktrade/risk/__init__.py
+10 −0
@@ -0,0 +1,10 @@ | ||
| 1 | +from ktrade.risk.gateway import ( | |
| 2 | + MarketState, | |
| 3 | + OrderIntent, | |
| 4 | + PortfolioSnapshot, | |
| 5 | + RiskDecision, | |
| 6 | + RiskGateway, | |
| 7 | + RiskLimits, | |
| 8 | +) | |
| 9 | + | |
| 10 | +__all__ = ["MarketState", "OrderIntent", "PortfolioSnapshot", "RiskDecision", "RiskGateway", "RiskLimits"] | |
added
apps/api/ktrade/risk/gateway.py
+142 −0
@@ -0,0 +1,142 @@ | ||
| 1 | +"""Independent risk gateway. EVERY order — backtest, paper or live — must pass `RiskGateway.evaluate`. | |
| 2 | + | |
| 3 | +Default-deny: any missing/stale input, kill switch, reconciliation failure or internal error rejects the order. | |
| 4 | +The gateway is pure (no I/O) so it can be unit-tested and reused unchanged by all engines; engines are responsible | |
| 5 | +for feeding it fresh `PortfolioSnapshot`/`MarketState` values. | |
| 6 | +""" | |
| 7 | + | |
| 8 | +from __future__ import annotations | |
| 9 | + | |
| 10 | +import math | |
| 11 | +from typing import Literal | |
| 12 | + | |
| 13 | +from pydantic import BaseModel, Field | |
| 14 | + | |
| 15 | +Side = Literal["buy", "sell"] | |
| 16 | + | |
| 17 | + | |
| 18 | +class RiskLimits(BaseModel): | |
| 19 | + max_order_notional: float = Field(1_000.0, description="Quote-currency notional per order") | |
| 20 | + max_position_notional: float = Field(5_000.0, description="Per-symbol absolute notional") | |
| 21 | + max_gross_exposure: float = Field(1.0, description="Σ|position notional| / equity") | |
| 22 | + max_net_exposure: float = Field(1.0) | |
| 23 | + max_concentration: float = Field(0.6, description="Largest |position| / equity") | |
| 24 | + daily_loss_limit: float = Field(0.03, description="Fraction of start-of-day equity") | |
| 25 | + max_drawdown_limit: float = Field(0.15, description="Fraction from peak equity (rolling)") | |
| 26 | + min_cash: float = Field(0.0, description="Quote currency that must remain after a buy") | |
| 27 | + max_open_orders: int = 20 | |
| 28 | + max_participation: float = Field(0.1, description="Order qty / recent bar volume") | |
| 29 | + max_price_deviation: float = Field(0.05, description="|limit or mark − last close| / last close") | |
| 30 | + stale_data_seconds: float = 120.0 | |
| 31 | + max_clock_drift_seconds: float = 5.0 | |
| 32 | + var_limit_95: float | None = Field(None, description="Optional 1-bar 95% VaR limit (fraction of equity)") | |
| 33 | + | |
| 34 | + | |
| 35 | +class OrderIntent(BaseModel): | |
| 36 | + symbol: str | |
| 37 | + side: Side | |
| 38 | + qty: float | |
| 39 | + order_type: Literal["market", "limit"] = "market" | |
| 40 | + limit_price: float | None = None | |
| 41 | + strategy: str = "manual" | |
| 42 | + client_order_id: str | None = None | |
| 43 | + | |
| 44 | + | |
| 45 | +class PortfolioSnapshot(BaseModel): | |
| 46 | + equity: float | |
| 47 | + cash: float | |
| 48 | + positions: dict[str, float] = Field(default_factory=dict, description="symbol → qty") | |
| 49 | + marks: dict[str, float] = Field(default_factory=dict, description="symbol → mark price") | |
| 50 | + open_orders: int = 0 | |
| 51 | + day_start_equity: float | None = None | |
| 52 | + peak_equity: float | None = None | |
| 53 | + reconciled: bool = True | |
| 54 | + var_95: float | None = None | |
| 55 | + | |
| 56 | + | |
| 57 | +class MarketState(BaseModel): | |
| 58 | + last_price: float | |
| 59 | + data_age_seconds: float = 0.0 | |
| 60 | + recent_bar_volume: float | None = None | |
| 61 | + best_bid: float | None = None | |
| 62 | + best_ask: float | None = None | |
| 63 | + clock_drift_seconds: float = 0.0 | |
| 64 | + exchange_status: str = "online" | |
| 65 | + pair_status: str = "online" | |
| 66 | + ordermin: float = 0.0 | |
| 67 | + costmin: float = 0.0 | |
| 68 | + | |
| 69 | + | |
| 70 | +class RiskDecision(BaseModel): | |
| 71 | + allowed: bool | |
| 72 | + reasons: list[str] = Field(default_factory=list) | |
| 73 | + checks: dict[str, bool] = Field(default_factory=dict) | |
| 74 | + adjusted_qty: float | None = None | |
| 75 | + | |
| 76 | + | |
| 77 | +class RiskGateway: | |
| 78 | + def __init__(self, limits: RiskLimits | None = None, kill_switch: bool = False) -> None: | |
| 79 | + self.limits = limits or RiskLimits() | |
| 80 | + self.kill_switch = kill_switch | |
| 81 | + | |
| 82 | + def evaluate(self, intent: OrderIntent, pf: PortfolioSnapshot | None, mkt: MarketState | None) -> RiskDecision: | |
| 83 | + L = self.limits | |
| 84 | + reasons: list[str] = [] | |
| 85 | + checks: dict[str, bool] = {} | |
| 86 | + | |
| 87 | + def check(name: str, ok: bool, why: str) -> None: | |
| 88 | + checks[name] = bool(ok) | |
| 89 | + if not ok: | |
| 90 | + reasons.append(why) | |
| 91 | + | |
| 92 | + try: | |
| 93 | + check("kill_switch", not self.kill_switch, "kill switch active") | |
| 94 | + check("inputs_present", pf is not None and mkt is not None, "missing portfolio or market state (default-deny)") | |
| 95 | + if pf is None or mkt is None: | |
| 96 | + return RiskDecision(allowed=False, reasons=reasons, checks=checks) | |
| 97 | + check("reconciled", pf.reconciled, "ledger not reconciled with exchange") | |
| 98 | + check("exchange_online", mkt.exchange_status == "online", f"exchange status {mkt.exchange_status}") | |
| 99 | + check("pair_online", mkt.pair_status in ("online", "limit_only" if intent.order_type == "limit" else "online"), f"pair status {mkt.pair_status}") | |
| 100 | + check("data_fresh", mkt.data_age_seconds <= L.stale_data_seconds, f"market data stale ({mkt.data_age_seconds:.0f}s)") | |
| 101 | + check("clock", abs(mkt.clock_drift_seconds) <= L.max_clock_drift_seconds, f"clock drift {mkt.clock_drift_seconds:.1f}s") | |
| 102 | + check("qty_finite", math.isfinite(intent.qty) and intent.qty > 0, "invalid quantity") | |
| 103 | + check("price_finite", math.isfinite(mkt.last_price) and mkt.last_price > 0, "invalid reference price") | |
| 104 | + if not (checks["qty_finite"] and checks["price_finite"]): | |
| 105 | + return RiskDecision(allowed=False, reasons=reasons, checks=checks) | |
| 106 | + | |
| 107 | + ref = intent.limit_price if (intent.order_type == "limit" and intent.limit_price) else mkt.last_price | |
| 108 | + if intent.limit_price is not None: | |
| 109 | + check("price_deviation", abs(intent.limit_price - mkt.last_price) / mkt.last_price <= L.max_price_deviation, "limit price deviates from last") | |
| 110 | + notional = intent.qty * ref | |
| 111 | + check("min_size", intent.qty >= mkt.ordermin and notional >= mkt.costmin, f"below exchange minimum (ordermin {mkt.ordermin}, costmin {mkt.costmin})") | |
| 112 | + check("order_notional", notional <= L.max_order_notional, f"order notional {notional:.2f} > {L.max_order_notional}") | |
| 113 | + check("open_orders", pf.open_orders < L.max_open_orders, "too many open orders") | |
| 114 | + | |
| 115 | + pos = pf.positions.get(intent.symbol, 0.0) | |
| 116 | + new_pos = pos + intent.qty if intent.side == "buy" else pos - intent.qty | |
| 117 | + check("no_short", new_pos >= -1e-12, "would create a short position (spot account)") | |
| 118 | + check("position_notional", abs(new_pos) * ref <= L.max_position_notional, "position notional limit") | |
| 119 | + if intent.side == "buy": | |
| 120 | + check("min_cash", pf.cash - notional >= L.min_cash, "insufficient cash after order") | |
| 121 | + | |
| 122 | + eq = max(pf.equity, 1e-9) | |
| 123 | + gross = sum(abs(q) * pf.marks.get(s, ref if s == intent.symbol else 0.0) for s, q in pf.positions.items() if s != intent.symbol) + abs(new_pos) * ref | |
| 124 | + check("gross_exposure", gross / eq <= L.max_gross_exposure + 1e-9, f"gross exposure {gross / eq:.2f} > {L.max_gross_exposure}") | |
| 125 | + check("concentration", abs(new_pos) * ref / eq <= L.max_concentration + 1e-9, f"concentration > {L.max_concentration}") | |
| 126 | + if pf.day_start_equity: | |
| 127 | + check("daily_loss", (pf.equity / pf.day_start_equity - 1) > -L.daily_loss_limit, "daily loss limit hit") | |
| 128 | + if pf.peak_equity: | |
| 129 | + check("drawdown", (pf.equity / pf.peak_equity - 1) > -L.max_drawdown_limit, "drawdown limit hit") | |
| 130 | + if mkt.recent_bar_volume: | |
| 131 | + check("participation", intent.qty <= L.max_participation * mkt.recent_bar_volume, "order exceeds participation cap") | |
| 132 | + if L.var_limit_95 is not None and pf.var_95 is not None: | |
| 133 | + check("var", pf.var_95 <= L.var_limit_95, "VaR limit") | |
| 134 | + except Exception as e: # noqa: BLE001 — default-deny on internal error | |
| 135 | + reasons.append(f"risk gateway error: {e}") | |
| 136 | + checks["internal"] = False | |
| 137 | + # Sells that *reduce* an existing long are allowed even when loss/exposure limits are breached (de-risking); | |
| 138 | + # every other failed check blocks. Buys are never exempt. | |
| 139 | + reducing = intent.side == "sell" and pf is not None and pf.positions.get(intent.symbol, 0.0) > 0 | |
| 140 | + exempt = {"daily_loss", "drawdown", "position_notional", "concentration", "gross_exposure", "var"} if reducing else set() | |
| 141 | + failed = [name for name, ok in checks.items() if not ok and name not in exempt] | |
| 142 | + return RiskDecision(allowed=not failed, reasons=reasons, checks=checks) | |
added
apps/api/ktrade/strategy/__init__.py
+4 −0
@@ -0,0 +1,4 @@ | ||
| 1 | +from ktrade.strategy.base import ParamSpec, Strategy, StrategyMeta | |
| 2 | +from ktrade.strategy.registry import get_strategy, list_strategies | |
| 3 | + | |
| 4 | +__all__ = ["ParamSpec", "Strategy", "StrategyMeta", "get_strategy", "list_strategies"] | |
added
apps/api/ktrade/strategy/base.py
+112 −0
@@ -0,0 +1,112 @@ | ||
| 1 | +"""Strategy SDK. | |
| 2 | + | |
| 3 | +Lifecycle (identical in backtest, paper and live engines): | |
| 4 | + 1. `features(df)` — causal feature computation on closed bars (row t may only use rows <= t) | |
| 5 | + 2. `target(feats)` — target exposure in [0, 1] (spot: no shorting unless the exchange account supports it) | |
| 6 | + decided AT THE CLOSE of bar t and executable no earlier than the OPEN of bar t+1 | |
| 7 | + 3. `uncertainty(feats)`— optional per-bar confidence/uncertainty (used for sizing & reporting) | |
| 8 | + 4. risk gating + execution are done by the engine, never by the strategy. | |
| 9 | + | |
| 10 | +Causality is enforced by tests (`tests/test_no_lookahead.py`): for every registered strategy, targets computed | |
| 11 | +on a truncated history must equal targets computed on the full history, bar for bar. | |
| 12 | +""" | |
| 13 | + | |
| 14 | +from __future__ import annotations | |
| 15 | + | |
| 16 | +from abc import ABC, abstractmethod | |
| 17 | +from dataclasses import dataclass, field | |
| 18 | +from typing import Any | |
| 19 | + | |
| 20 | +import numpy as np | |
| 21 | +import pandas as pd | |
| 22 | +from pydantic import BaseModel | |
| 23 | + | |
| 24 | + | |
| 25 | +class ParamSpec(BaseModel): | |
| 26 | + name: str | |
| 27 | + type: str = "int" # int | float | bool | choice | |
| 28 | + default: Any | |
| 29 | + min: float | None = None | |
| 30 | + max: float | None = None | |
| 31 | + step: float | None = None | |
| 32 | + choices: list[Any] | None = None | |
| 33 | + description: str = "" | |
| 34 | + | |
| 35 | + | |
| 36 | +class StrategyMeta(BaseModel): | |
| 37 | + name: str | |
| 38 | + label: str | |
| 39 | + family: str | |
| 40 | + hypothesis: str | |
| 41 | + universe_note: str = "Kraken spot pairs in the configured universe" | |
| 42 | + experimental: bool = True | |
| 43 | + allows_short: bool = False | |
| 44 | + min_bars: int = 50 | |
| 45 | + params: list[ParamSpec] = [] | |
| 46 | + failure_modes: list[str] = [] | |
| 47 | + references: list[str] = [] | |
| 48 | + | |
| 49 | + | |
| 50 | +@dataclass | |
| 51 | +class StrategyResult: | |
| 52 | + features: pd.DataFrame | |
| 53 | + target: pd.Series # exposure fraction in [0,1] (or [-1,1] if shorting allowed), index-aligned with features | |
| 54 | + uncertainty: pd.Series | None = None | |
| 55 | + diagnostics: dict[str, Any] = field(default_factory=dict) | |
| 56 | + | |
| 57 | + | |
| 58 | +class Strategy(ABC): | |
| 59 | + meta: StrategyMeta | |
| 60 | + | |
| 61 | + def __init__(self, params: dict[str, Any] | None = None) -> None: | |
| 62 | + self.params = self.validate_params(params or {}) | |
| 63 | + | |
| 64 | + @classmethod | |
| 65 | + def validate_params(cls, params: dict[str, Any]) -> dict[str, Any]: | |
| 66 | + out: dict[str, Any] = {} | |
| 67 | + for spec in cls.meta.params: | |
| 68 | + v = params.get(spec.name, spec.default) | |
| 69 | + if spec.type == "int": | |
| 70 | + v = int(v) | |
| 71 | + elif spec.type == "float": | |
| 72 | + v = float(v) | |
| 73 | + elif spec.type == "bool": | |
| 74 | + v = bool(v) if not isinstance(v, str) else v.lower() in ("1", "true", "yes") | |
| 75 | + elif spec.type == "choice" and spec.choices is not None and v not in spec.choices: | |
| 76 | + raise ValueError(f"{spec.name}: {v!r} not in {spec.choices}") | |
| 77 | + if spec.min is not None and isinstance(v, int | float) and v < spec.min: | |
| 78 | + raise ValueError(f"{spec.name}: {v} < min {spec.min}") | |
| 79 | + if spec.max is not None and isinstance(v, int | float) and v > spec.max: | |
| 80 | + raise ValueError(f"{spec.name}: {v} > max {spec.max}") | |
| 81 | + out[spec.name] = v | |
| 82 | + unknown = set(params) - {s.name for s in cls.meta.params} | |
| 83 | + if unknown: | |
| 84 | + raise ValueError(f"unknown params: {sorted(unknown)}") | |
| 85 | + return out | |
| 86 | + | |
| 87 | + @abstractmethod | |
| 88 | + def features(self, df: pd.DataFrame) -> pd.DataFrame: ... | |
| 89 | + | |
| 90 | + @abstractmethod | |
| 91 | + def target(self, feats: pd.DataFrame) -> pd.Series: ... | |
| 92 | + | |
| 93 | + def uncertainty(self, feats: pd.DataFrame) -> pd.Series | None: | |
| 94 | + return None | |
| 95 | + | |
| 96 | + def run(self, df: pd.DataFrame) -> StrategyResult: | |
| 97 | + feats = self.features(df) | |
| 98 | + tgt = self.target(feats).astype(float) | |
| 99 | + lo = -1.0 if self.meta.allows_short else 0.0 | |
| 100 | + tgt = tgt.clip(lo, 1.0).fillna(0.0) | |
| 101 | + return StrategyResult(features=feats, target=tgt, uncertainty=self.uncertainty(feats)) | |
| 102 | + | |
| 103 | + | |
| 104 | +def log_returns(close: pd.Series) -> pd.Series: | |
| 105 | + return np.log(close).diff() | |
| 106 | + | |
| 107 | + | |
| 108 | +def vol_target_scale(ret: pd.Series, lookback: int, target_annual_vol: float, bars_per_year: float, cap: float = 1.0) -> pd.Series: | |
| 109 | + """Position scale so that realized vol ≈ target; uses only trailing returns (shifted to be causal at bar close).""" | |
| 110 | + rv = ret.rolling(lookback, min_periods=max(5, lookback // 2)).std(ddof=1) * np.sqrt(bars_per_year) | |
| 111 | + scale = (target_annual_vol / rv.replace(0, np.nan)).clip(upper=cap) | |
| 112 | + return scale.fillna(0.0) | |
added
apps/api/ktrade/strategy/benchmarks.py
+61 −0
@@ -0,0 +1,61 @@ | ||
| 1 | +"""Benchmarks: buy-and-hold, cash, simple momentum, equal-risk (constant vol target).""" | |
| 2 | + | |
| 3 | +from __future__ import annotations | |
| 4 | + | |
| 5 | +import pandas as pd | |
| 6 | + | |
| 7 | +from ktrade.strategy.base import ParamSpec, Strategy, StrategyMeta, log_returns, vol_target_scale | |
| 8 | + | |
| 9 | + | |
| 10 | +class BuyAndHold(Strategy): | |
| 11 | + meta = StrategyMeta(name="buy_and_hold", label="Buy & hold (benchmark)", family="benchmark", hypothesis="Fully invested from the first bar.", experimental=False, min_bars=2, params=[]) | |
| 12 | + | |
| 13 | + def features(self, df: pd.DataFrame) -> pd.DataFrame: | |
| 14 | + return pd.DataFrame({"ts": df["ts"].values, "close": df["close"].values}, index=df.index) | |
| 15 | + | |
| 16 | + def target(self, feats: pd.DataFrame) -> pd.Series: | |
| 17 | + return pd.Series(1.0, index=feats.index) | |
| 18 | + | |
| 19 | + | |
| 20 | +class Cash(Strategy): | |
| 21 | + meta = StrategyMeta(name="cash", label="Cash (benchmark)", family="benchmark", hypothesis="Never invested; zero return, zero risk (ignores interest).", experimental=False, min_bars=1, params=[]) | |
| 22 | + | |
| 23 | + def features(self, df: pd.DataFrame) -> pd.DataFrame: | |
| 24 | + return pd.DataFrame({"ts": df["ts"].values, "close": df["close"].values}, index=df.index) | |
| 25 | + | |
| 26 | + def target(self, feats: pd.DataFrame) -> pd.Series: | |
| 27 | + return pd.Series(0.0, index=feats.index) | |
| 28 | + | |
| 29 | + | |
| 30 | +class SimpleMomentum(Strategy): | |
| 31 | + meta = StrategyMeta( | |
| 32 | + name="simple_momentum", label="Simple momentum (benchmark)", family="benchmark", | |
| 33 | + hypothesis="Long when the trailing N-bar return is positive, otherwise cash. No vol scaling.", experimental=False, min_bars=30, | |
| 34 | + params=[ParamSpec(name="lookback", type="int", default=24, min=2, max=2000)], | |
| 35 | + ) | |
| 36 | + | |
| 37 | + def features(self, df: pd.DataFrame) -> pd.DataFrame: | |
| 38 | + f = pd.DataFrame({"ts": df["ts"].values, "close": df["close"].values}, index=df.index) | |
| 39 | + f["mom"] = df["close"] / df["close"].shift(self.params["lookback"]) - 1 | |
| 40 | + return f | |
| 41 | + | |
| 42 | + def target(self, feats: pd.DataFrame) -> pd.Series: | |
| 43 | + return (feats["mom"] > 0).astype(float).where(feats["mom"].notna(), 0.0) | |
| 44 | + | |
| 45 | + | |
| 46 | +class EqualRisk(Strategy): | |
| 47 | + meta = StrategyMeta( | |
| 48 | + name="equal_risk", label="Constant-vol long (benchmark)", family="benchmark", | |
| 49 | + hypothesis="Always long, sized to a constant annualized volatility target (inverse-vol).", experimental=False, min_bars=60, | |
| 50 | + params=[ParamSpec(name="vol_lookback", type="int", default=48, min=5, max=1000), ParamSpec(name="target_vol", type="float", default=0.40, min=0.01, max=3.0)], | |
| 51 | + ) | |
| 52 | + | |
| 53 | + def features(self, df: pd.DataFrame) -> pd.DataFrame: | |
| 54 | + f = pd.DataFrame({"ts": df["ts"].values, "close": df["close"].values}, index=df.index) | |
| 55 | + f["ret"] = log_returns(df["close"]) | |
| 56 | + bars_per_year = 365 * 24 * 60 / float(df.attrs.get("interval_min", 60)) | |
| 57 | + f["vol_scale"] = vol_target_scale(f["ret"], self.params["vol_lookback"], self.params["target_vol"], bars_per_year) | |
| 58 | + return f | |
| 59 | + | |
| 60 | + def target(self, feats: pd.DataFrame) -> pd.Series: | |
| 61 | + return feats["vol_scale"].fillna(0.0) | |
added
apps/api/ktrade/strategy/meanrev.py
+94 −0
@@ -0,0 +1,94 @@ | ||
| 1 | +"""Mean reversion: z-score of price vs rolling mean with OU half-life estimate and a trend-regime filter.""" | |
| 2 | + | |
| 3 | +from __future__ import annotations | |
| 4 | + | |
| 5 | +import numpy as np | |
| 6 | +import pandas as pd | |
| 7 | + | |
| 8 | +from ktrade.strategy.base import ParamSpec, Strategy, StrategyMeta, log_returns | |
| 9 | + | |
| 10 | + | |
| 11 | +def ou_half_life(x: pd.Series) -> float: | |
| 12 | + """Half-life from an AR(1) fit of Δx on x_{t-1}: HL = -ln2 / ln(1+β). Returns nan when not mean-reverting.""" | |
| 13 | + x = x.dropna() | |
| 14 | + if len(x) < 20: | |
| 15 | + return float("nan") | |
| 16 | + dx = x.diff().dropna() | |
| 17 | + xl = x.shift(1).dropna().loc[dx.index] | |
| 18 | + xl_c = xl - xl.mean() | |
| 19 | + denom = float((xl_c**2).sum()) | |
| 20 | + if denom == 0: | |
| 21 | + return float("nan") | |
| 22 | + beta = float((xl_c * (dx - dx.mean())).sum() / denom) | |
| 23 | + if beta >= 0: | |
| 24 | + return float("nan") | |
| 25 | + return float(-np.log(2) / np.log(1 + beta)) | |
| 26 | + | |
| 27 | + | |
| 28 | +class ZScoreMeanReversion(Strategy): | |
| 29 | + meta = StrategyMeta( | |
| 30 | + name="zscore_mr", | |
| 31 | + label="Z-score mean reversion (OU half-life, regime-filtered)", | |
| 32 | + family="mean_reversion", | |
| 33 | + hypothesis="Short-horizon deviations of price from its rolling mean revert when the series is locally stationary " | |
| 34 | + "(OU half-life short and finite). Enter long when z < -entry (oversold) only if the longer trend is not strongly negative; exit at z > -exit. " | |
| 35 | + "Spot-only: the short leg (z > +entry) is not traded. Entries are skipped when the expected reversion is smaller than round-trip costs.", | |
| 36 | + experimental=True, | |
| 37 | + min_bars=200, | |
| 38 | + params=[ | |
| 39 | + ParamSpec(name="window", type="int", default=48, min=10, max=2000, description="Rolling mean/std window"), | |
| 40 | + ParamSpec(name="entry_z", type="float", default=2.0, min=0.5, max=5.0), | |
| 41 | + ParamSpec(name="exit_z", type="float", default=0.5, min=0.0, max=3.0), | |
| 42 | + ParamSpec(name="max_half_life", type="float", default=48.0, min=1.0, max=5000.0, description="Skip when OU half-life (bars) exceeds this"), | |
| 43 | + ParamSpec(name="hl_window", type="int", default=240, min=30, max=5000), | |
| 44 | + ParamSpec(name="trend_filter", type="int", default=240, min=0, max=5000, description="EMA span; no entries when price < EMA (0 = off)"), | |
| 45 | + ParamSpec(name="cost_bps", type="float", default=60.0, min=0.0, max=500.0, description="Round-trip cost threshold for entry"), | |
| 46 | + ], | |
| 47 | + failure_modes=["Trending breakdown (catching falling knives)", "Half-life estimate noisy on short windows", "Costs dominate on 1-min bars"], | |
| 48 | + references=["Uhlenbeck & Ornstein (1930)", "Chan (2013) Algorithmic Trading, ch. 2"], | |
| 49 | + ) | |
| 50 | + | |
| 51 | + def features(self, df: pd.DataFrame) -> pd.DataFrame: | |
| 52 | + p = self.params | |
| 53 | + f = pd.DataFrame(index=df.index) | |
| 54 | + f["ts"] = df["ts"].values | |
| 55 | + f["close"] = df["close"].values | |
| 56 | + lc = np.log(df["close"]) | |
| 57 | + f["ret"] = log_returns(df["close"]) | |
| 58 | + m = lc.rolling(p["window"], min_periods=p["window"]).mean() | |
| 59 | + s = lc.rolling(p["window"], min_periods=p["window"]).std(ddof=1) | |
| 60 | + f["z"] = (lc - m) / s.replace(0, np.nan) | |
| 61 | + f["half_life"] = lc.rolling(p["hl_window"], min_periods=p["hl_window"]).apply(ou_half_life, raw=False) | |
| 62 | + f["expected_rev_bps"] = (-f["z"] * s * 1e4).clip(lower=0) | |
| 63 | + if p["trend_filter"] > 0: | |
| 64 | + ema = df["close"].ewm(span=p["trend_filter"], adjust=False, min_periods=p["trend_filter"]).mean() | |
| 65 | + f["trend_ok"] = (df["close"] >= ema * 0.97).astype(float).where(ema.notna()) | |
| 66 | + else: | |
| 67 | + f["trend_ok"] = 1.0 | |
| 68 | + return f | |
| 69 | + | |
| 70 | + def target(self, feats: pd.DataFrame) -> pd.Series: | |
| 71 | + p = self.params | |
| 72 | + z = feats["z"].values | |
| 73 | + hl = feats["half_life"].values | |
| 74 | + ok = feats["trend_ok"].values | |
| 75 | + erv = feats["expected_rev_bps"].values | |
| 76 | + pos = np.zeros(len(z)) | |
| 77 | + holding = 0.0 | |
| 78 | + for i in range(len(z)): | |
| 79 | + if np.isnan(z[i]): | |
| 80 | + pos[i] = 0.0 | |
| 81 | + holding = 0.0 | |
| 82 | + continue | |
| 83 | + if holding == 0.0: | |
| 84 | + if z[i] < -p["entry_z"] and (np.isnan(hl[i]) is False and hl[i] <= p["max_half_life"]) and ok[i] == 1.0 and erv[i] >= p["cost_bps"]: | |
| 85 | + holding = 1.0 | |
| 86 | + else: | |
| 87 | + if z[i] > -p["exit_z"]: | |
| 88 | + holding = 0.0 | |
| 89 | + pos[i] = holding | |
| 90 | + return pd.Series(pos, index=feats.index) | |
| 91 | + | |
| 92 | + def uncertainty(self, feats: pd.DataFrame) -> pd.Series | None: | |
| 93 | + hl = feats["half_life"] | |
| 94 | + return (hl / self.params["max_half_life"]).clip(0, 1).fillna(1.0) | |
added
apps/api/ktrade/strategy/momentum.py
+94 −0
@@ -0,0 +1,94 @@ | ||
| 1 | +"""Time-series momentum with volatility targeting (long-only spot variant).""" | |
| 2 | + | |
| 3 | +from __future__ import annotations | |
| 4 | + | |
| 5 | +import numpy as np | |
| 6 | +import pandas as pd | |
| 7 | + | |
| 8 | +from ktrade.strategy.base import ParamSpec, Strategy, StrategyMeta, log_returns, vol_target_scale | |
| 9 | + | |
| 10 | + | |
| 11 | +class TimeSeriesMomentum(Strategy): | |
| 12 | + meta = StrategyMeta( | |
| 13 | + name="tsmom", | |
| 14 | + label="Time-series momentum (vol-targeted)", | |
| 15 | + family="trend", | |
| 16 | + hypothesis="Crypto returns exhibit medium-horizon autocorrelation (Moskowitz–Ooi–Pedersen style). Go long when the trailing " | |
| 17 | + "lookback return is positive and the fast EMA is above the slow EMA; scale by inverse realized volatility; stay in cash otherwise. " | |
| 18 | + "Spot-only: no shorting, so the strategy captures only the long leg of the premium.", | |
| 19 | + experimental=True, | |
| 20 | + allows_short=False, | |
| 21 | + min_bars=120, | |
| 22 | + params=[ | |
| 23 | + ParamSpec(name="lookback", type="int", default=48, min=5, max=2000, description="Bars for the trailing return signal"), | |
| 24 | + ParamSpec(name="ema_fast", type="int", default=20, min=2, max=500), | |
| 25 | + ParamSpec(name="ema_slow", type="int", default=80, min=5, max=2000), | |
| 26 | + ParamSpec(name="vol_lookback", type="int", default=48, min=5, max=1000), | |
| 27 | + ParamSpec(name="target_vol", type="float", default=0.40, min=0.01, max=3.0, description="Annualized vol target"), | |
| 28 | + ParamSpec(name="max_leverage", type="float", default=1.0, min=0.0, max=1.0, description="Max exposure (spot → ≤1)"), | |
| 29 | + ], | |
| 30 | + failure_modes=["Whipsaw in range-bound regimes", "Fee drag at high turnover on short intervals", "Vol targeting lags regime shifts", "Only long leg available on spot"], | |
| 31 | + references=["Moskowitz, Ooi, Pedersen (2012) Time series momentum, JFE"], | |
| 32 | + ) | |
| 33 | + | |
| 34 | + def features(self, df: pd.DataFrame) -> pd.DataFrame: | |
| 35 | + p = self.params | |
| 36 | + f = pd.DataFrame(index=df.index) | |
| 37 | + f["ts"] = df["ts"].values | |
| 38 | + f["close"] = df["close"].values | |
| 39 | + f["ret"] = log_returns(df["close"]) | |
| 40 | + f["mom"] = np.log(df["close"] / df["close"].shift(p["lookback"])) | |
| 41 | + f["ema_fast"] = df["close"].ewm(span=p["ema_fast"], adjust=False, min_periods=p["ema_fast"]).mean() | |
| 42 | + f["ema_slow"] = df["close"].ewm(span=p["ema_slow"], adjust=False, min_periods=p["ema_slow"]).mean() | |
| 43 | + bars_per_year = 365 * 24 * 60 / float(df.attrs.get("interval_min", 60)) | |
| 44 | + f["vol_scale"] = vol_target_scale(f["ret"], p["vol_lookback"], p["target_vol"], bars_per_year, cap=p["max_leverage"]) | |
| 45 | + return f | |
| 46 | + | |
| 47 | + def target(self, feats: pd.DataFrame) -> pd.Series: | |
| 48 | + signal = ((feats["mom"] > 0) & (feats["ema_fast"] > feats["ema_slow"])).astype(float) | |
| 49 | + return (signal * feats["vol_scale"]).fillna(0.0) | |
| 50 | + | |
| 51 | + def uncertainty(self, feats: pd.DataFrame) -> pd.Series | None: | |
| 52 | + # |momentum| in units of rolling vol → weak signals near zero are the uncertain ones | |
| 53 | + z = feats["mom"] / (feats["ret"].rolling(self.params["lookback"], min_periods=5).std(ddof=1) * np.sqrt(self.params["lookback"])) | |
| 54 | + return (1.0 / (1.0 + z.abs())).fillna(1.0) | |
| 55 | + | |
| 56 | + | |
| 57 | +class MovingAverageEnsemble(Strategy): | |
| 58 | + meta = StrategyMeta( | |
| 59 | + name="ma_ensemble", | |
| 60 | + label="Moving-average crossover ensemble", | |
| 61 | + family="trend", | |
| 62 | + hypothesis="An ensemble of EMA crossover rules at several horizons (vote fraction) reduces the parameter-fragility of a single " | |
| 63 | + "crossover while keeping trend exposure. Exposure = fraction of rules that are bullish, volatility-scaled.", | |
| 64 | + experimental=True, | |
| 65 | + min_bars=200, | |
| 66 | + params=[ | |
| 67 | + ParamSpec(name="fast_spans", type="choice", default="8,16,32", choices=["4,8,16", "8,16,32", "10,20,40"]), | |
| 68 | + ParamSpec(name="slow_mult", type="int", default=3, min=2, max=10), | |
| 69 | + ParamSpec(name="vol_lookback", type="int", default=48, min=5, max=1000), | |
| 70 | + ParamSpec(name="target_vol", type="float", default=0.40, min=0.01, max=3.0), | |
| 71 | + ], | |
| 72 | + failure_modes=["Lag at turning points", "Choppy markets"], | |
| 73 | + ) | |
| 74 | + | |
| 75 | + def features(self, df: pd.DataFrame) -> pd.DataFrame: | |
| 76 | + p = self.params | |
| 77 | + f = pd.DataFrame(index=df.index) | |
| 78 | + f["ts"] = df["ts"].values | |
| 79 | + f["close"] = df["close"].values | |
| 80 | + f["ret"] = log_returns(df["close"]) | |
| 81 | + votes = [] | |
| 82 | + for s in [int(x) for x in p["fast_spans"].split(",")]: | |
| 83 | + fast = df["close"].ewm(span=s, adjust=False, min_periods=s).mean() | |
| 84 | + slow = df["close"].ewm(span=s * p["slow_mult"], adjust=False, min_periods=s * p["slow_mult"]).mean() | |
| 85 | + col = f"x_{s}" | |
| 86 | + f[col] = (fast > slow).astype(float).where(slow.notna()) | |
| 87 | + votes.append(col) | |
| 88 | + f["vote"] = f[votes].mean(axis=1) | |
| 89 | + bars_per_year = 365 * 24 * 60 / float(df.attrs.get("interval_min", 60)) | |
| 90 | + f["vol_scale"] = vol_target_scale(f["ret"], p["vol_lookback"], p["target_vol"], bars_per_year) | |
| 91 | + return f | |
| 92 | + | |
| 93 | + def target(self, feats: pd.DataFrame) -> pd.Series: | |
| 94 | + return (feats["vote"] * feats["vol_scale"]).fillna(0.0) | |
added
apps/api/ktrade/strategy/registry.py
+24 −0
@@ -0,0 +1,24 @@ | ||
| 1 | +from __future__ import annotations | |
| 2 | + | |
| 3 | +from ktrade.strategy.base import Strategy | |
| 4 | +from ktrade.strategy.benchmarks import BuyAndHold, Cash, EqualRisk, SimpleMomentum | |
| 5 | +from ktrade.strategy.meanrev import ZScoreMeanReversion | |
| 6 | +from ktrade.strategy.momentum import MovingAverageEnsemble, TimeSeriesMomentum | |
| 7 | + | |
| 8 | +_REGISTRY: dict[str, type[Strategy]] = { | |
| 9 | + c.meta.name: c for c in (TimeSeriesMomentum, MovingAverageEnsemble, ZScoreMeanReversion, BuyAndHold, Cash, SimpleMomentum, EqualRisk) | |
| 10 | +} | |
| 11 | + | |
| 12 | + | |
| 13 | +def list_strategies() -> list[dict]: | |
| 14 | + return [c.meta.model_dump() for c in _REGISTRY.values()] | |
| 15 | + | |
| 16 | + | |
| 17 | +def get_strategy(name: str) -> type[Strategy]: | |
| 18 | + if name not in _REGISTRY: | |
| 19 | + raise KeyError(f"unknown strategy {name!r}; known: {sorted(_REGISTRY)}") | |
| 20 | + return _REGISTRY[name] | |
| 21 | + | |
| 22 | + | |
| 23 | +def strategy_names() -> list[str]: | |
| 24 | + return list(_REGISTRY) | |
added
apps/api/ktrade/workers/__init__.py
+0 −0
added
apps/api/ktrade/workers/ingest.py
+66 −0
@@ -0,0 +1,66 @@ | ||
| 1 | +"""Ingestion worker: pair discovery (hourly), OHLC accumulation (every N s), WS streaming, retention.""" | |
| 2 | + | |
| 3 | +from __future__ import annotations | |
| 4 | + | |
| 5 | +import asyncio | |
| 6 | +import time | |
| 7 | + | |
| 8 | +import redis.asyncio as aioredis | |
| 9 | + | |
| 10 | +from ktrade.config import Settings | |
| 11 | +from ktrade.db.pool import create_pool | |
| 12 | +from ktrade.ingestion.backfill import backfill_all | |
| 13 | +from ktrade.ingestion.pairs import refresh_pairs | |
| 14 | +from ktrade.ingestion.stream import StreamIngestor | |
| 15 | +from ktrade.kraken.models import PairMeta | |
| 16 | +from ktrade.kraken.rest import KrakenREST | |
| 17 | +from ktrade.logging import get_logger | |
| 18 | + | |
| 19 | +log = get_logger("worker.ingest") | |
| 20 | + | |
| 21 | + | |
| 22 | +async def run_worker(s: Settings) -> None: | |
| 23 | + pool = await create_pool(s.database_url, max_size=8) | |
| 24 | + r = aioredis.from_url(s.redis_url, decode_responses=True) | |
| 25 | + rest = KrakenREST(s.kraken_rest_url, tier=s.kraken_tier) # public endpoints only — no credentials in the ingest worker | |
| 26 | + pairs: dict[str, PairMeta] = {} | |
| 27 | + while not pairs: | |
| 28 | + try: | |
| 29 | + pairs = await refresh_pairs(rest, pool, s.universe) | |
| 30 | + except Exception as e: # noqa: BLE001 | |
| 31 | + log.warning("worker.pairs.retry", error=str(e)) | |
| 32 | + await asyncio.sleep(10) | |
| 33 | + # Kraken WS v2 accepts a single ohlc interval per symbol per connection → stream 1-min bars only; REST accumulates the rest. | |
| 34 | + stream = StreamIngestor(pool, r, s.kraken_ws_url, pairs, s.book_depth, [1]) | |
| 35 | + | |
| 36 | + async def backfill_loop() -> None: | |
| 37 | + while True: | |
| 38 | + t0 = time.time() | |
| 39 | + try: | |
| 40 | + res = await backfill_all(rest, pool, pairs, s.candle_intervals) | |
| 41 | + await r.set("ktrade:backfill:last", str(time.time())) | |
| 42 | + log.info("worker.backfill.done", pairs=len(pairs), rows=sum(res.values()), seconds=round(time.time() - t0, 1)) | |
| 43 | + except Exception as e: # noqa: BLE001 | |
| 44 | + log.warning("worker.backfill.error", error=str(e)) | |
| 45 | + await asyncio.sleep(max(5, s.backfill_poll_seconds - (time.time() - t0))) | |
| 46 | + | |
| 47 | + async def pairs_loop() -> None: | |
| 48 | + while True: | |
| 49 | + await asyncio.sleep(3600) | |
| 50 | + try: | |
| 51 | + new = await refresh_pairs(rest, pool, s.universe) | |
| 52 | + pairs.update(new) | |
| 53 | + except Exception as e: # noqa: BLE001 | |
| 54 | + log.warning("worker.pairs.error", error=str(e)) | |
| 55 | + | |
| 56 | + async def retention_loop() -> None: | |
| 57 | + while True: | |
| 58 | + try: | |
| 59 | + await pool.execute("DELETE FROM book_samples WHERE ts < $1", int(time.time()) - 14 * 86400) | |
| 60 | + await pool.execute("DELETE FROM trades WHERE ts < $1", time.time() - 30 * 86400) | |
| 61 | + except Exception as e: # noqa: BLE001 | |
| 62 | + log.warning("worker.retention.error", error=str(e)) | |
| 63 | + await asyncio.sleep(3600) | |
| 64 | + | |
| 65 | + log.info("worker.started", universe=list(pairs), intervals=s.candle_intervals) | |
| 66 | + await asyncio.gather(backfill_loop(), pairs_loop(), retention_loop(), stream.run()) | |
added
apps/api/migrations/0001_init.sql
+111 −0
@@ -0,0 +1,111 @@ | ||
| 1 | +-- ktrade schema v1 (Phase 1 + Phase 2). Timescale hypertables are created only if the extension exists. | |
| 2 | +CREATE TABLE IF NOT EXISTS schema_migrations (version TEXT PRIMARY KEY, applied_at TIMESTAMPTZ NOT NULL DEFAULT now()); | |
| 3 | + | |
| 4 | +CREATE TABLE IF NOT EXISTS pairs ( | |
| 5 | + pair_id TEXT PRIMARY KEY, | |
| 6 | + altname TEXT NOT NULL, | |
| 7 | + wsname TEXT NOT NULL UNIQUE, | |
| 8 | + base TEXT NOT NULL, quote TEXT NOT NULL, | |
| 9 | + status TEXT NOT NULL DEFAULT 'unknown', | |
| 10 | + ordermin NUMERIC NOT NULL DEFAULT 0, costmin NUMERIC NOT NULL DEFAULT 0, tick_size NUMERIC NOT NULL DEFAULT 0, | |
| 11 | + pair_decimals INT NOT NULL DEFAULT 8, lot_decimals INT NOT NULL DEFAULT 8, cost_decimals INT NOT NULL DEFAULT 8, | |
| 12 | + leverage_buy JSONB NOT NULL DEFAULT '[]', leverage_sell JSONB NOT NULL DEFAULT '[]', | |
| 13 | + in_universe BOOLEAN NOT NULL DEFAULT false, | |
| 14 | + raw JSONB NOT NULL DEFAULT '{}', | |
| 15 | + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() | |
| 16 | +); | |
| 17 | + | |
| 18 | +-- Bars keyed by OPEN time (unix seconds). A bar is usable by a strategy only at ts + interval*60. | |
| 19 | +CREATE TABLE IF NOT EXISTS candles ( | |
| 20 | + symbol TEXT NOT NULL, interval_min INT NOT NULL, ts BIGINT NOT NULL, | |
| 21 | + open DOUBLE PRECISION NOT NULL, high DOUBLE PRECISION NOT NULL, low DOUBLE PRECISION NOT NULL, close DOUBLE PRECISION NOT NULL, | |
| 22 | + vwap DOUBLE PRECISION NOT NULL, volume DOUBLE PRECISION NOT NULL, count INT NOT NULL, | |
| 23 | + closed BOOLEAN NOT NULL DEFAULT true, | |
| 24 | + source TEXT NOT NULL DEFAULT 'rest', | |
| 25 | + ingested_at TIMESTAMPTZ NOT NULL DEFAULT now(), | |
| 26 | + PRIMARY KEY (symbol, interval_min, ts) | |
| 27 | +); | |
| 28 | +CREATE INDEX IF NOT EXISTS candles_sym_iv_ts_desc ON candles (symbol, interval_min, ts DESC); | |
| 29 | + | |
| 30 | +CREATE TABLE IF NOT EXISTS trades ( | |
| 31 | + symbol TEXT NOT NULL, trade_id BIGINT NOT NULL, ts DOUBLE PRECISION NOT NULL, | |
| 32 | + price DOUBLE PRECISION NOT NULL, volume DOUBLE PRECISION NOT NULL, | |
| 33 | + side TEXT NOT NULL, order_type TEXT NOT NULL, | |
| 34 | + received_at DOUBLE PRECISION, | |
| 35 | + PRIMARY KEY (symbol, trade_id) | |
| 36 | +); | |
| 37 | +CREATE INDEX IF NOT EXISTS trades_sym_ts ON trades (symbol, ts DESC); | |
| 38 | + | |
| 39 | +CREATE TABLE IF NOT EXISTS ticker_latest ( | |
| 40 | + symbol TEXT PRIMARY KEY, payload JSONB NOT NULL, exchange_ts DOUBLE PRECISION, received_at DOUBLE PRECISION NOT NULL | |
| 41 | +); | |
| 42 | +CREATE TABLE IF NOT EXISTS book_latest ( | |
| 43 | + symbol TEXT PRIMARY KEY, payload JSONB NOT NULL, exchange_ts TEXT, received_at DOUBLE PRECISION NOT NULL, valid BOOLEAN NOT NULL DEFAULT true | |
| 44 | +); | |
| 45 | +-- 1-second sampled microstructure series (spread, depth, imbalance) | |
| 46 | +CREATE TABLE IF NOT EXISTS book_samples ( | |
| 47 | + symbol TEXT NOT NULL, ts BIGINT NOT NULL, | |
| 48 | + best_bid DOUBLE PRECISION, best_ask DOUBLE PRECISION, spread_bps DOUBLE PRECISION, | |
| 49 | + bid_depth DOUBLE PRECISION, ask_depth DOUBLE PRECISION, imbalance DOUBLE PRECISION, | |
| 50 | + PRIMARY KEY (symbol, ts) | |
| 51 | +); | |
| 52 | + | |
| 53 | +CREATE TABLE IF NOT EXISTS ingestion_state (key TEXT PRIMARY KEY, value JSONB NOT NULL, updated_at TIMESTAMPTZ NOT NULL DEFAULT now()); | |
| 54 | + | |
| 55 | +CREATE TABLE IF NOT EXISTS data_gaps ( | |
| 56 | + id BIGSERIAL PRIMARY KEY, symbol TEXT NOT NULL, interval_min INT NOT NULL, | |
| 57 | + gap_start BIGINT NOT NULL, gap_end BIGINT NOT NULL, missing_bars INT NOT NULL, | |
| 58 | + detected_at TIMESTAMPTZ NOT NULL DEFAULT now(), | |
| 59 | + UNIQUE (symbol, interval_min, gap_start) | |
| 60 | +); | |
| 61 | + | |
| 62 | +CREATE TABLE IF NOT EXISTS backtest_runs ( | |
| 63 | + id UUID PRIMARY KEY, | |
| 64 | + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), | |
| 65 | + finished_at TIMESTAMPTZ, | |
| 66 | + status TEXT NOT NULL DEFAULT 'queued', | |
| 67 | + strategy TEXT NOT NULL, symbol TEXT NOT NULL, interval_min INT NOT NULL, | |
| 68 | + params JSONB NOT NULL DEFAULT '{}', cost_model JSONB NOT NULL DEFAULT '{}', | |
| 69 | + start_ts BIGINT, end_ts BIGINT, | |
| 70 | + metrics JSONB, provenance JSONB, artifacts JSONB, | |
| 71 | + error TEXT, | |
| 72 | + label TEXT | |
| 73 | +); | |
| 74 | +CREATE INDEX IF NOT EXISTS backtest_runs_created ON backtest_runs (created_at DESC); | |
| 75 | + | |
| 76 | +-- Append-only, hash-chained audit trail (prev_hash → hash). No UPDATE/DELETE is ever issued by the app. | |
| 77 | +CREATE TABLE IF NOT EXISTS audit_log ( | |
| 78 | + id BIGSERIAL PRIMARY KEY, | |
| 79 | + ts TIMESTAMPTZ NOT NULL DEFAULT now(), | |
| 80 | + actor TEXT NOT NULL, action TEXT NOT NULL, entity TEXT, details JSONB NOT NULL DEFAULT '{}', | |
| 81 | + cid TEXT, prev_hash TEXT, hash TEXT NOT NULL | |
| 82 | +); | |
| 83 | + | |
| 84 | +CREATE TABLE IF NOT EXISTS risk_state (key TEXT PRIMARY KEY, value JSONB NOT NULL, updated_at TIMESTAMPTZ NOT NULL DEFAULT now()); | |
| 85 | +INSERT INTO risk_state (key, value) VALUES ('kill_switch', '{"active": false, "reason": null, "since": null}') ON CONFLICT DO NOTHING; | |
| 86 | +INSERT INTO risk_state (key, value) VALUES ('limits', '{}') ON CONFLICT DO NOTHING; | |
| 87 | + | |
| 88 | +-- Paper-trading ledger (Phase 4 uses it; created now so the schema is stable) | |
| 89 | +CREATE TABLE IF NOT EXISTS paper_orders ( | |
| 90 | + client_order_id TEXT PRIMARY KEY, created_at DOUBLE PRECISION NOT NULL, | |
| 91 | + strategy TEXT, symbol TEXT NOT NULL, side TEXT NOT NULL, order_type TEXT NOT NULL, | |
| 92 | + qty NUMERIC NOT NULL, limit_price NUMERIC, status TEXT NOT NULL, filled_qty NUMERIC NOT NULL DEFAULT 0, | |
| 93 | + avg_fill_price NUMERIC, fee NUMERIC NOT NULL DEFAULT 0, reason TEXT, risk_decision JSONB, updated_at DOUBLE PRECISION NOT NULL | |
| 94 | +); | |
| 95 | +CREATE TABLE IF NOT EXISTS paper_fills ( | |
| 96 | + id BIGSERIAL PRIMARY KEY, client_order_id TEXT NOT NULL REFERENCES paper_orders(client_order_id), | |
| 97 | + ts DOUBLE PRECISION NOT NULL, symbol TEXT NOT NULL, side TEXT NOT NULL, qty NUMERIC NOT NULL, price NUMERIC NOT NULL, fee NUMERIC NOT NULL, liquidity TEXT NOT NULL | |
| 98 | +); | |
| 99 | +CREATE TABLE IF NOT EXISTS paper_positions ( | |
| 100 | + symbol TEXT PRIMARY KEY, qty NUMERIC NOT NULL DEFAULT 0, avg_cost NUMERIC NOT NULL DEFAULT 0, realized_pnl NUMERIC NOT NULL DEFAULT 0, updated_at DOUBLE PRECISION NOT NULL | |
| 101 | +); | |
| 102 | +CREATE TABLE IF NOT EXISTS paper_account (key TEXT PRIMARY KEY, value JSONB NOT NULL, updated_at TIMESTAMPTZ NOT NULL DEFAULT now()); | |
| 103 | +CREATE TABLE IF NOT EXISTS paper_equity (ts BIGINT PRIMARY KEY, equity DOUBLE PRECISION NOT NULL, cash DOUBLE PRECISION NOT NULL, exposure DOUBLE PRECISION NOT NULL); | |
| 104 | + | |
| 105 | +DO $$ | |
| 106 | +BEGIN | |
| 107 | + IF EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'timescaledb') THEN | |
| 108 | + PERFORM create_hypertable('candles', 'ts', chunk_time_interval => 2592000, if_not_exists => TRUE, migrate_data => TRUE); | |
| 109 | + PERFORM create_hypertable('book_samples', 'ts', chunk_time_interval => 604800, if_not_exists => TRUE, migrate_data => TRUE); | |
| 110 | + END IF; | |
| 111 | +END $$; | |
added
apps/api/pyproject.toml
+64 −0
@@ -0,0 +1,64 @@ | ||
| 1 | +[project] | |
| 2 | +name = "ktrade" | |
| 3 | +version = "0.1.0" | |
| 4 | +description = "Kraken quantitative trading research, backtesting, paper-trading and execution platform (API + workers)" | |
| 5 | +requires-python = ">=3.12,<3.14" | |
| 6 | +dependencies = [ | |
| 7 | + "fastapi>=0.115", | |
| 8 | + "uvicorn[standard]>=0.30", | |
| 9 | + "pydantic>=2.8", | |
| 10 | + "pydantic-settings>=2.4", | |
| 11 | + "asyncpg>=0.29", | |
| 12 | + "redis>=5.0", | |
| 13 | + "httpx>=0.27", | |
| 14 | + "websockets>=13.0", | |
| 15 | + "structlog>=24.4", | |
| 16 | + "orjson>=3.10", | |
| 17 | + "numpy>=1.26,<3", | |
| 18 | + "pandas>=2.2", | |
| 19 | + "scipy>=1.13", | |
| 20 | + "statsmodels>=0.14", | |
| 21 | + "itsdangerous>=2.2", | |
| 22 | + "bcrypt>=4.2", | |
| 23 | + "prometheus-client>=0.20", | |
| 24 | + "typer>=0.12", | |
| 25 | + "python-multipart>=0.0.9", | |
| 26 | +] | |
| 27 | + | |
| 28 | +[project.optional-dependencies] | |
| 29 | +dev = [ | |
| 30 | + "pytest>=8.3", | |
| 31 | + "pytest-asyncio>=0.24", | |
| 32 | + "hypothesis>=6.112", | |
| 33 | + "ruff>=0.6", | |
| 34 | + "mypy>=1.11", | |
| 35 | + "respx>=0.21", | |
| 36 | +] | |
| 37 | + | |
| 38 | +[project.scripts] | |
| 39 | +ktrade = "ktrade.cli:app" | |
| 40 | + | |
| 41 | +[build-system] | |
| 42 | +requires = ["hatchling"] | |
| 43 | +build-backend = "hatchling.build" | |
| 44 | + | |
| 45 | +[tool.hatch.build.targets.wheel] | |
| 46 | +packages = ["ktrade"] | |
| 47 | + | |
| 48 | +[tool.pytest.ini_options] | |
| 49 | +asyncio_mode = "auto" | |
| 50 | +testpaths = ["tests"] | |
| 51 | + | |
| 52 | +[tool.ruff] | |
| 53 | +line-length = 110 | |
| 54 | +target-version = "py312" | |
| 55 | + | |
| 56 | +[tool.ruff.lint] | |
| 57 | +select = ["E", "F", "I", "B", "UP", "ASYNC"] | |
| 58 | +ignore = ["E501", "B008"] | |
| 59 | + | |
| 60 | +[tool.mypy] | |
| 61 | +plugins = ["pydantic.mypy"] | |
| 62 | +python_version = "3.12" | |
| 63 | +ignore_missing_imports = true | |
| 64 | +check_untyped_defs = true | |
added
apps/api/tests/__init__.py
+0 −0
added
apps/api/tests/conftest.py
+28 −0
@@ -0,0 +1,28 @@ | ||
| 1 | +import os | |
| 2 | + | |
| 3 | +import numpy as np | |
| 4 | +import pandas as pd | |
| 5 | +import pytest | |
| 6 | + | |
| 7 | +os.environ.setdefault("KTRADE_DATABASE_URL", "postgresql://ktrade:ktrade@127.0.0.1:5432/ktrade_test") | |
| 8 | +os.environ.setdefault("KTRADE_REDIS_URL", "redis://127.0.0.1:6379/15") | |
| 9 | +os.environ.setdefault("KTRADE_SECRET_KEY", "test-secret") | |
| 10 | +os.environ.setdefault("KTRADE_LOG_JSON", "false") | |
| 11 | + | |
| 12 | + | |
| 13 | +def synthetic_candles(n: int = 2000, seed: int = 1, interval_min: int = 60, start_ts: int = 1_700_000_000, drift: float = 0.0002, vol: float = 0.01) -> pd.DataFrame: | |
| 14 | + rng = np.random.default_rng(seed) | |
| 15 | + r = rng.normal(drift, vol, n) | |
| 16 | + close = 30000 * np.exp(np.cumsum(r)) | |
| 17 | + open_ = np.concatenate([[close[0]], close[:-1]]) * (1 + rng.normal(0, 0.0005, n)) | |
| 18 | + high = np.maximum(open_, close) * (1 + np.abs(rng.normal(0, 0.002, n))) | |
| 19 | + low = np.minimum(open_, close) * (1 - np.abs(rng.normal(0, 0.002, n))) | |
| 20 | + vol_ = np.abs(rng.normal(50, 10, n)) | |
| 21 | + df = pd.DataFrame({"ts": start_ts + np.arange(n) * interval_min * 60, "open": open_, "high": high, "low": low, "close": close, "vwap": (high + low + close) / 3, "volume": vol_, "count": rng.integers(10, 500, n)}) | |
| 22 | + df.attrs["interval_min"] = interval_min | |
| 23 | + return df | |
| 24 | + | |
| 25 | + | |
| 26 | +@pytest.fixture | |
| 27 | +def candles() -> pd.DataFrame: | |
| 28 | + return synthetic_candles() | |
added
apps/api/tests/test_api.py
+104 −0
@@ -0,0 +1,104 @@ | ||
| 1 | +"""Integration tests against a local Postgres (ktrade_test) and Redis db 15. Kraken calls are mocked.""" | |
| 2 | + | |
| 3 | +import os | |
| 4 | + | |
| 5 | +import httpx | |
| 6 | +import pytest | |
| 7 | +import respx | |
| 8 | + | |
| 9 | +from ktrade.api.auth import hash_password | |
| 10 | +from ktrade.db.pool import dsn, migrate | |
| 11 | + | |
| 12 | +pytestmark = pytest.mark.skipif(os.environ.get("KTRADE_SKIP_DB") == "1", reason="db tests disabled") | |
| 13 | + | |
| 14 | + | |
| 15 | +@pytest.fixture | |
| 16 | +async def client(monkeypatch): | |
| 17 | + import asyncpg | |
| 18 | + | |
| 19 | + from ktrade.config import get_settings | |
| 20 | + | |
| 21 | + get_settings.cache_clear() | |
| 22 | + monkeypatch.setenv("KTRADE_OWNER_PASSWORD_HASH", hash_password("hunter2")) | |
| 23 | + monkeypatch.setenv("KTRADE_APP_BASE_URL", "http://testserver") | |
| 24 | + url = os.environ["KTRADE_DATABASE_URL"] | |
| 25 | + conn = await asyncpg.connect(dsn(url)) | |
| 26 | + await conn.execute("DROP SCHEMA public CASCADE; CREATE SCHEMA public") | |
| 27 | + await conn.close() | |
| 28 | + await migrate(url) | |
| 29 | + from ktrade.api.main import create_app | |
| 30 | + | |
| 31 | + app = create_app() | |
| 32 | + async with app.router.lifespan_context(app): | |
| 33 | + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://testserver") as c: | |
| 34 | + yield c, app | |
| 35 | + get_settings.cache_clear() | |
| 36 | + | |
| 37 | + | |
| 38 | +async def test_health_and_auth_flow(client): | |
| 39 | + c, app = client | |
| 40 | + r = await c.get("/api/health") | |
| 41 | + assert r.status_code == 200 and r.json()["db"] == "ok" | |
| 42 | + assert (await c.get("/api/pairs")).status_code == 401 | |
| 43 | + r = await c.post("/api/auth/login", json={"password": "wrong"}, headers={"origin": "http://testserver"}) | |
| 44 | + assert r.status_code == 401 | |
| 45 | + r = await c.post("/api/auth/login", json={"password": "hunter2"}, headers={"origin": "http://testserver"}) | |
| 46 | + assert r.status_code == 200 and "ktrade_session" in r.cookies | |
| 47 | + assert (await c.get("/api/auth/me")).json()["authenticated"] is True | |
| 48 | + # CSRF: mutation without same-origin header is rejected | |
| 49 | + r = await c.post("/api/risk/kill-switch", json={"active": True}) | |
| 50 | + assert r.status_code == 403 | |
| 51 | + r = await c.post("/api/risk/kill-switch", json={"active": True, "reason": "test"}, headers={"origin": "http://testserver"}) | |
| 52 | + assert r.status_code == 200 and r.json()["active"] is True | |
| 53 | + audit = (await c.get("/api/audit")).json() | |
| 54 | + assert audit["chain_valid"] and any(a["action"] == "risk.kill_switch" for a in audit["items"]) | |
| 55 | + | |
| 56 | + | |
| 57 | +async def test_backtest_end_to_end(client): | |
| 58 | + import asyncio | |
| 59 | + | |
| 60 | + from tests.conftest import synthetic_candles | |
| 61 | + | |
| 62 | + c, app = client | |
| 63 | + st = app.state.ktrade | |
| 64 | + await c.post("/api/auth/login", json={"password": "hunter2"}, headers={"origin": "http://testserver"}) | |
| 65 | + df = synthetic_candles(n=1200, seed=21) | |
| 66 | + await st.pool.execute("INSERT INTO pairs (pair_id, altname, wsname, base, quote, status, in_universe, lot_decimals, ordermin, costmin) VALUES ('XXBTZUSD','XBTUSD','BTC/USD','XXBT','ZUSD','online',true,8,0.0001,0.5)") | |
| 67 | + await st.pool.executemany("INSERT INTO candles (symbol, interval_min, ts, open, high, low, close, vwap, volume, count, closed) VALUES ('BTC/USD',60,$1,$2,$3,$4,$5,$6,$7,$8,true)", | |
| 68 | + [(int(r.ts), r.open, r.high, r.low, r.close, r.vwap, r.volume, int(r.count)) for r in df.itertuples()]) | |
| 69 | + r = await c.get("/api/market/candles", params={"symbol": "BTC/USD", "interval": 60, "limit": 10}) | |
| 70 | + assert len(r.json()["items"]) == 10 | |
| 71 | + r = await c.post("/api/backtests", json={"strategy": "tsmom", "symbol": "BTC/USD", "interval_min": 60, "holdout_frac": 0.2, "sensitivity": {"x": "lookback", "x_values": [24, 48], "y": "ema_slow", "y_values": [60, 80]}}, headers={"origin": "http://testserver"}) | |
| 72 | + assert r.status_code == 202 | |
| 73 | + rid = r.json()["id"] | |
| 74 | + for _ in range(100): | |
| 75 | + await asyncio.sleep(0.2) | |
| 76 | + run = (await c.get(f"/api/backtests/{rid}")).json() | |
| 77 | + if run["status"] in ("done", "failed"): | |
| 78 | + break | |
| 79 | + assert run["status"] == "done", run.get("error") | |
| 80 | + assert run["metrics"]["bars"] > 0 and "sharpe" in run["metrics"] | |
| 81 | + assert run["provenance"]["dataset_hash"] and run["artifacts"]["benchmarks"]["buy_and_hold"] | |
| 82 | + assert run["artifacts"]["holdout"]["metrics"]["bars"] > 0 | |
| 83 | + assert run["artifacts"]["sensitivity"]["z"] and run["artifacts"]["stress"]["fees_x2"] | |
| 84 | + r = await c.get(f"/api/backtests/{rid}/trades.csv") | |
| 85 | + assert r.status_code == 200 | |
| 86 | + r = await c.post("/api/backtests", json={"strategy": "tsmom", "symbol": "BTC/USD", "params": {"nope": 1}}, headers={"origin": "http://testserver"}) | |
| 87 | + assert r.status_code == 422 | |
| 88 | + | |
| 89 | + | |
| 90 | +@respx.mock | |
| 91 | +async def test_rest_client_parsing(): | |
| 92 | + from ktrade.kraken.rest import KrakenREST | |
| 93 | + | |
| 94 | + respx.get("https://api.kraken.com/0/public/OHLC").mock(return_value=httpx.Response(200, json={"error": [], "result": {"XXBTZUSD": [[1700000000, "1", "2", "0.5", "1.5", "1.2", "10", 3]], "last": 1700000000}})) | |
| 95 | + respx.get("https://api.kraken.com/0/public/AssetPairs").mock(return_value=httpx.Response(200, json={"error": [], "result": {"XXBTZUSD": {"altname": "XBTUSD", "wsname": "XBT/USD", "base": "XXBT", "quote": "ZUSD", "ordermin": "0.0001", "costmin": "0.5", "tick_size": "0.1", "pair_decimals": 1, "lot_decimals": 8, "status": "online"}}})) | |
| 96 | + respx.get("https://api.kraken.com/0/public/Time").mock(return_value=httpx.Response(200, json={"error": ["EAPI:Rate limit exceeded"]})) | |
| 97 | + rest = KrakenREST(public_min_interval=0) | |
| 98 | + candles, last = await rest.ohlc("XXBTZUSD", 1) | |
| 99 | + assert candles[0].close == 1.5 and last == 1700000000 and candles[0].closed_at(1) == 1700000060 | |
| 100 | + pairs = await rest.asset_pairs() | |
| 101 | + assert pairs["XXBTZUSD"].wsname == "BTC/USD" and float(pairs["XXBTZUSD"].ordermin) == 0.0001 | |
| 102 | + with pytest.raises(Exception, match="Rate limit"): | |
| 103 | + await rest.time() | |
| 104 | + await rest.aclose() | |
added
apps/api/tests/test_backtest_engine.py
+88 −0
@@ -0,0 +1,88 @@ | ||
| 1 | +import numpy as np | |
| 2 | +import pandas as pd | |
| 3 | + | |
| 4 | +from ktrade.backtest.costs import CostModel, round_lot | |
| 5 | +from ktrade.backtest.engine import run_backtest | |
| 6 | +from ktrade.backtest.metrics import compute_metrics | |
| 7 | +from ktrade.strategy.base import ParamSpec, Strategy, StrategyMeta | |
| 8 | +from ktrade.strategy.benchmarks import BuyAndHold | |
| 9 | +from tests.conftest import synthetic_candles | |
| 10 | + | |
| 11 | + | |
| 12 | +class StepAt(Strategy): | |
| 13 | + meta = StrategyMeta(name="step", label="step", family="test", hypothesis="go long at bar k", min_bars=1, params=[ParamSpec(name="k", type="int", default=10)]) | |
| 14 | + | |
| 15 | + def features(self, df): | |
| 16 | + return pd.DataFrame({"ts": df["ts"].values}, index=df.index) | |
| 17 | + | |
| 18 | + def target(self, feats): | |
| 19 | + t = pd.Series(0.0, index=feats.index) | |
| 20 | + t.iloc[self.params["k"]:] = 1.0 | |
| 21 | + return t | |
| 22 | + | |
| 23 | + | |
| 24 | +def test_execution_happens_at_next_open_not_same_bar(): | |
| 25 | + df = synthetic_candles(n=50, seed=2) | |
| 26 | + k = 10 | |
| 27 | + res = run_backtest(StepAt({"k": k}), df, interval_min=60, cost_model=CostModel(spread_bps=0, slippage_bps=0, impact_coeff=0, taker_fee=0, max_participation=10)) | |
| 28 | + assert len(res.trades) == 1 | |
| 29 | + t = res.trades[0] | |
| 30 | + assert t.ts == int(df["ts"].iloc[k + 1]) # filled on bar k+1 | |
| 31 | + assert t.ref_price == df["open"].iloc[k + 1] # at its OPEN | |
| 32 | + assert res.equity["position"].iloc[k] == 0.0 # no position on the decision bar | |
| 33 | + assert res.equity["position"].iloc[k + 1] > 0 | |
| 34 | + | |
| 35 | + | |
| 36 | +def test_latency_adds_bars(): | |
| 37 | + df = synthetic_candles(n=50, seed=2) | |
| 38 | + res = run_backtest(StepAt({"k": 10}), df, interval_min=60, cost_model=CostModel(latency_bars=2, taker_fee=0, spread_bps=0, slippage_bps=0, impact_coeff=0, max_participation=10)) | |
| 39 | + assert res.trades[0].ts == int(df["ts"].iloc[13]) | |
| 40 | + | |
| 41 | + | |
| 42 | +def test_fees_and_slippage_are_charged(): | |
| 43 | + df = synthetic_candles(n=50, seed=2) | |
| 44 | + cm = CostModel(taker_fee=0.004, spread_bps=10, slippage_bps=5, impact_coeff=0, max_participation=10) | |
| 45 | + res = run_backtest(StepAt({"k": 5}), df, interval_min=60, cost_model=cm, initial_cash=10_000) | |
| 46 | + t = res.trades[0] | |
| 47 | + assert t.price > t.ref_price # buy pays half-spread + slippage | |
| 48 | + assert abs(t.fee - t.notional * 0.004) < 1e-9 | |
| 49 | + assert res.fees_paid > 0 and res.slippage_paid > 0 | |
| 50 | + assert abs(t.notional + t.fee - (10_000 - res.equity["cash"].iloc[6])) < 1e-6 | |
| 51 | + | |
| 52 | + | |
| 53 | +def test_partial_fills_respect_participation_cap(): | |
| 54 | + df = synthetic_candles(n=60, seed=2) | |
| 55 | + df["volume"] = 0.01 # tiny bar volume → participation cap binds | |
| 56 | + cm = CostModel(max_participation=0.5, taker_fee=0, spread_bps=0, slippage_bps=0, impact_coeff=0) | |
| 57 | + res = run_backtest(StepAt({"k": 5}), df, interval_min=60, cost_model=cm, initial_cash=10_000) | |
| 58 | + assert all(t.qty <= 0.005 + 1e-12 for t in res.trades) | |
| 59 | + assert any(t.partial for t in res.trades) | |
| 60 | + assert len(res.trades) > 1 # remainder keeps being worked on following bars | |
| 61 | + | |
| 62 | + | |
| 63 | +def test_lot_rounding_and_min_size(): | |
| 64 | + assert round_lot(1.23456789123, 8) == 1.23456789 | |
| 65 | + assert round_lot(0.000000001, 8) == 0.0 | |
| 66 | + df = synthetic_candles(n=40, seed=2) | |
| 67 | + res = run_backtest(StepAt({"k": 5}), df, interval_min=60, initial_cash=1.0, ordermin=0.0001, costmin=0.5, cost_model=CostModel(max_participation=10)) | |
| 68 | + assert res.trades == [] and res.rejected # 1 USD cannot buy ≥ 0.0001 BTC at 30k → rejected by the gateway | |
| 69 | + | |
| 70 | + | |
| 71 | +def test_buy_and_hold_equity_tracks_price(): | |
| 72 | + df = synthetic_candles(n=300, seed=4) | |
| 73 | + cm = CostModel(taker_fee=0, spread_bps=0, slippage_bps=0, impact_coeff=0, max_participation=10) | |
| 74 | + res = run_backtest(BuyAndHold(), df, interval_min=60, cost_model=cm, initial_cash=10_000) | |
| 75 | + entry = df["open"].iloc[1] | |
| 76 | + expected = 10_000 / entry * df["close"].iloc[-1] | |
| 77 | + assert abs(res.equity["equity"].iloc[-1] - expected) / expected < 1e-6 | |
| 78 | + m = compute_metrics(res.equity, res.trades, 60, 10_000, 0, 0, res.turnover) | |
| 79 | + assert m["bars"] == 300 and np.isfinite(m["sharpe"]) and -1 <= m["max_drawdown"] <= 0 | |
| 80 | + | |
| 81 | + | |
| 82 | +def test_never_short_and_never_negative_cash(): | |
| 83 | + from ktrade.strategy.momentum import TimeSeriesMomentum | |
| 84 | + | |
| 85 | + df = synthetic_candles(n=1500, seed=9, vol=0.03) | |
| 86 | + res = run_backtest(TimeSeriesMomentum(), df, interval_min=60) | |
| 87 | + assert (res.equity["position"] >= -1e-12).all() | |
| 88 | + assert (res.equity["cash"] >= -1e-6).all() | |
added
apps/api/tests/test_book_checksum.py
+36 −0
@@ -0,0 +1,36 @@ | ||
| 1 | +from decimal import Decimal | |
| 2 | + | |
| 3 | +from ktrade.kraken.checksum import _enc, book_checksum, fmt_fixed | |
| 4 | +from ktrade.kraken.ws import LocalBook | |
| 5 | + | |
| 6 | + | |
| 7 | +def test_encoding_rules_from_docs(): | |
| 8 | + assert _enc("45285.2") == "452852" | |
| 9 | + assert _enc("0.00100000") == "100000" | |
| 10 | + assert fmt_fixed(Decimal("45285.2"), 1) == "45285.2" | |
| 11 | + assert fmt_fixed(Decimal("0.001"), 8) == "0.00100000" | |
| 12 | + | |
| 13 | + | |
| 14 | +def test_checksum_is_order_independent_and_top10_only(): | |
| 15 | + asks = [(f"{100 + i}.0", "1.00000000") for i in range(15)] | |
| 16 | + bids = [(f"{99 - i}.0", "1.00000000") for i in range(15)] | |
| 17 | + a = book_checksum(asks, bids) | |
| 18 | + b = book_checksum(list(reversed(asks)), list(reversed(bids))) | |
| 19 | + assert a == b | |
| 20 | + # levels beyond the top 10 do not matter | |
| 21 | + assert book_checksum(asks[:10], bids[:10]) == a | |
| 22 | + assert book_checksum(asks[:9], bids[:10]) != a | |
| 23 | + | |
| 24 | + | |
| 25 | +def test_local_book_apply_and_checksum(): | |
| 26 | + b = LocalBook("BTC/USD", depth=10, price_decimals=1, qty_decimals=8) | |
| 27 | + snap = {"bids": [{"price": 100.0, "qty": 1.0}, {"price": 99.5, "qty": 2.0}], "asks": [{"price": 100.5, "qty": 1.5}, {"price": 101.0, "qty": 3.0}], "timestamp": "t"} | |
| 28 | + expected = book_checksum([("100.5", "1.50000000"), ("101.0", "3.00000000")], [("100.0", "1.00000000"), ("99.5", "2.00000000")]) | |
| 29 | + assert b.apply("snapshot", {**snap, "checksum": expected}) | |
| 30 | + # update: remove a level, change another | |
| 31 | + upd = {"bids": [{"price": 99.5, "qty": 0}], "asks": [{"price": 100.5, "qty": 0.5}]} | |
| 32 | + expected2 = book_checksum([("100.5", "0.50000000"), ("101.0", "3.00000000")], [("100.0", "1.00000000")]) | |
| 33 | + assert b.apply("update", {**upd, "checksum": expected2}) | |
| 34 | + assert not b.apply("update", {"bids": [], "asks": [], "checksum": 12345}) | |
| 35 | + top = b.top() | |
| 36 | + assert top["best_bid"] == 100.0 and top["best_ask"] == 100.5 and top["spread"] == 0.5 | |
added
apps/api/tests/test_ingestion.py
+46 −0
@@ -0,0 +1,46 @@ | ||
| 1 | +import os | |
| 2 | + | |
| 3 | +import asyncpg | |
| 4 | +import pytest | |
| 5 | + | |
| 6 | +from ktrade.db.pool import create_pool, dsn, migrate | |
| 7 | +from ktrade.ingestion.backfill import detect_gaps, upsert_candles | |
| 8 | +from ktrade.ingestion.stream import _parse_ts | |
| 9 | +from ktrade.kraken.models import normalize_symbol | |
| 10 | + | |
| 11 | +pytestmark = pytest.mark.skipif(os.environ.get("KTRADE_SKIP_DB") == "1", reason="db tests disabled") | |
| 12 | + | |
| 13 | + | |
| 14 | +def test_symbol_normalisation(): | |
| 15 | + assert normalize_symbol("XBT/USD") == "BTC/USD" | |
| 16 | + assert normalize_symbol("XDG/EUR") == "DOGE/EUR" | |
| 17 | + assert normalize_symbol("ETH/USD") == "ETH/USD" | |
| 18 | + | |
| 19 | + | |
| 20 | +def test_rfc3339_nanoseconds(): | |
| 21 | + assert abs(_parse_ts("2026-10-22T03:11:19.123456789Z") - 1792638679.123456) < 1e-3 | |
| 22 | + | |
| 23 | + | |
| 24 | +async def test_closed_flag_never_regresses_and_gaps_detected(): | |
| 25 | + url = os.environ["KTRADE_DATABASE_URL"] | |
| 26 | + conn = await asyncpg.connect(dsn(url)) | |
| 27 | + await conn.execute("DROP SCHEMA public CASCADE; CREATE SCHEMA public") | |
| 28 | + await conn.close() | |
| 29 | + await migrate(url) | |
| 30 | + pool = await create_pool(url) | |
| 31 | + try: | |
| 32 | + async with pool.acquire() as c: | |
| 33 | + await upsert_candles(c, "T/USD", 1, [(60, 1, 1, 1, 1, 1, 1, 1, True), (120, 1, 1, 1, 2, 1, 1, 1, False)], "rest") | |
| 34 | + # a later poll reports bar 120 as closed with the final close → overwritten | |
| 35 | + await upsert_candles(c, "T/USD", 1, [(120, 1, 1, 1, 3, 1, 1, 1, True)], "ws") | |
| 36 | + # a stale 'forming' snapshot must NOT overwrite a closed bar | |
| 37 | + await upsert_candles(c, "T/USD", 1, [(120, 1, 1, 1, 9, 1, 1, 1, False)], "rest") | |
| 38 | + # gap: bars 180, 240 missing | |
| 39 | + await upsert_candles(c, "T/USD", 1, [(300, 1, 1, 1, 1, 1, 1, 1, True)], "rest") | |
| 40 | + rows = await pool.fetch("SELECT ts, close, closed FROM candles WHERE symbol='T/USD' ORDER BY ts") | |
| 41 | + assert [(r["ts"], r["close"], r["closed"]) for r in rows] == [(60, 1.0, True), (120, 3.0, True), (300, 1.0, True)] | |
| 42 | + assert await detect_gaps(pool, "T/USD", 1) == 1 | |
| 43 | + g = await pool.fetchrow("SELECT gap_start, gap_end, missing_bars FROM data_gaps") | |
| 44 | + assert (g["gap_start"], g["gap_end"], g["missing_bars"]) == (180, 240, 2) | |
| 45 | + finally: | |
| 46 | + await pool.close() | |
added
apps/api/tests/test_kraken_signing.py
+29 −0
@@ -0,0 +1,29 @@ | ||
| 1 | +"""Official test vector from https://docs.kraken.com/api/docs/guides/spot-rest-auth.""" | |
| 2 | + | |
| 3 | +from ktrade.kraken.rest import OHLC_MAX_ROWS, CallCounter, NonceSource, sign | |
| 4 | + | |
| 5 | +SECRET = "kQH5HW/8p1uGOVjbgWA7FunAmGO8lsSUXNsu3eow76sz84Q18fWxnyRzBHCd3pd5nE9qa99HAZtuZuj6F1huXg==" | |
| 6 | + | |
| 7 | + | |
| 8 | +def test_signature_matches_kraken_test_vector(): | |
| 9 | + data = {"nonce": "1616492376594", "ordertype": "limit", "pair": "XBTUSD", "price": 37500, "type": "buy", "volume": 1.25} | |
| 10 | + assert sign("/0/private/AddOrder", data, SECRET) == "4/dpxb3iT4tp/ZCVEwSnEsLxx0bqyhLpdfOpc6fn7OR8+UClSV5n9E6aSS8MPtnRfp32bAb0nmbRn6H8ndwLUQ==" | |
| 11 | + | |
| 12 | + | |
| 13 | +def test_signature_raw_body_variant(): | |
| 14 | + body = "nonce=1616492376594&ordertype=limit&pair=XBTUSD&price=37500&type=buy&volume=1.25" | |
| 15 | + assert sign("/0/private/AddOrder", body, SECRET, nonce="1616492376594") == "4/dpxb3iT4tp/ZCVEwSnEsLxx0bqyhLpdfOpc6fn7OR8+UClSV5n9E6aSS8MPtnRfp32bAb0nmbRn6H8ndwLUQ==" | |
| 16 | + | |
| 17 | + | |
| 18 | +async def test_nonce_strictly_increasing(): | |
| 19 | + ns = NonceSource() | |
| 20 | + vals = [int(await ns.next()) for _ in range(200)] | |
| 21 | + assert all(b > a for a, b in zip(vals, vals[1:], strict=False)) | |
| 22 | + | |
| 23 | + | |
| 24 | +async def test_call_counter_throttles(): | |
| 25 | + c = CallCounter("starter", safety=0.5) # budget 7.5 points | |
| 26 | + for _ in range(7): | |
| 27 | + await c.acquire(1) | |
| 28 | + assert c.points >= 6.5 | |
| 29 | + assert OHLC_MAX_ROWS == 720 | |
added
apps/api/tests/test_metrics_validation.py
+50 −0
@@ -0,0 +1,50 @@ | ||
| 1 | +import math | |
| 2 | + | |
| 3 | +import numpy as np | |
| 4 | +import pandas as pd | |
| 5 | + | |
| 6 | +from ktrade.backtest.metrics import block_bootstrap_ci, deflated_sharpe, drawdown_series | |
| 7 | +from ktrade.backtest.validation import param_grid, walk_forward | |
| 8 | +from ktrade.strategy.meanrev import ou_half_life | |
| 9 | +from ktrade.strategy.momentum import TimeSeriesMomentum | |
| 10 | +from tests.conftest import synthetic_candles | |
| 11 | + | |
| 12 | + | |
| 13 | +def test_drawdown(): | |
| 14 | + dd = drawdown_series(pd.Series([100, 110, 99, 120, 60])) | |
| 15 | + assert math.isclose(dd.min(), 0.5 - 1) | |
| 16 | + | |
| 17 | + | |
| 18 | +def test_ou_half_life_detects_mean_reversion_and_random_walk(): | |
| 19 | + rng = np.random.default_rng(0) | |
| 20 | + x = np.zeros(2000) | |
| 21 | + for i in range(1, 2000): | |
| 22 | + x[i] = 0.9 * x[i - 1] + rng.normal() | |
| 23 | + hl = ou_half_life(pd.Series(x)) | |
| 24 | + assert 4 < hl < 10 # theory: -ln2/ln(0.9) ≈ 6.6 | |
| 25 | + rw = pd.Series(np.cumsum(rng.normal(size=2000))) | |
| 26 | + assert math.isnan(ou_half_life(rw)) or ou_half_life(rw) > 100 | |
| 27 | + | |
| 28 | + | |
| 29 | +def test_bootstrap_ci_contains_point_estimate(): | |
| 30 | + rng = np.random.default_rng(1) | |
| 31 | + r = rng.normal(0.0005, 0.01, 2000) | |
| 32 | + bpy = 365 * 24 | |
| 33 | + ci = block_bootstrap_ci(r, bpy, n_boot=200) | |
| 34 | + s = r.mean() / r.std(ddof=1) * math.sqrt(bpy) | |
| 35 | + assert ci["sharpe_ci"][0] <= s <= ci["sharpe_ci"][1] | |
| 36 | + | |
| 37 | + | |
| 38 | +def test_deflated_sharpe_decreases_with_trials(): | |
| 39 | + a = deflated_sharpe(1.5, 2000, 365 * 24, 1, 0.0, 0.0)["dsr"] | |
| 40 | + b = deflated_sharpe(1.5, 2000, 365 * 24, 100, 0.0, 0.0)["dsr"] | |
| 41 | + assert a > b | |
| 42 | + | |
| 43 | + | |
| 44 | +def test_walk_forward_produces_oos_folds(): | |
| 45 | + df = synthetic_candles(n=1500, seed=11, drift=0.0005) | |
| 46 | + wf = walk_forward(TimeSeriesMomentum, df, interval_min=60, grid={"lookback": [24, 48], "ema_fast": [10], "ema_slow": [40]}, n_folds=3) | |
| 47 | + assert len(wf["folds"]) == 3 and wf["n_trials"] == 2 | |
| 48 | + for f in wf["folds"]: | |
| 49 | + assert f["train"][1] < f["test"][0] # OOS strictly after IS | |
| 50 | + assert len(param_grid(TimeSeriesMomentum, {"lookback": [1, 24]})) == 1 # 1 < min → dropped | |
added
apps/api/tests/test_no_lookahead.py
+26 −0
@@ -0,0 +1,26 @@ | ||
| 1 | +"""Causality proof: for every registered strategy, the target at bar t computed on data[:t+1] equals the target | |
| 2 | +computed on the full series. Any use of future rows (centered windows, shift(-k), global normalisation…) fails.""" | |
| 3 | + | |
| 4 | +import numpy as np | |
| 5 | +import pytest | |
| 6 | + | |
| 7 | +from ktrade.strategy.registry import _REGISTRY | |
| 8 | +from tests.conftest import synthetic_candles | |
| 9 | + | |
| 10 | + | |
| 11 | +@pytest.mark.parametrize("name", sorted(_REGISTRY)) | |
| 12 | +def test_targets_are_causal(name): | |
| 13 | + cls = _REGISTRY[name] | |
| 14 | + df = synthetic_candles(n=700, seed=3) | |
| 15 | + full = cls().run(df).target.to_numpy() | |
| 16 | + for cut in (320, 450, 600, 699): | |
| 17 | + part = cls().run(df.iloc[: cut + 1].copy()).target.to_numpy() | |
| 18 | + assert np.allclose(np.nan_to_num(part), np.nan_to_num(full[: cut + 1]), atol=1e-9), f"{name}: look-ahead at bar {cut}" | |
| 19 | + | |
| 20 | + | |
| 21 | +@pytest.mark.parametrize("name", sorted(_REGISTRY)) | |
| 22 | +def test_targets_within_bounds(name): | |
| 23 | + cls = _REGISTRY[name] | |
| 24 | + t = cls().run(synthetic_candles(n=400, seed=5)).target | |
| 25 | + lo = -1.0 if cls.meta.allows_short else 0.0 | |
| 26 | + assert ((t >= lo - 1e-12) & (t <= 1.0 + 1e-12)).all() | |
added
apps/api/tests/test_risk_gateway.py
+79 −0
@@ -0,0 +1,79 @@ | ||
| 1 | +from hypothesis import given, settings | |
| 2 | +from hypothesis import strategies as st | |
| 3 | + | |
| 4 | +from ktrade.risk.gateway import MarketState, OrderIntent, PortfolioSnapshot, RiskGateway, RiskLimits | |
| 5 | + | |
| 6 | + | |
| 7 | +def base(): | |
| 8 | + return ( | |
| 9 | + OrderIntent(symbol="BTC/USD", side="buy", qty=0.01), | |
| 10 | + PortfolioSnapshot(equity=10_000, cash=10_000, positions={}, marks={}, peak_equity=10_000, day_start_equity=10_000), | |
| 11 | + MarketState(last_price=30_000, data_age_seconds=1, ordermin=0.0001, costmin=0.5), | |
| 12 | + ) | |
| 13 | + | |
| 14 | + | |
| 15 | +def test_allows_a_sane_order(): | |
| 16 | + i, p, m = base() | |
| 17 | + assert RiskGateway(RiskLimits(max_order_notional=1000, max_position_notional=5000)).evaluate(i, p, m).allowed | |
| 18 | + | |
| 19 | + | |
| 20 | +def test_default_deny_on_missing_inputs(): | |
| 21 | + i, _, _ = base() | |
| 22 | + d = RiskGateway().evaluate(i, None, None) | |
| 23 | + assert not d.allowed and "default-deny" in " ".join(d.reasons) | |
| 24 | + | |
| 25 | + | |
| 26 | +def test_kill_switch_blocks_everything(): | |
| 27 | + i, p, m = base() | |
| 28 | + assert not RiskGateway(kill_switch=True).evaluate(i, p, m).allowed | |
| 29 | + | |
| 30 | + | |
| 31 | +def test_stale_data_blocks(): | |
| 32 | + i, p, m = base() | |
| 33 | + m.data_age_seconds = 999 | |
| 34 | + d = RiskGateway().evaluate(i, p, m) | |
| 35 | + assert not d.allowed and d.checks["data_fresh"] is False | |
| 36 | + | |
| 37 | + | |
| 38 | +def test_min_size_and_notional_limits(): | |
| 39 | + i, p, m = base() | |
| 40 | + i.qty = 0.00001 | |
| 41 | + assert RiskGateway().evaluate(i, p, m).checks["min_size"] is False | |
| 42 | + i.qty = 1.0 # 30k notional | |
| 43 | + assert RiskGateway(RiskLimits(max_order_notional=1000)).evaluate(i, p, m).checks["order_notional"] is False | |
| 44 | + | |
| 45 | + | |
| 46 | +def test_no_short_on_spot(): | |
| 47 | + i, p, m = base() | |
| 48 | + i.side = "sell" | |
| 49 | + assert RiskGateway().evaluate(i, p, m).checks["no_short"] is False | |
| 50 | + | |
| 51 | + | |
| 52 | +def test_reducing_sell_allowed_under_drawdown_but_buy_not(): | |
| 53 | + i, p, m = base() | |
| 54 | + p.positions = {"BTC/USD": 0.05} | |
| 55 | + p.equity = 8_000 | |
| 56 | + p.cash = 6_500 | |
| 57 | + gw = RiskGateway(RiskLimits(max_drawdown_limit=0.10, max_order_notional=5000, max_position_notional=5000)) | |
| 58 | + assert not gw.evaluate(i, p, m).allowed | |
| 59 | + sell = OrderIntent(symbol="BTC/USD", side="sell", qty=0.05) | |
| 60 | + assert gw.evaluate(sell, p, m).allowed | |
| 61 | + | |
| 62 | + | |
| 63 | +def test_unreconciled_ledger_blocks(): | |
| 64 | + i, p, m = base() | |
| 65 | + p.reconciled = False | |
| 66 | + assert not RiskGateway().evaluate(i, p, m).allowed | |
| 67 | + | |
| 68 | + | |
| 69 | +@settings(max_examples=200, deadline=None) | |
| 70 | +@given(qty=st.floats(min_value=0, max_value=10) | st.just(float('nan')) | st.just(float('inf')), cash=st.floats(0, 1e6), price=st.floats(1, 1e6), age=st.floats(0, 1000)) | |
| 71 | +def test_allowed_implies_all_checks_pass(qty, cash, price, age): | |
| 72 | + i = OrderIntent(symbol="X", side="buy", qty=qty) | |
| 73 | + p = PortfolioSnapshot(equity=cash, cash=cash) | |
| 74 | + m = MarketState(last_price=price, data_age_seconds=age) | |
| 75 | + d = RiskGateway().evaluate(i, p, m) | |
| 76 | + if d.allowed: | |
| 77 | + assert all(d.checks.values()) and not d.reasons | |
| 78 | + else: | |
| 79 | + assert d.reasons | |
added
apps/web/.gitignore
+41 −0
@@ -0,0 +1,41 @@ | ||
| 1 | +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. | |
| 2 | + | |
| 3 | +# dependencies | |
| 4 | +/node_modules | |
| 5 | +/.pnp | |
| 6 | +.pnp.* | |
| 7 | +.yarn/* | |
| 8 | +!.yarn/patches | |
| 9 | +!.yarn/plugins | |
| 10 | +!.yarn/releases | |
| 11 | +!.yarn/versions | |
| 12 | + | |
| 13 | +# testing | |
| 14 | +/coverage | |
| 15 | + | |
| 16 | +# next.js | |
| 17 | +/.next/ | |
| 18 | +/out/ | |
| 19 | + | |
| 20 | +# production | |
| 21 | +/build | |
| 22 | + | |
| 23 | +# misc | |
| 24 | +.DS_Store | |
| 25 | +*.pem | |
| 26 | + | |
| 27 | +# debug | |
| 28 | +npm-debug.log* | |
| 29 | +yarn-debug.log* | |
| 30 | +yarn-error.log* | |
| 31 | +.pnpm-debug.log* | |
| 32 | + | |
| 33 | +# env files (can opt-in for committing if needed) | |
| 34 | +.env* | |
| 35 | + | |
| 36 | +# vercel | |
| 37 | +.vercel | |
| 38 | + | |
| 39 | +# typescript | |
| 40 | +*.tsbuildinfo | |
| 41 | +next-env.d.ts | |
added
apps/web/README.md
+38 −0
@@ -0,0 +1,38 @@ | ||
| 1 | +# ktrade web terminal (`apps/web`) | |
| 2 | + | |
| 3 | +Next.js 16 (App Router, TypeScript), Tailwind CSS v4, TanStack Query, TradingView `lightweight-charts`. | |
| 4 | +Dark "trading terminal" UI for the ktrade API (`apps/api`). All data comes from the API; nothing is mocked. | |
| 5 | + | |
| 6 | +## Commands | |
| 7 | + | |
| 8 | +```bash | |
| 9 | +pnpm install | |
| 10 | +pnpm dev -p 8471 # dev server (API must run on 127.0.0.1:8470) | |
| 11 | +pnpm lint && pnpm tsc --noEmit # zero errors expected | |
| 12 | +pnpm build | |
| 13 | +pnpm next start -H 0.0.0.0 -p 8471 # production | |
| 14 | +``` | |
| 15 | + | |
| 16 | +## Environment | |
| 17 | + | |
| 18 | +| Variable | Default | Purpose | | |
| 19 | +|----------------|--------------------------|--------------------------------------------------------------| | |
| 20 | +| `API_UPSTREAM` | `http://127.0.0.1:8470` | Where `/api/:path*` is rewritten to (read at build/start) | | |
| 21 | +| `PORT` | — | Use `-p 8471` on the `next start` command line instead | | |
| 22 | + | |
| 23 | +`/api/*` is proxied by a Next.js rewrite (`next.config.ts`); Server-Sent Events (`/api/market/stream`) stream through it | |
| 24 | +without buffering (verified with `curl -N`), so no custom server is needed. Session cookie `ktrade_session` is checked for | |
| 25 | +presence by `src/proxy.ts` (Next 16 name for middleware); the API validates it on every call and the client redirects to | |
| 26 | +`/login` on 401 (`src/lib/api.ts`). | |
| 27 | + | |
| 28 | +## Pages | |
| 29 | + | |
| 30 | +`/login`, `/` overview, `/market` (candles + volume, live order book with checksum flag, trade tape, microstructure, data | |
| 31 | +quality), `/lab` (strategy catalog → parameter form generated from `ParamSpec`, cost model, holdout, walk-forward, | |
| 32 | +sensitivity), `/backtests` (+ compare up to 8 runs) and `/backtests/[id]` (metrics with bootstrap CI and deflated Sharpe, | |
| 33 | +equity vs benchmarks, underwater, rolling Sharpe, holdout, walk-forward, sensitivity heatmap, stress, shuffled trades, | |
| 34 | +ledger, rejected orders, provenance), `/portfolio` / `/paper` / `/execution` (Phase 3–5 placeholders; live tab locked), | |
| 35 | +`/risk` (limits, kill switch with confirmation, what-if evaluator, risk events, audit chain), `/reports` and | |
| 36 | +`/reports/[id]` (printable research report), `/settings` (credential status, preflight, balances, environment). | |
| 37 | + | |
| 38 | +Screenshots from a real session are in `screenshots/`. | |
added
apps/web/eslint.config.mjs
+18 −0
@@ -0,0 +1,18 @@ | ||
| 1 | +import { defineConfig, globalIgnores } from "eslint/config"; | |
| 2 | +import nextVitals from "eslint-config-next/core-web-vitals"; | |
| 3 | +import nextTs from "eslint-config-next/typescript"; | |
| 4 | + | |
| 5 | +const eslintConfig = defineConfig([ | |
| 6 | + ...nextVitals, | |
| 7 | + ...nextTs, | |
| 8 | + // Override default ignores of eslint-config-next. | |
| 9 | + globalIgnores([ | |
| 10 | + // Default ignores of eslint-config-next: | |
| 11 | + ".next/**", | |
| 12 | + "out/**", | |
| 13 | + "build/**", | |
| 14 | + "next-env.d.ts", | |
| 15 | + ]), | |
| 16 | +]); | |
| 17 | + | |
| 18 | +export default eslintConfig; | |
added
apps/web/next.config.ts
+33 −0
@@ -0,0 +1,33 @@ | ||
| 1 | +import type { NextConfig } from "next"; | |
| 2 | + | |
| 3 | +const API_UPSTREAM = process.env.API_UPSTREAM ?? "http://127.0.0.1:8470"; | |
| 4 | + | |
| 5 | +const nextConfig: NextConfig = { | |
| 6 | + reactStrictMode: true, | |
| 7 | + poweredByHeader: false, | |
| 8 | + turbopack: { | |
| 9 | + rules: { | |
| 10 | + "*.css": { | |
| 11 | + loaders: ["@tailwindcss/turbopack"], | |
| 12 | + as: "*.css", | |
| 13 | + }, | |
| 14 | + }, | |
| 15 | + }, | |
| 16 | + async rewrites() { | |
| 17 | + return [{ source: "/api/:path*", destination: `${API_UPSTREAM}/api/:path*` }]; | |
| 18 | + }, | |
| 19 | + async headers() { | |
| 20 | + return [ | |
| 21 | + { | |
| 22 | + source: "/(.*)", | |
| 23 | + headers: [ | |
| 24 | + { key: "X-Frame-Options", value: "DENY" }, | |
| 25 | + { key: "X-Content-Type-Options", value: "nosniff" }, | |
| 26 | + { key: "Referrer-Policy", value: "same-origin" }, | |
| 27 | + ], | |
| 28 | + }, | |
| 29 | + ]; | |
| 30 | + }, | |
| 31 | +}; | |
| 32 | + | |
| 33 | +export default nextConfig; | |
added
apps/web/package.json
+30 −0
@@ -0,0 +1,30 @@ | ||
| 1 | +{ | |
| 2 | + "name": "web", | |
| 3 | + "version": "0.1.0", | |
| 4 | + "private": true, | |
| 5 | + "scripts": { | |
| 6 | + "dev": "next dev", | |
| 7 | + "build": "next build", | |
| 8 | + "start": "next start", | |
| 9 | + "lint": "eslint" | |
| 10 | + }, | |
| 11 | + "dependencies": { | |
| 12 | + "@tanstack/react-query": "^5.104.5", | |
| 13 | + "lightweight-charts": "^5.2.1", | |
| 14 | + "lucide-react": "^1.60.0", | |
| 15 | + "next": "16.4.2", | |
| 16 | + "react": "19.3.0", | |
| 17 | + "react-dom": "19.3.0" | |
| 18 | + }, | |
| 19 | + "devDependencies": { | |
| 20 | + "@tailwindcss/turbopack": "^4", | |
| 21 | + "@types/node": "^20", | |
| 22 | + "@types/react": "^19", | |
| 23 | + "@types/react-dom": "^19", | |
| 24 | + "eslint": "^9", | |
| 25 | + "eslint-config-next": "16.4.2", | |
| 26 | + "tailwindcss": "^4", | |
| 27 | + "typescript": "^5" | |
| 28 | + }, | |
| 29 | + "packageManager": "pnpm@11.1.2" | |
| 30 | +} | |
added
apps/web/pnpm-lock.yaml
+4229 −0
@@ -0,0 +1,4229 @@ | ||
| 1 | +lockfileVersion: '9.0' | |
| 2 | + | |
| 3 | +settings: | |
| 4 | + autoInstallPeers: true | |
| 5 | + excludeLinksFromLockfile: false | |
| 6 | + | |
| 7 | +importers: | |
| 8 | + | |
| 9 | + .: | |
| 10 | + dependencies: | |
| 11 | + '@tanstack/react-query': | |
| 12 | + specifier: ^5.104.5 | |
| 13 | + version: 5.104.5(@types/react@19.3.0)(react@19.3.0) | |
| 14 | + lightweight-charts: | |
| 15 | + specifier: ^5.2.1 | |
| 16 | + version: 5.2.1 | |
| 17 | + lucide-react: | |
| 18 | + specifier: ^1.60.0 | |
| 19 | + version: 1.60.0(@types/react@19.3.0)(react@19.3.0) | |
| 20 | + next: | |
| 21 | + specifier: 16.4.2 | |
| 22 | + version: 16.4.2(@babel/core@7.29.7)(@types/node@20.19.43)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) | |
| 23 | + react: | |
| 24 | + specifier: 19.3.0 | |
| 25 | + version: 19.3.0 | |
| 26 | + react-dom: | |
| 27 | + specifier: 19.3.0 | |
| 28 | + version: 19.3.0(react@19.3.0) | |
| 29 | + devDependencies: | |
| 30 | + '@tailwindcss/turbopack': | |
| 31 | + specifier: ^4 | |
| 32 | + version: 4.3.3 | |
| 33 | + '@types/node': | |
| 34 | + specifier: ^20 | |
| 35 | + version: 20.19.43 | |
| 36 | + '@types/react': | |
| 37 | + specifier: ^19 | |
| 38 | + version: 19.3.0 | |
| 39 | + '@types/react-dom': | |
| 40 | + specifier: ^19 | |
| 41 | + version: 19.3.0(@types/react@19.3.0) | |
| 42 | + eslint: | |
| 43 | + specifier: ^9 | |
| 44 | + version: 9.39.5(jiti@2.7.0) | |
| 45 | + eslint-config-next: | |
| 46 | + specifier: 16.4.2 | |
| 47 | + version: 16.4.2(@typescript-eslint/parser@8.72.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) | |
| 48 | + tailwindcss: | |
| 49 | + specifier: ^4 | |
| 50 | + version: 4.3.3 | |
| 51 | + typescript: | |
| 52 | + specifier: ^5 | |
| 53 | + version: 5.9.3 | |
| 54 | + | |
| 55 | +packages: | |
| 56 | + | |
| 57 | + '@alloc/quick-lru@5.3.0': | |
| 58 | + resolution: {integrity: sha512-U4+70Pc5ZS9osnCBCE5Jha/ciHM+Yp+CNMNC/7HvYbNRk1Ldd+f7qO65W5qfhu/TCv+/ozljlXXe9Nj8419DMA==} | |
| 59 | + engines: {node: '>=10'} | |
| 60 | + | |
| 61 | + '@babel/code-frame@7.29.7': | |
| 62 | + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} | |
| 63 | + engines: {node: '>=6.9.0'} | |
| 64 | + | |
| 65 | + '@babel/compat-data@7.29.7': | |
| 66 | + resolution: {integrity: sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==} | |
| 67 | + engines: {node: '>=6.9.0'} | |
| 68 | + | |
| 69 | + '@babel/core@7.29.7': | |
| 70 | + resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==} | |
| 71 | + engines: {node: '>=6.9.0'} | |
| 72 | + | |
| 73 | + '@babel/generator@7.29.8': | |
| 74 | + resolution: {integrity: sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==} | |
| 75 | + engines: {node: '>=6.9.0'} | |
| 76 | + | |
| 77 | + '@babel/helper-compilation-targets@7.29.7': | |
| 78 | + resolution: {integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==} | |
| 79 | + engines: {node: '>=6.9.0'} | |
| 80 | + | |
| 81 | + '@babel/helper-globals@7.29.7': | |
| 82 | + resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==} | |
| 83 | + engines: {node: '>=6.9.0'} | |
| 84 | + | |
| 85 | + '@babel/helper-module-imports@7.29.7': | |
| 86 | + resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==} | |
| 87 | + engines: {node: '>=6.9.0'} | |
| 88 | + | |
| 89 | + '@babel/helper-module-transforms@7.29.7': | |
| 90 | + resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==} | |
| 91 | + engines: {node: '>=6.9.0'} | |
| 92 | + peerDependencies: | |
| 93 | + '@babel/core': ^7.0.0 | |
| 94 | + | |
| 95 | + '@babel/helper-string-parser@7.29.7': | |
| 96 | + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} | |
| 97 | + engines: {node: '>=6.9.0'} | |
| 98 | + | |
| 99 | + '@babel/helper-validator-identifier@7.29.7': | |
| 100 | + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} | |
| 101 | + engines: {node: '>=6.9.0'} | |
| 102 | + | |
| 103 | + '@babel/helper-validator-option@7.29.7': | |
| 104 | + resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==} | |
| 105 | + engines: {node: '>=6.9.0'} | |
| 106 | + | |
| 107 | + '@babel/helpers@7.29.10': | |
| 108 | + resolution: {integrity: sha512-EMCx3AGZ3WfAl6ht7AyQoPm2Lh+BGYEMdc4Q6Hf7uSCDOZFuLq44qNwL+iaim8VlyYhRAo4X43+weadK8ZB4Yg==} | |
| 109 | + engines: {node: '>=6.9.0'} | |
| 110 | + | |
| 111 | + '@babel/parser@7.29.9': | |
| 112 | + resolution: {integrity: sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==} | |
| 113 | + engines: {node: '>=6.0.0'} | |
| 114 | + hasBin: true | |
| 115 | + | |
| 116 | + '@babel/template@7.29.7': | |
| 117 | + resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} | |
| 118 | + engines: {node: '>=6.9.0'} | |
| 119 | + | |
| 120 | + '@babel/traverse@7.29.10': | |
| 121 | + resolution: {integrity: sha512-B3lYAs2nkKskbgQ1nfy8Ma9ao9Q1rntTAQHrzzyDuj+TSO+X3le6PfWwqDvodsb+OumE10eMFfuMCTQSQ5iuIQ==} | |
| 122 | + engines: {node: '>=6.9.0'} | |
| 123 | + | |
| 124 | + '@babel/types@7.29.8': | |
| 125 | + resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} | |
| 126 | + engines: {node: '>=6.9.0'} | |
| 127 | + | |
| 128 | + '@emnapi/core@1.10.0': | |
| 129 | + resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} | |
| 130 | + | |
| 131 | + '@emnapi/runtime@1.10.0': | |
| 132 | + resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} | |
| 133 | + | |
| 134 | + '@emnapi/runtime@1.11.3': | |
| 135 | + resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==} | |
| 136 | + | |
| 137 | + '@emnapi/wasi-threads@1.2.1': | |
| 138 | + resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} | |
| 139 | + | |
| 140 | + '@eslint-community/eslint-utils@4.10.1': | |
| 141 | + resolution: {integrity: sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==} | |
| 142 | + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} | |
| 143 | + peerDependencies: | |
| 144 | + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 | |
| 145 | + | |
| 146 | + '@eslint-community/eslint-utils@4.9.1': | |
| 147 | + resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} | |
| 148 | + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} | |
| 149 | + peerDependencies: | |
| 150 | + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 | |
| 151 | + | |
| 152 | + '@eslint-community/regexpp@4.12.2': | |
| 153 | + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} | |
| 154 | + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} | |
| 155 | + | |
| 156 | + '@eslint/config-array@0.21.2': | |
| 157 | + resolution: {integrity: sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==} | |
| 158 | + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} | |
| 159 | + | |
| 160 | + '@eslint/config-helpers@0.4.2': | |
| 161 | + resolution: {integrity: sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==} | |
| 162 | + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} | |
| 163 | + | |
| 164 | + '@eslint/core@0.17.0': | |
| 165 | + resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==} | |
| 166 | + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} | |
| 167 | + | |
| 168 | + '@eslint/eslintrc@3.3.7': | |
| 169 | + resolution: {integrity: sha512-F42g89Qd5oAWtp0k0nnSrjziAKza7w8SVT4mStc18LZMaRb4J1HQAHLCalEtDCxrTuksx7NU9qsmeLwpOfPqWw==} | |
| 170 | + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} | |
| 171 | + | |
| 172 | + '@eslint/js@9.39.5': | |
| 173 | + resolution: {integrity: sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A==} | |
| 174 | + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} | |
| 175 | + | |
| 176 | + '@eslint/object-schema@2.1.7': | |
| 177 | + resolution: {integrity: sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==} | |
| 178 | + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} | |
| 179 | + | |
| 180 | + '@eslint/plugin-kit@0.4.1': | |
| 181 | + resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} | |
| 182 | + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} | |
| 183 | + | |
| 184 | + '@humanfs/core@0.19.2': | |
| 185 | + resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} | |
| 186 | + engines: {node: '>=18.18.0'} | |
| 187 | + | |
| 188 | + '@humanfs/node@0.16.8': | |
| 189 | + resolution: {integrity: sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==} | |
| 190 | + engines: {node: '>=18.18.0'} | |
| 191 | + | |
| 192 | + '@humanfs/types@0.15.0': | |
| 193 | + resolution: {integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==} | |
| 194 | + engines: {node: '>=18.18.0'} | |
| 195 | + | |
| 196 | + '@humanwhocodes/module-importer@1.0.1': | |
| 197 | + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} | |
| 198 | + engines: {node: '>=12.22'} | |
| 199 | + | |
| 200 | + '@humanwhocodes/retry@0.4.3': | |
| 201 | + resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} | |
| 202 | + engines: {node: '>=18.18'} | |
| 203 | + | |
| 204 | + '@img/colour@1.1.0': | |
| 205 | + resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} | |
| 206 | + engines: {node: '>=18'} | |
| 207 | + | |
| 208 | + '@img/sharp-darwin-arm64@0.35.5': | |
| 209 | + resolution: {integrity: sha512-QRUlFQ0WxvdWyqqG/WtI3iupfD5rBzmCHXSdPsY91sAtVtTo7Q4cb6zOccZ3gqEqkr0f1As1ehLqmEpDsRf+lg==} | |
| 210 | + engines: {node: '>=20.9.0'} | |
| 211 | + cpu: [arm64] | |
| 212 | + os: [darwin] | |
| 213 | + | |
| 214 | + '@img/sharp-darwin-x64@0.35.5': | |
| 215 | + resolution: {integrity: sha512-+BR255RhDlpygUpOc/Jdt1nT6DQ3XG/ERo5wbcdOf5Q320dKtPCKPLR1LJs9VGXRaMa8l1uUa0tkCNOXiAxZUw==} | |
| 216 | + engines: {node: '>=20.9.0'} | |
| 217 | + cpu: [x64] | |
| 218 | + os: [darwin] | |
| 219 | + | |
| 220 | + '@img/sharp-freebsd-wasm32@0.35.5': | |
| 221 | + resolution: {integrity: sha512-Y/z91nEZ4uIBX5X3nfTovjU9lHNKFYbL2lpHCLVNmXQK03VIZvXBBt0KxbPGp2SdGSF+2mQU4e+hQaWOt86iAw==} | |
| 222 | + engines: {node: '>=20.9.0'} | |
| 223 | + os: [freebsd] | |
| 224 | + | |
| 225 | + '@img/sharp-libvips-darwin-arm64@1.3.4': | |
| 226 | + resolution: {integrity: sha512-5R89nBYiRdUlSWJxPhO+GVtaXzXSxKnRu/xqMn3KTA3L9EB9Oy/P+Nn2f2vlhPuUdy/Zusb2DarbyTpGCfEDuw==} | |
| 227 | + cpu: [arm64] | |
| 228 | + os: [darwin] | |
| 229 | + | |
| 230 | + '@img/sharp-libvips-darwin-x64@1.3.4': | |
| 231 | + resolution: {integrity: sha512-iR2OKH80yi0U+dUplyh3/xdpFvps6YkCwsXenIJxqxR1v9o+xtKTGbS9H7cps+2Vxjc8B1j96p75NmTGjIhtpQ==} | |
| 232 | + cpu: [x64] | |
| 233 | + os: [darwin] | |
| 234 | + | |
| 235 | + '@img/sharp-libvips-linux-arm64@1.3.4': | |
| 236 | + resolution: {integrity: sha512-Y3dgX/6lE2QhQb+Gxy0WZxfg9MEm/JBjamZpS2IklP7xIQoKN4hzAm7KcMVGtaVDt3neE9OKBC7vAfonA/Lr1A==} | |
| 237 | + cpu: [arm64] | |
| 238 | + os: [linux] | |
| 239 | + libc: [glibc] | |
| 240 | + | |
| 241 | + '@img/sharp-libvips-linux-arm@1.3.4': | |
| 242 | + resolution: {integrity: sha512-LmRtTsOHuvM2+wlO2Db37dx5MiZhB0FvSunciw48YjdOkZz9KAiRbm8ujeMOA1INqmei5NapFxYEK1D1ZSidmw==} | |
| 243 | + cpu: [arm] | |
| 244 | + os: [linux] | |
| 245 | + libc: [glibc] | |
| 246 | + | |
| 247 | + '@img/sharp-libvips-linux-ppc64@1.3.4': | |
| 248 | + resolution: {integrity: sha512-Le6boB8Tai0Nis+gIxIpKx68UDVVIqdR8Tin5Yf1z2LJJQLDJvCDRqRu+jC2qCoD+eIomonmOwB4smBRxfVpYQ==} | |
| 249 | + cpu: [ppc64] | |
| 250 | + os: [linux] | |
| 251 | + libc: [glibc] | |
| 252 | + | |
| 253 | + '@img/sharp-libvips-linux-riscv64@1.3.4': | |
| 254 | + resolution: {integrity: sha512-aHkkIEHPRdQEegJN20MLmGtxYD9R2wQr3Cwpddnu5+YKMt6Uzax7S9h5gpZTo8wyrGuZSlfQ63OevL5mTyOC7Q==} | |
| 255 | + cpu: [riscv64] | |
| 256 | + os: [linux] | |
| 257 | + libc: [glibc] | |
| 258 | + | |
| 259 | + '@img/sharp-libvips-linux-s390x@1.3.4': | |
| 260 | + resolution: {integrity: sha512-ra/mB6MikESDUO7Yg+Mi95bFBb9GsObURuhnOv3OqknjGe9sZrG8tCe9q0xSIGrtLgvgw0gKnFWcK4blSgQOuQ==} | |
| 261 | + cpu: [s390x] | |
| 262 | + os: [linux] | |
| 263 | + libc: [glibc] | |
| 264 | + | |
| 265 | + '@img/sharp-libvips-linux-x64@1.3.4': | |
| 266 | + resolution: {integrity: sha512-GJ//SSXbnwSDes02umB3nDJLFcQzw8a18V8fyhqr6tV515tOEMdImjjxj1AoafMRz56F3PHgftnj1QEKSU1zkw==} | |
| 267 | + cpu: [x64] | |
| 268 | + os: [linux] | |
| 269 | + libc: [glibc] | |
| 270 | + | |
| 271 | + '@img/sharp-libvips-linuxmusl-arm64@1.3.4': | |
| 272 | + resolution: {integrity: sha512-hvulFwtjUcagsis6BBxHwGFwWoNZjgYmULGVrZcyfNbjA8hKILbRxGg15/7w5HDyXHXUos/j6baAWqnCyQ2DWA==} | |
| 273 | + cpu: [arm64] | |
| 274 | + os: [linux] | |
| 275 | + libc: [musl] | |
| 276 | + | |
| 277 | + '@img/sharp-libvips-linuxmusl-x64@1.3.4': | |
| 278 | + resolution: {integrity: sha512-6zXKeE/p39I1AmA3cJG35eyBGNqNddLnUXjhwBnsGjFPWqf5VKkDBEqaEkPDoTEtkxwi2vv8Tcr2mDyP4So7Fg==} | |
| 279 | + cpu: [x64] | |
| 280 | + os: [linux] | |
| 281 | + libc: [musl] | |
| 282 | + | |
| 283 | + '@img/sharp-linux-arm64@0.35.5': | |
| 284 | + resolution: {integrity: sha512-LYVx5JTsOM2CBzmxreh+nl64/3H6Xb09iSLknqH47z2T2DFFxDeFLP5y4dJwe6H7uGQlHPyEEtIqyo3DYsRwdQ==} | |
| 285 | + engines: {node: '>=20.9.0'} | |
| 286 | + cpu: [arm64] | |
| 287 | + os: [linux] | |
| 288 | + libc: [glibc] | |
| 289 | + | |
| 290 | + '@img/sharp-linux-arm@0.35.5': | |
| 291 | + resolution: {integrity: sha512-LEaXK2WdXVK5ykcw0buWyPMsmLLL2vpHLD6yrNSW+JGEL3BZPA4tpKN6iaMc4AxTTAoaX/sU1rOL51lcIz48ZQ==} | |
| 292 | + engines: {node: '>=20.9.0'} | |
| 293 | + cpu: [arm] | |
| 294 | + os: [linux] | |
| 295 | + libc: [glibc] | |
| 296 | + | |
| 297 | + '@img/sharp-linux-ppc64@0.35.5': | |
| 298 | + resolution: {integrity: sha512-QVxAAq8evVRI9ia2vqgwrmWucn5Dfv+JdWzj75pD8omHLPSP7f8p20O8jxzjCcuCEQEOtYOZUmX1hkiZ0kdevA==} | |
| 299 | + engines: {node: '>=20.9.0'} | |
| 300 | + cpu: [ppc64] | |
| 301 | + os: [linux] | |
| 302 | + libc: [glibc] | |
| 303 | + | |
| 304 | + '@img/sharp-linux-riscv64@0.35.5': | |
| 305 | + resolution: {integrity: sha512-LtdreXguaavKODPIfzJ4kffx7UNt1omwtK0rch4EBbbSTXPnxWmYSayXdLJw0fJzQ97kHt1gL/yh4tvU+nCyRQ==} | |
| 306 | + engines: {node: '>=20.9.0'} | |
| 307 | + cpu: [riscv64] | |
| 308 | + os: [linux] | |
| 309 | + libc: [glibc] | |
| 310 | + | |
| 311 | + '@img/sharp-linux-s390x@0.35.5': | |
| 312 | + resolution: {integrity: sha512-UZasTOFiYzotTsGOCu42BfUzP6Tu6Do/947iRm1RsLKvlllxwGcn4RN27LibGWceix4Y+Pmw3jsnTcCQIgWjqA==} | |
| 313 | + engines: {node: '>=20.9.0'} | |
| 314 | + cpu: [s390x] | |
| 315 | + os: [linux] | |
| 316 | + libc: [glibc] | |
| 317 | + | |
| 318 | + '@img/sharp-linux-x64@0.35.5': | |
| 319 | + resolution: {integrity: sha512-SxFtLTeJInhAA9Q836kux2vZNeOBQEx658qvbboZScr0wIARym3IcGmW7KpVD5sbVg0Ojy+udFQdayYIZyoNog==} | |
| 320 | + engines: {node: '>=20.9.0'} | |
| 321 | + cpu: [x64] | |
| 322 | + os: [linux] | |
| 323 | + libc: [glibc] | |
| 324 | + | |
| 325 | + '@img/sharp-linuxmusl-arm64@0.35.5': | |
| 326 | + resolution: {integrity: sha512-9HbMclmI1zlNkFRs3z9/eBtDjfD0sGlrX1z6b1qwmiFY5ElDLh4BC0LPBdVp7z1DXFiKlIcznf+ZlsuZzLxQqg==} | |
| 327 | + engines: {node: '>=20.9.0'} | |
| 328 | + cpu: [arm64] | |
| 329 | + os: [linux] | |
| 330 | + libc: [musl] | |
| 331 | + | |
| 332 | + '@img/sharp-linuxmusl-x64@0.35.5': | |
| 333 | + resolution: {integrity: sha512-4KOphqB035HrVdqLZfCgMzzERrQkkzOwRhl4OAkRO1YCldbaFjySXMaK534Mo0V+LndnlJk+sbUyLeU0ULyD1A==} | |
| 334 | + engines: {node: '>=20.9.0'} | |
| 335 | + cpu: [x64] | |
| 336 | + os: [linux] | |
| 337 | + libc: [musl] | |
| 338 | + | |
| 339 | + '@img/sharp-wasm32@0.35.5': | |
| 340 | + resolution: {integrity: sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ==} | |
| 341 | + engines: {node: '>=20.9.0'} | |
| 342 | + | |
| 343 | + '@img/sharp-webcontainers-wasm32@0.35.5': | |
| 344 | + resolution: {integrity: sha512-hfhF/FmoQyTUkA0bIKFOtw536BQSeBMe6BF6QyWlrPxT754+TFLaZ7sKKTfvvM0yJgKgaYTwnFCIZ/GuDw5SUA==} | |
| 345 | + engines: {node: '>=20.9.0'} | |
| 346 | + cpu: [wasm32] | |
| 347 | + | |
| 348 | + '@img/sharp-win32-arm64@0.35.5': | |
| 349 | + resolution: {integrity: sha512-X4t7g+7ZA5DKblCBEXGjUqqemj4vczING/5viFwAL8h4N3qYeyjwdCvRLHi4EdOUI+2Z7UFlp1VM+p/AuEtm6Q==} | |
| 350 | + engines: {node: '>=20.9.0'} | |
| 351 | + cpu: [arm64] | |
| 352 | + os: [win32] | |
| 353 | + | |
| 354 | + '@img/sharp-win32-ia32@0.35.5': | |
| 355 | + resolution: {integrity: sha512-5Zm82LoBc43nhwNybZlG7Y1KO//Zhsn306fQl29ZOuStHLGTo3BWL83q3cznX0poxSAMuYL1On/BHBxkBeKr6A==} | |
| 356 | + engines: {node: ^20.9.0} | |
| 357 | + cpu: [ia32] | |
| 358 | + os: [win32] | |
| 359 | + | |
| 360 | + '@img/sharp-win32-x64@0.35.5': | |
| 361 | + resolution: {integrity: sha512-x76eH0vEiHlcMQu8Y8IenntaACtddpT6W0wmXtWrnKcnKI7ME5DdgqhAD6SEWOEl1v2zDvkZDhFA9KnURwpfqg==} | |
| 362 | + engines: {node: '>=20.9.0'} | |
| 363 | + cpu: [x64] | |
| 364 | + os: [win32] | |
| 365 | + | |
| 366 | + '@jridgewell/gen-mapping@0.3.13': | |
| 367 | + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} | |
| 368 | + | |
| 369 | + '@jridgewell/remapping@2.3.5': | |
| 370 | + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} | |
| 371 | + | |
| 372 | + '@jridgewell/resolve-uri@3.1.2': | |
| 373 | + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} | |
| 374 | + engines: {node: '>=6.0.0'} | |
| 375 | + | |
| 376 | + '@jridgewell/sourcemap-codec@1.6.0': | |
| 377 | + resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==} | |
| 378 | + | |
| 379 | + '@jridgewell/trace-mapping@0.3.31': | |
| 380 | + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} | |
| 381 | + | |
| 382 | + '@napi-rs/wasm-runtime@1.2.5': | |
| 383 | + resolution: {integrity: sha512-HshSvXzmBxNzqejd3k/KemgQ3hdREUYFRRrgxOZ+gPljDIsO2SDZrVZMd4Z/HsxudQbE62OVyVFE8z9BCurfjA==} | |
| 384 | + engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0} | |
| 385 | + peerDependencies: | |
| 386 | + '@emnapi/core': ^1.7.1 || ^2.0.0-alpha.4 | |
| 387 | + '@emnapi/runtime': ^1.7.1 || ^2.0.0-alpha.4 | |
| 388 | + | |
| 389 | + '@next/env@16.4.2': | |
| 390 | + resolution: {integrity: sha512-nTUTFH7G5/J/fmpkjVLegOkqyKOP0CEsJcMsyO4UCYeN1su+YMW/MfaSErnJskL7XM+UDK6aTvslxsRCyya/sA==} | |
| 391 | + | |
| 392 | + '@next/eslint-plugin-next@16.4.2': | |
| 393 | + resolution: {integrity: sha512-gCTWlw0e+Y6kajpFyKKgA195jUJCRhlzJE0lV7ufe8a+ghIsANWZ0/FiJwg6DuOG1+OvRbcZDcFBoRh3mNJx0g==} | |
| 394 | + | |
| 395 | + '@next/swc-darwin-arm64@16.4.2': | |
| 396 | + resolution: {integrity: sha512-ONkhFvZNFWlsEHOM/81AtxdyuJP2KRYKYR0JxPwvcu1AuPMMMgcL+wW2luldJvMtZwKtUxoSkCychW5wIeoYSw==} | |
| 397 | + engines: {node: '>= 10'} | |
| 398 | + cpu: [arm64] | |
| 399 | + os: [darwin] | |
| 400 | + | |
| 401 | + '@next/swc-darwin-x64@16.4.2': | |
| 402 | + resolution: {integrity: sha512-luPCMbOsCoaF+kXJEUjmjmOZg1lC3BflhmUu+ZmW4tBu2vW/4AXak8JBFvePizGkxVmNiAw/r9ra+6UviCWtWQ==} | |
| 403 | + engines: {node: '>= 10'} | |
| 404 | + cpu: [x64] | |
| 405 | + os: [darwin] | |
| 406 | + | |
| 407 | + '@next/swc-linux-arm64-gnu@16.4.2': | |
| 408 | + resolution: {integrity: sha512-xhwFTAncJyaCNy5KqKKuKaIIdCy1/1ZV1s5S1G0hhT7wGMkDTlfJ6aQbfRFnIG/s3/kDrxQ8aycVe+c5QnP8ow==} | |
| 409 | + engines: {node: '>= 10'} | |
| 410 | + cpu: [arm64] | |
| 411 | + os: [linux] | |
| 412 | + libc: [glibc] | |
| 413 | + | |
| 414 | + '@next/swc-linux-arm64-musl@16.4.2': | |
| 415 | + resolution: {integrity: sha512-T1PNmfbPcxr5ndtWUL/3wQtLXsz808lq6sc2GrNT5k2oquF7UyqZddHSuAewHVsWsGXe6nsl9HoN1YNriVjuSg==} | |
| 416 | + engines: {node: '>= 10'} | |
| 417 | + cpu: [arm64] | |
| 418 | + os: [linux] | |
| 419 | + libc: [musl] | |
| 420 | + | |
| 421 | + '@next/swc-linux-x64-gnu@16.4.2': | |
| 422 | + resolution: {integrity: sha512-N9cDUOCGtkJG5PNKDFZUF3xXndrJJcpLp121uAGjIGvO/U2p9tfdNOM9htj7+5f72l0m8rMw/cvYacUJBQoudg==} | |
| 423 | + engines: {node: '>= 10'} | |
| 424 | + cpu: [x64] | |
| 425 | + os: [linux] | |
| 426 | + libc: [glibc] | |
| 427 | + | |
| 428 | + '@next/swc-linux-x64-musl@16.4.2': | |
| 429 | + resolution: {integrity: sha512-yY2uh1YhX6ufuYy2FCbw1dqblbhI57GLYAJfCkqD9auTv6pLrH67Uccjc3QMKBeCYAG6lkgu08ExmX1bbjejjQ==} | |
| 430 | + engines: {node: '>= 10'} | |
| 431 | + cpu: [x64] | |
| 432 | + os: [linux] | |
| 433 | + libc: [musl] | |
| 434 | + | |
| 435 | + '@next/swc-win32-arm64-msvc@16.4.2': | |
| 436 | + resolution: {integrity: sha512-j/ArWRIHpMMB6wYLMhr2puTguBt70lNU/Cmjl9p6dTDE8DGknYJMP9PdwPk2dqpCNL/KIX9yfngDvIesxJPmsg==} | |
| 437 | + engines: {node: '>= 10'} | |
| 438 | + cpu: [arm64] | |
| 439 | + os: [win32] | |
| 440 | + | |
| 441 | + '@next/swc-win32-x64-msvc@16.4.2': | |
| 442 | + resolution: {integrity: sha512-AA4edyWHDko3NCSo0x6GomsdgFb0oR1J3MXU1b5xt6FRFg4AucrMPW3S0hCe/9onU0O1BbpGQBsqo1r0Gneu1g==} | |
| 443 | + engines: {node: '>= 10'} | |
| 444 | + cpu: [x64] | |
| 445 | + os: [win32] | |
| 446 | + | |
| 447 | + '@nodelib/fs.scandir@2.1.5': | |
| 448 | + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} | |
| 449 | + engines: {node: '>= 8'} | |
| 450 | + | |
| 451 | + '@nodelib/fs.stat@2.0.5': | |
| 452 | + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} | |
| 453 | + engines: {node: '>= 8'} | |
| 454 | + | |
| 455 | + '@nodelib/fs.walk@1.2.8': | |
| 456 | + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} | |
| 457 | + engines: {node: '>= 8'} | |
| 458 | + | |
| 459 | + '@nolyfill/is-core-module@1.0.39': | |
| 460 | + resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==} | |
| 461 | + engines: {node: '>=12.4.0'} | |
| 462 | + | |
| 463 | + '@rtsao/scc@1.1.0': | |
| 464 | + resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} | |
| 465 | + | |
| 466 | + '@swc/helpers@0.5.23': | |
| 467 | + resolution: {integrity: sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==} | |
| 468 | + | |
| 469 | + '@tailwindcss/node@4.3.3': | |
| 470 | + resolution: {integrity: sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==} | |
| 471 | + | |
| 472 | + '@tailwindcss/oxide-android-arm64@4.3.3': | |
| 473 | + resolution: {integrity: sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==} | |
| 474 | + engines: {node: '>= 20'} | |
| 475 | + cpu: [arm64] | |
| 476 | + os: [android] | |
| 477 | + | |
| 478 | + '@tailwindcss/oxide-darwin-arm64@4.3.3': | |
| 479 | + resolution: {integrity: sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==} | |
| 480 | + engines: {node: '>= 20'} | |
| 481 | + cpu: [arm64] | |
| 482 | + os: [darwin] | |
| 483 | + | |
| 484 | + '@tailwindcss/oxide-darwin-x64@4.3.3': | |
| 485 | + resolution: {integrity: sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==} | |
| 486 | + engines: {node: '>= 20'} | |
| 487 | + cpu: [x64] | |
| 488 | + os: [darwin] | |
| 489 | + | |
| 490 | + '@tailwindcss/oxide-freebsd-x64@4.3.3': | |
| 491 | + resolution: {integrity: sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==} | |
| 492 | + engines: {node: '>= 20'} | |
| 493 | + cpu: [x64] | |
| 494 | + os: [freebsd] | |
| 495 | + | |
| 496 | + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3': | |
| 497 | + resolution: {integrity: sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==} | |
| 498 | + engines: {node: '>= 20'} | |
| 499 | + cpu: [arm] | |
| 500 | + os: [linux] | |
| 501 | + | |
| 502 | + '@tailwindcss/oxide-linux-arm64-gnu@4.3.3': | |
| 503 | + resolution: {integrity: sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==} | |
| 504 | + engines: {node: '>= 20'} | |
| 505 | + cpu: [arm64] | |
| 506 | + os: [linux] | |
| 507 | + libc: [glibc] | |
| 508 | + | |
| 509 | + '@tailwindcss/oxide-linux-arm64-musl@4.3.3': | |
| 510 | + resolution: {integrity: sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==} | |
| 511 | + engines: {node: '>= 20'} | |
| 512 | + cpu: [arm64] | |
| 513 | + os: [linux] | |
| 514 | + libc: [musl] | |
| 515 | + | |
| 516 | + '@tailwindcss/oxide-linux-x64-gnu@4.3.3': | |
| 517 | + resolution: {integrity: sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==} | |
| 518 | + engines: {node: '>= 20'} | |
| 519 | + cpu: [x64] | |
| 520 | + os: [linux] | |
| 521 | + libc: [glibc] | |
| 522 | + | |
| 523 | + '@tailwindcss/oxide-linux-x64-musl@4.3.3': | |
| 524 | + resolution: {integrity: sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==} | |
| 525 | + engines: {node: '>= 20'} | |
| 526 | + cpu: [x64] | |
| 527 | + os: [linux] | |
| 528 | + libc: [musl] | |
| 529 | + | |
| 530 | + '@tailwindcss/oxide-wasm32-wasi@4.3.3': | |
| 531 | + resolution: {integrity: sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==} | |
| 532 | + engines: {node: '>=14.0.0'} | |
| 533 | + cpu: [wasm32] | |
| 534 | + bundledDependencies: | |
| 535 | + - '@napi-rs/wasm-runtime' | |
| 536 | + - '@emnapi/core' | |
| 537 | + - '@emnapi/runtime' | |
| 538 | + - '@tybys/wasm-util' | |
| 539 | + - '@emnapi/wasi-threads' | |
| 540 | + - tslib | |
| 541 | + | |
| 542 | + '@tailwindcss/oxide-win32-arm64-msvc@4.3.3': | |
| 543 | + resolution: {integrity: sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==} | |
| 544 | + engines: {node: '>= 20'} | |
| 545 | + cpu: [arm64] | |
| 546 | + os: [win32] | |
| 547 | + | |
| 548 | + '@tailwindcss/oxide-win32-x64-msvc@4.3.3': | |
| 549 | + resolution: {integrity: sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==} | |
| 550 | + engines: {node: '>= 20'} | |
| 551 | + cpu: [x64] | |
| 552 | + os: [win32] | |
| 553 | + | |
| 554 | + '@tailwindcss/oxide@4.3.3': | |
| 555 | + resolution: {integrity: sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==} | |
| 556 | + engines: {node: '>= 20'} | |
| 557 | + | |
| 558 | + '@tailwindcss/turbopack@4.3.3': | |
| 559 | + resolution: {integrity: sha512-FgL4wRgItNdXcDjcgYUSd59gNsWqNVK4GotjZimZMmt7OOIkRNqH2QEUcRXLdBDXvwTsi5rkYxDxzfrFUkmObQ==} | |
| 560 | + | |
| 561 | + '@tanstack/query-core@5.104.5': | |
| 562 | + resolution: {integrity: sha512-Q94gI8Opd+CbdVNOdj8ZCyV8WqLYitrDq1t96Xc8WZT6lY0r5iRnJLXYDxMex7EEf2v+mXvn4FcjaBKSxXZQ9A==} | |
| 563 | + | |
| 564 | + '@tanstack/react-query@5.104.5': | |
| 565 | + resolution: {integrity: sha512-RORgFe052U+93weaNuqg7qHq7vNVNzXN/VrXqD4bbAuQCWUqOl2lV5Ui9flWk5CVvMyL/Bh27QWhzwH0aaOx9g==} | |
| 566 | + peerDependencies: | |
| 567 | + '@types/react': ^18 || ^19 | |
| 568 | + react: ^18 || ^19 | |
| 569 | + peerDependenciesMeta: | |
| 570 | + '@types/react': | |
| 571 | + optional: true | |
| 572 | + | |
| 573 | + '@tybys/wasm-util@0.10.4': | |
| 574 | + resolution: {integrity: sha512-W3c4gRigFS0T/Ma4qIYF3GDAc5AQdHb1yL5znJT1Zv1YaD9Kitx656wBjvr19qbiosmZT8lWDM5BEMynUqX65A==} | |
| 575 | + | |
| 576 | + '@types/estree@1.0.9': | |
| 577 | + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} | |
| 578 | + | |
| 579 | + '@types/json-schema@7.0.15': | |
| 580 | + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} | |
| 581 | + | |
| 582 | + '@types/json5@0.0.29': | |
| 583 | + resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} | |
| 584 | + | |
| 585 | + '@types/node@20.19.43': | |
| 586 | + resolution: {integrity: sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==} | |
| 587 | + | |
| 588 | + '@types/react-dom@19.3.0': | |
| 589 | + resolution: {integrity: sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==} | |
| 590 | + peerDependencies: | |
| 591 | + '@types/react': ^19.3.0 | |
| 592 | + | |
| 593 | + '@types/react@19.3.0': | |
| 594 | + resolution: {integrity: sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==} | |
| 595 | + | |
| 596 | + '@typescript-eslint/eslint-plugin@8.72.0': | |
| 597 | + resolution: {integrity: sha512-aysKgysPX9XOv/dk95UkvUDe++Q3n3dJPG9CEWT3RUccxfkPJ+Qn6nhKPDhZXU6Ofo9kgwGWwKyQCnZIXTgngg==} | |
| 598 | + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} | |
| 599 | + peerDependencies: | |
| 600 | + '@typescript-eslint/parser': ^8.72.0 | |
| 601 | + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 | |
| 602 | + typescript: '>=4.8.4 <6.1.0' | |
| 603 | + | |
| 604 | + '@typescript-eslint/parser@8.72.0': | |
| 605 | + resolution: {integrity: sha512-0O+x3ynciD6zuXb1CKSu165haQV6ey53DKhHHUnujP7U+XlltJG+/keT0Xhd1HXCze+IHf7Tvt39ovyepX/LNw==} | |
| 606 | + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} | |
| 607 | + peerDependencies: | |
| 608 | + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 | |
| 609 | + typescript: '>=4.8.4 <6.1.0' | |
| 610 | + | |
| 611 | + '@typescript-eslint/project-service@8.72.0': | |
| 612 | + resolution: {integrity: sha512-bzHngBbmIgzj6TsTjVUCntGx6FFLgE7mPjpO0PzHycOaZHXqS43XnrkFIHRS16LLr/xO6FHSXG3GJFo/F4bSBQ==} | |
| 613 | + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} | |
| 614 | + peerDependencies: | |
| 615 | + typescript: '>=4.8.4 <6.1.0' | |
| 616 | + | |
| 617 | + '@typescript-eslint/scope-manager@8.72.0': | |
| 618 | + resolution: {integrity: sha512-JewPyJOYcXBSAS7PIgufscHOwZqhTGyBRiQJhhuDln66tMchxvvt7nJ90JKKIoOelmdHA3kAQbm9cEUHc4JSEA==} | |
| 619 | + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} | |
| 620 | + | |
| 621 | + '@typescript-eslint/tsconfig-utils@8.72.0': | |
| 622 | + resolution: {integrity: sha512-0vbxCf7xLVPSjcOdm6eQbgViH8EDJ8xnl1laLT9yoqSz2YmcSfSg5Vdl0JUvZf6rcuJ0ZivJIuMD4edTg8obaA==} | |
| 623 | + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} | |
| 624 | + peerDependencies: | |
| 625 | + typescript: '>=4.8.4 <6.1.0' | |
| 626 | + | |
| 627 | + '@typescript-eslint/type-utils@8.72.0': | |
| 628 | + resolution: {integrity: sha512-gEXxmmbSTSOVTC37IYYvremOM31v+UN2W772bJxmVYPhAOPSUxtacyb7wkm1EhkYKU6KzFJsdU44DCV4to0LWw==} | |
| 629 | + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} | |
| 630 | + peerDependencies: | |
| 631 | + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 | |
| 632 | + typescript: '>=4.8.4 <6.1.0' | |
| 633 | + | |
| 634 | + '@typescript-eslint/types@8.72.0': | |
| 635 | + resolution: {integrity: sha512-Adfoy59kxNEjT5E5fnyrp7GpHYuWs8ONbdohu4ez6IU46qDq8LltG90tLmkucZB2uhYL8mqBjtXqGd8yhfjXqQ==} | |
| 636 | + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} | |
| 637 | + | |
| 638 | + '@typescript-eslint/typescript-estree@8.72.0': | |
| 639 | + resolution: {integrity: sha512-gwLfFgWcw15tN2KXDCJvxEOQjPn+P3j/n3eSqkZ5sX4VpW9Cosodr+tWwBZRvJT3TcKx6HnPRGZMCNAiP1Xhug==} | |
| 640 | + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} | |
| 641 | + peerDependencies: | |
| 642 | + typescript: '>=4.8.4 <6.1.0' | |
| 643 | + | |
| 644 | + '@typescript-eslint/utils@8.72.0': | |
| 645 | + resolution: {integrity: sha512-0up3oArJZBYnzOaEXKo8DmI43QvMZyPoFEt9LWzRJcyl/RcJjfKKPebknN1VbTgXBf3M/KggUh5uUMdURLnseg==} | |
| 646 | + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} | |
| 647 | + peerDependencies: | |
| 648 | + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 | |
| 649 | + typescript: '>=4.8.4 <6.1.0' | |
| 650 | + | |
| 651 | + '@typescript-eslint/visitor-keys@8.72.0': | |
| 652 | + resolution: {integrity: sha512-E2mroviasaWecDzTGiGMtuEguuLVOeRggnrIn93CJuxYJzQBHbyymlT08aVQydbBal5538o+JkBKXh2sRuCyIA==} | |
| 653 | + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} | |
| 654 | + | |
| 655 | + '@unrs/resolver-binding-android-arm-eabi@1.12.2': | |
| 656 | + resolution: {integrity: sha512-g5T90pqg1bo/7mytQx6F4iBNC0Wsh9cu+z9veDbFjc7HjpesJFWD7QMS0NGStXM075+7dJPPVvBbpZlnrdpi/w==} | |
| 657 | + cpu: [arm] | |
| 658 | + os: [android] | |
| 659 | + | |
| 660 | + '@unrs/resolver-binding-android-arm64@1.12.2': | |
| 661 | + resolution: {integrity: sha512-YGCRZv/9GLhwmz6mYDeTsm/92BAyR28l6c2ReweVW5pWgfsitWLY8upvfRlGdoyD8HjeTHSYJWyZGD4KJA/nFQ==} | |
| 662 | + cpu: [arm64] | |
| 663 | + os: [android] | |
| 664 | + | |
| 665 | + '@unrs/resolver-binding-darwin-arm64@1.12.2': | |
| 666 | + resolution: {integrity: sha512-u9DiNT1auQMO20A9SyTuG3wUgQWB9Z7KjAg0uFuCDR1FsAY8A0CG2S6JpHS1xwm/w1G08bjXZDcyOCjv1WAm2w==} | |
| 667 | + cpu: [arm64] | |
| 668 | + os: [darwin] | |
| 669 | + | |
| 670 | + '@unrs/resolver-binding-darwin-x64@1.12.2': | |
| 671 | + resolution: {integrity: sha512-f7rPLi/T1HVKZu/u6t87lroib16n8vrSzcyxI7lg4BGO9UF26KhQL44sd9eOUgrTYhvRXtWOIZT5PejdPyJfUA==} | |
| 672 | + cpu: [x64] | |
| 673 | + os: [darwin] | |
| 674 | + | |
| 675 | + '@unrs/resolver-binding-freebsd-x64@1.12.2': | |
| 676 | + resolution: {integrity: sha512-BpcOjWCJub6nRZUS2zA20pmLvjtqAtGejETaIyRLiZiQf++cbrjltLA5NN/xaXfqeOBOSlMFbemIl5/S5tljmg==} | |
| 677 | + cpu: [x64] | |
| 678 | + os: [freebsd] | |
| 679 | + | |
| 680 | + '@unrs/resolver-binding-linux-arm-gnueabihf@1.12.2': | |
| 681 | + resolution: {integrity: sha512-vZTDvdSISZjJx66OzJqtsOhzifbqRjbmI1Mnu49fQDwog5GtDI4QidRiEAYbZCRj9C8YZEW+3ZjqsyS9GR4k2A==} | |
| 682 | + cpu: [arm] | |
| 683 | + os: [linux] | |
| 684 | + | |
| 685 | + '@unrs/resolver-binding-linux-arm-musleabihf@1.12.2': | |
| 686 | + resolution: {integrity: sha512-BiPI+IrIlwcW4nLLMM21+B1dFPzd55yAVgVGrdgDjNef+ch03GdxrcyaIz8X9SsQirh/kCQ7mviyWlMxdh2D7g==} | |
| 687 | + cpu: [arm] | |
| 688 | + os: [linux] | |
| 689 | + | |
| 690 | + '@unrs/resolver-binding-linux-arm64-gnu@1.12.2': | |
| 691 | + resolution: {integrity: sha512-zJc0H99FEPoFfSrNpa91HYfxzfAJCr502oxNK1cfdC9hlaFI43RT+JFCann9JUgZmLzzntChHyn13Sgn9ljHNg==} | |
| 692 | + cpu: [arm64] | |
| 693 | + os: [linux] | |
| 694 | + libc: [glibc] | |
| 695 | + | |
| 696 | + '@unrs/resolver-binding-linux-arm64-musl@1.12.2': | |
| 697 | + resolution: {integrity: sha512-KQ3Lki6l+Pz1k/eBipN41ES+YUK30beLGb9YqcB1O542cyLCNE6GaxrfcY3T6EezmGGk84wb5XyO9loTM9tkcA==} | |
| 698 | + cpu: [arm64] | |
| 699 | + os: [linux] | |
| 700 | + libc: [musl] | |
| 701 | + | |
| 702 | + '@unrs/resolver-binding-linux-loong64-gnu@1.12.2': | |
| 703 | + resolution: {integrity: sha512-3SJGEh1DborhG6pyxvhPzCT4bbSIVihsvgJc13P1bHG7KLdNDaF9T3gsTwFc7Jw/5Y5/iWOjkEx7Zy0NvCGX3Q==} | |
| 704 | + cpu: [loong64] | |
| 705 | + os: [linux] | |
| 706 | + libc: [glibc] | |
| 707 | + | |
| 708 | + '@unrs/resolver-binding-linux-loong64-musl@1.12.2': | |
| 709 | + resolution: {integrity: sha512-jiuG/Obbel7uw1PwHNFfrkiKhLAF6mnyZ6aWlOAVN9WqKm8v0OFGnciJIHu8+CMvXLQ8AD51LPzAoUfT21D5Ew==} | |
| 710 | + cpu: [loong64] | |
| 711 | + os: [linux] | |
| 712 | + libc: [musl] | |
| 713 | + | |
| 714 | + '@unrs/resolver-binding-linux-ppc64-gnu@1.12.2': | |
| 715 | + resolution: {integrity: sha512-q7xRvVpmcfeL+LlZg8Pbbo6QaTZwDU5BaGZbwfhkEsXJn3Was8xYfE0RBH266xZt0rM6B7i8xAYIvjthuUIWHg==} | |
| 716 | + cpu: [ppc64] | |
| 717 | + os: [linux] | |
| 718 | + libc: [glibc] | |
| 719 | + | |
| 720 | + '@unrs/resolver-binding-linux-riscv64-gnu@1.12.2': | |
| 721 | + resolution: {integrity: sha512-0CVdx6lcnT3Q9inOH8tsMIOJ6ImndllMjqJHg8RLVdB7Vq4SfkEXl9mCSsVNuNA4MCYycRicCUxPCabVHJRr6A==} | |
| 722 | + cpu: [riscv64] | |
| 723 | + os: [linux] | |
| 724 | + libc: [glibc] | |
| 725 | + | |
| 726 | + '@unrs/resolver-binding-linux-riscv64-musl@1.12.2': | |
| 727 | + resolution: {integrity: sha512-iOwlRo9vnp6R6ohHQS11n0NnfdXx/omhkocmIfaPRpQhKZ+3BDMkkdRVh53qjkFkpPddf+FETA28NwGN7l5l+w==} | |
| 728 | + cpu: [riscv64] | |
| 729 | + os: [linux] | |
| 730 | + libc: [musl] | |
| 731 | + | |
| 732 | + '@unrs/resolver-binding-linux-s390x-gnu@1.12.2': | |
| 733 | + resolution: {integrity: sha512-HYJtLfXq94q8iZNFT1lknx258wlkkWhZeUXJRqzKBBUJ00CvZ+N33zgbCqimLjsyw5Va6uUxhVa12mI+kaveEw==} | |
| 734 | + cpu: [s390x] | |
| 735 | + os: [linux] | |
| 736 | + libc: [glibc] | |
| 737 | + | |
| 738 | + '@unrs/resolver-binding-linux-x64-gnu@1.12.2': | |
| 739 | + resolution: {integrity: sha512-mPsUhunKKDih5O96Y6enDQyHc1SqBPlY1E/SfMWDM3EdJ95Z9CArPeCVwCCqbP45ljvivdEk8Fxn+SIb1rDAJQ==} | |
| 740 | + cpu: [x64] | |
| 741 | + os: [linux] | |
| 742 | + libc: [glibc] | |
| 743 | + | |
| 744 | + '@unrs/resolver-binding-linux-x64-musl@1.12.2': | |
| 745 | + resolution: {integrity: sha512-azrt6+5ydLd8Vt210AAFis/lZevSfPw93EJRIJG+xPu4WCJ8K0kppCTpMyLPcKT7H15M4Jnt2tMp5bOvCkRC6A==} | |
| 746 | + cpu: [x64] | |
| 747 | + os: [linux] | |
| 748 | + libc: [musl] | |
| 749 | + | |
| 750 | + '@unrs/resolver-binding-openharmony-arm64@1.12.2': | |
| 751 | + resolution: {integrity: sha512-YZ9hP4O0X9PQb8eO980qmLNGH4zT3I9+SZTdt0Pr0YyuGQhYKoOZkV02VzrzyOZJ5xIJ3UFIenKkUkGg8GjgWQ==} | |
| 752 | + cpu: [arm64] | |
| 753 | + os: [openharmony] | |
| 754 | + | |
| 755 | + '@unrs/resolver-binding-wasm32-wasi@1.12.2': | |
| 756 | + resolution: {integrity: sha512-tYFDIkMxSflfEc/h92ZWNsZlHSwgimbNHSO3PL2JWQHfCuC2q316jMyYU9TIWZsFK2bQwyK5VAdYgn8ygPj69A==} | |
| 757 | + engines: {node: '>=14.0.0'} | |
| 758 | + cpu: [wasm32] | |
| 759 | + | |
| 760 | + '@unrs/resolver-binding-win32-arm64-msvc@1.12.2': | |
| 761 | + resolution: {integrity: sha512-qzNyg3xL0VPQmCaUh+N5jSitce6k+uCBfMDesWRnlULOZaqUkaJ0ybdT+UqlAWJoQjuqfIU/0Ptx9bteN4D82g==} | |
| 762 | + cpu: [arm64] | |
| 763 | + os: [win32] | |
| 764 | + | |
| 765 | + '@unrs/resolver-binding-win32-ia32-msvc@1.12.2': | |
| 766 | + resolution: {integrity: sha512-WD9sY00OfpHVGfsnHZoA8jVT+esS/Bg8z8jzxp5BnDCjjwsuKsPQrzswwpFy4J1AUJbXPRfkpcX0mXrzeXW79g==} | |
| 767 | + cpu: [ia32] | |
| 768 | + os: [win32] | |
| 769 | + | |
| 770 | + '@unrs/resolver-binding-win32-x64-msvc@1.12.2': | |
| 771 | + resolution: {integrity: sha512-nAB74NfSNKknqQ1RrYj6uz8FcXEomu/MATJZxh/x+BArzN2U3JbOYC0APYzUIGhVY3m5hRxA8VPNdPBoG8txlA==} | |
| 772 | + cpu: [x64] | |
| 773 | + os: [win32] | |
| 774 | + | |
| 775 | + acorn-jsx@5.3.2: | |
| 776 | + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} | |
| 777 | + peerDependencies: | |
| 778 | + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 | |
| 779 | + | |
| 780 | + acorn@8.19.0: | |
| 781 | + resolution: {integrity: sha512-oJlA3XiRm7Cyk6qFD2Jn8ak9B7jSy0qy00ADO3+8dpT0LSjFihQYv4C02LFCSYJV3Q37xYwwRy5m+IpIiUzqWw==} | |
| 782 | + engines: {node: '>=0.4.0'} | |
| 783 | + hasBin: true | |
| 784 | + | |
| 785 | + ajv@6.15.0: | |
| 786 | + resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} | |
| 787 | + | |
| 788 | + ansi-styles@4.3.0: | |
| 789 | + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} | |
| 790 | + engines: {node: '>=8'} | |
| 791 | + | |
| 792 | + argparse@2.0.1: | |
| 793 | + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} | |
| 794 | + | |
| 795 | + aria-query@5.3.2: | |
| 796 | + resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} | |
| 797 | + engines: {node: '>= 0.4'} | |
| 798 | + | |
| 799 | + array-buffer-byte-length@1.0.2: | |
| 800 | + resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==} | |
| 801 | + engines: {node: '>= 0.4'} | |
| 802 | + | |
| 803 | + array-includes@3.2.0: | |
| 804 | + resolution: {integrity: sha512-VXY5eFRarnXcYxwBjJzPmEhH55+rmP79/+ueDhi0F+TuqfHCItagIHqxeUZrmgrOPa31QTh9H85DjX3FfJ0FTg==} | |
| 805 | + engines: {node: '>= 0.4'} | |
| 806 | + | |
| 807 | + array.prototype.findlast@1.2.5: | |
| 808 | + resolution: {integrity: sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==} | |
| 809 | + engines: {node: '>= 0.4'} | |
| 810 | + | |
| 811 | + array.prototype.findlastindex@1.2.6: | |
| 812 | + resolution: {integrity: sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==} | |
| 813 | + engines: {node: '>= 0.4'} | |
| 814 | + | |
| 815 | + array.prototype.flat@1.3.3: | |
| 816 | + resolution: {integrity: sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==} | |
| 817 | + engines: {node: '>= 0.4'} | |
| 818 | + | |
| 819 | + array.prototype.flatmap@1.3.3: | |
| 820 | + resolution: {integrity: sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==} | |
| 821 | + engines: {node: '>= 0.4'} | |
| 822 | + | |
| 823 | + array.prototype.tosorted@1.1.4: | |
| 824 | + resolution: {integrity: sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==} | |
| 825 | + engines: {node: '>= 0.4'} | |
| 826 | + | |
| 827 | + arraybuffer.prototype.slice@1.0.4: | |
| 828 | + resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==} | |
| 829 | + engines: {node: '>= 0.4'} | |
| 830 | + | |
| 831 | + ast-types-flow@0.0.8: | |
| 832 | + resolution: {integrity: sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==} | |
| 833 | + | |
| 834 | + async-function@1.0.0: | |
| 835 | + resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} | |
| 836 | + engines: {node: '>= 0.4'} | |
| 837 | + | |
| 838 | + available-typed-arrays@1.0.7: | |
| 839 | + resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} | |
| 840 | + engines: {node: '>= 0.4'} | |
| 841 | + | |
| 842 | + axe-core@4.14.0: | |
| 843 | + resolution: {integrity: sha512-9WTZxEjsZ7b13TH8JPmbV2z8CHbl80/2hm3XPEG4JgNdQLK81IBRXmSxHfMAOkSqQeRxT/0dwNDz2GOm3zzpcQ==} | |
| 844 | + engines: {node: '>=4'} | |
| 845 | + | |
| 846 | + axobject-query@4.1.0: | |
| 847 | + resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} | |
| 848 | + engines: {node: '>= 0.4'} | |
| 849 | + | |
| 850 | + balanced-match@1.0.2: | |
| 851 | + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} | |
| 852 | + | |
| 853 | + balanced-match@4.0.4: | |
| 854 | + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} | |
| 855 | + engines: {node: 18 || 20 || >=22} | |
| 856 | + | |
| 857 | + baseline-browser-mapping@2.11.30: | |
| 858 | + resolution: {integrity: sha512-YJs6Pz17RMNv8HoyKREtCY38E3BV1nPkWVmIb+XN1uHC1Vqt3KZCH/uJH9ZKRoiKb/icZ6tvehyee6RZNbFfRA==} | |
| 859 | + engines: {node: '>=6.0.0'} | |
| 860 | + hasBin: true | |
| 861 | + | |
| 862 | + brace-expansion@1.1.21: | |
| 863 | + resolution: {integrity: sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==} | |
| 864 | + | |
| 865 | + brace-expansion@5.0.12: | |
| 866 | + resolution: {integrity: sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==} | |
| 867 | + engines: {node: 20 || >=22} | |
| 868 | + | |
| 869 | + braces@3.0.3: | |
| 870 | + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} | |
| 871 | + engines: {node: '>=8'} | |
| 872 | + | |
| 873 | + browserslist@4.29.4: | |
| 874 | + resolution: {integrity: sha512-2TcbbiwXEq+N0kD36tykpCfyyQigUXmuwNQbnPp4Xk4PX9JOqVIKjJlIDjpH7uY6tmRGoAd+apeT1p60T8wsdA==} | |
| 875 | + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} | |
| 876 | + hasBin: true | |
| 877 | + | |
| 878 | + call-bind-apply-helpers@1.0.2: | |
| 879 | + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} | |
| 880 | + engines: {node: '>= 0.4'} | |
| 881 | + | |
| 882 | + call-bind@1.0.9: | |
| 883 | + resolution: {integrity: sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==} | |
| 884 | + engines: {node: '>= 0.4'} | |
| 885 | + | |
| 886 | + call-bound@1.0.4: | |
| 887 | + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} | |
| 888 | + engines: {node: '>= 0.4'} | |
| 889 | + | |
| 890 | + callsites@3.1.0: | |
| 891 | + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} | |
| 892 | + engines: {node: '>=6'} | |
| 893 | + | |
| 894 | + caniuse-lite@1.0.30001817: | |
| 895 | + resolution: {integrity: sha512-uZ1JG80BwhtMalIAGPxPfmNhHyd/B/nTdr3hITWlRamSyAdUStP1Gop01e7SD5q6J90ixJvWDbvmiFVfDgn4bw==} | |
| 896 | + | |
| 897 | + chalk@4.1.2: | |
| 898 | + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} | |
| 899 | + engines: {node: '>=10'} | |
| 900 | + | |
| 901 | + client-only@0.0.1: | |
| 902 | + resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} | |
| 903 | + | |
| 904 | + color-convert@2.0.1: | |
| 905 | + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} | |
| 906 | + engines: {node: '>=7.0.0'} | |
| 907 | + | |
| 908 | + color-name@1.1.4: | |
| 909 | + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} | |
| 910 | + | |
| 911 | + concat-map@0.0.1: | |
| 912 | + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} | |
| 913 | + | |
| 914 | + convert-source-map@2.0.0: | |
| 915 | + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} | |
| 916 | + | |
| 917 | + cross-spawn@7.0.6: | |
| 918 | + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} | |
| 919 | + engines: {node: '>= 8'} | |
| 920 | + | |
| 921 | + csstype@3.2.3: | |
| 922 | + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} | |
| 923 | + | |
| 924 | + damerau-levenshtein@1.0.8: | |
| 925 | + resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==} | |
| 926 | + | |
| 927 | + data-view-buffer@1.0.2: | |
| 928 | + resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==} | |
| 929 | + engines: {node: '>= 0.4'} | |
| 930 | + | |
| 931 | + data-view-byte-length@1.0.2: | |
| 932 | + resolution: {integrity: sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==} | |
| 933 | + engines: {node: '>= 0.4'} | |
| 934 | + | |
| 935 | + data-view-byte-offset@1.0.1: | |
| 936 | + resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==} | |
| 937 | + engines: {node: '>= 0.4'} | |
| 938 | + | |
| 939 | + debug@3.2.7: | |
| 940 | + resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==} | |
| 941 | + peerDependencies: | |
| 942 | + supports-color: '*' | |
| 943 | + peerDependenciesMeta: | |
| 944 | + supports-color: | |
| 945 | + optional: true | |
| 946 | + | |
| 947 | + debug@4.4.3: | |
| 948 | + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} | |
| 949 | + engines: {node: '>=6.0'} | |
| 950 | + peerDependencies: | |
| 951 | + supports-color: '*' | |
| 952 | + peerDependenciesMeta: | |
| 953 | + supports-color: | |
| 954 | + optional: true | |
| 955 | + | |
| 956 | + deep-is@0.1.4: | |
| 957 | + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} | |
| 958 | + | |
| 959 | + define-data-property@1.1.4: | |
| 960 | + resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} | |
| 961 | + engines: {node: '>= 0.4'} | |
| 962 | + | |
| 963 | + define-properties@1.2.1: | |
| 964 | + resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} | |
| 965 | + engines: {node: '>= 0.4'} | |
| 966 | + | |
| 967 | + detect-libc@2.1.2: | |
| 968 | + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} | |
| 969 | + engines: {node: '>=8'} | |
| 970 | + | |
| 971 | + doctrine@2.1.0: | |
| 972 | + resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} | |
| 973 | + engines: {node: '>=0.10.0'} | |
| 974 | + | |
| 975 | + dunder-proto@1.0.1: | |
| 976 | + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} | |
| 977 | + engines: {node: '>= 0.4'} | |
| 978 | + | |
| 979 | + electron-to-chromium@1.5.462: | |
| 980 | + resolution: {integrity: sha512-9qpCBlXPy5v7n1Q7WTO5rW2StI4WNQhl96HpgXRZLPlNoXAD0LbsQUflovQYAO44Yia073ISusqohncvGSMbGA==} | |
| 981 | + | |
| 982 | + emoji-regex@9.2.2: | |
| 983 | + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} | |
| 984 | + | |
| 985 | + enhanced-resolve@5.26.0: | |
| 986 | + resolution: {integrity: sha512-9vhedylFonb2YGogzUKX6+Ja72gOJbN1QHAqdrvqLwhdl/QWbKopzoUC9EbQNVsAns/bx/4uyqalrQAoy1IByw==} | |
| 987 | + engines: {node: '>=10.13.0'} | |
| 988 | + | |
| 989 | + es-abstract-get@1.0.0: | |
| 990 | + resolution: {integrity: sha512-6PMWXpdhshVvFp+FoWYs1EvG1Nj0tvk0dZM+XcK0xMEM1czRVcP6ohqPWHy6qPagSpC8j4+p89WXlT+xXJs/fg==} | |
| 991 | + engines: {node: '>= 0.4'} | |
| 992 | + | |
| 993 | + es-abstract@1.24.2: | |
| 994 | + resolution: {integrity: sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==} | |
| 995 | + engines: {node: '>= 0.4'} | |
| 996 | + | |
| 997 | + es-define-property@1.0.1: | |
| 998 | + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} | |
| 999 | + engines: {node: '>= 0.4'} | |
| 1000 | + | |
| 1001 | + es-errors@1.3.0: | |
| 1002 | + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} | |
| 1003 | + engines: {node: '>= 0.4'} | |
| 1004 | + | |
| 1005 | + es-iterator-helpers@1.4.0: | |
| 1006 | + resolution: {integrity: sha512-c/A0P0oxkACDc+cKWw8evLXK83oBKgn0qPOqCYT4x9uolpCIJAcYvJC9QYKNDRPsTeGyCrQ326jrvgZWdCdK5Q==} | |
| 1007 | + engines: {node: '>= 0.4'} | |
| 1008 | + | |
| 1009 | + es-object-atoms@1.1.2: | |
| 1010 | + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} | |
| 1011 | + engines: {node: '>= 0.4'} | |
| 1012 | + | |
| 1013 | + es-set-tostringtag@2.1.0: | |
| 1014 | + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} | |
| 1015 | + engines: {node: '>= 0.4'} | |
| 1016 | + | |
| 1017 | + es-shim-unscopables@1.1.0: | |
| 1018 | + resolution: {integrity: sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==} | |
| 1019 | + engines: {node: '>= 0.4'} | |
| 1020 | + | |
| 1021 | + es-to-primitive@1.3.4: | |
| 1022 | + resolution: {integrity: sha512-yPDz7wqpg1/mmHLmS3tcfTfbw5f1eryXvyghYBffGdERwe+mV7ZcWzTR8LR17Kvqt3qfPurjlonmnq3MKXIOXw==} | |
| 1023 | + engines: {node: '>= 0.4'} | |
| 1024 | + | |
| 1025 | + escalade@3.2.0: | |
| 1026 | + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} | |
| 1027 | + engines: {node: '>=6'} | |
| 1028 | + | |
| 1029 | + escape-string-regexp@4.0.0: | |
| 1030 | + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} | |
| 1031 | + engines: {node: '>=10'} | |
| 1032 | + | |
| 1033 | + eslint-config-next@16.4.2: | |
| 1034 | + resolution: {integrity: sha512-P33vjcETMXc+qBjpF77TeJHZLzk6cPcTy8KjpxLucYB3mCIFIORMcZ+cBXcAiIYjE5RM7jyknEEOwMNdnx/ADQ==} | |
| 1035 | + peerDependencies: | |
| 1036 | + eslint: '>=9.0.0' | |
| 1037 | + typescript: '>=3.3.1' | |
| 1038 | + peerDependenciesMeta: | |
| 1039 | + typescript: | |
| 1040 | + optional: true | |
| 1041 | + | |
| 1042 | + eslint-import-resolver-node@0.3.10: | |
| 1043 | + resolution: {integrity: sha512-tRrKqFyCaKict5hOd244sL6EQFNycnMQnBe+j8uqGNXYzsImGbGUU4ibtoaBmv5FLwJwcFJNeg1GeVjQfbMrDQ==} | |
| 1044 | + | |
| 1045 | + eslint-import-resolver-typescript@3.10.1: | |
| 1046 | + resolution: {integrity: sha512-A1rHYb06zjMGAxdLSkN2fXPBwuSaQ0iO5M/hdyS0Ajj1VBaRp0sPD3dn1FhME3c/JluGFbwSxyCfqdSbtQLAHQ==} | |
| 1047 | + engines: {node: ^14.18.0 || >=16.0.0} | |
| 1048 | + peerDependencies: | |
| 1049 | + eslint: '*' | |
| 1050 | + eslint-plugin-import: '*' | |
| 1051 | + eslint-plugin-import-x: '*' | |
| 1052 | + peerDependenciesMeta: | |
| 1053 | + eslint-plugin-import: | |
| 1054 | + optional: true | |
| 1055 | + eslint-plugin-import-x: | |
| 1056 | + optional: true | |
| 1057 | + | |
| 1058 | + eslint-module-utils@2.14.0: | |
| 1059 | + resolution: {integrity: sha512-W2WCRZ9Dqntd+2u8jJcVMV2PKulc6RdLgUUoh/yQr3uB6lo/ZOeGx11sv60/8S4QFFKNslAlWhr9u0Ef7ZW6Ig==} | |
| 1060 | + engines: {node: '>=4'} | |
| 1061 | + peerDependencies: | |
| 1062 | + '@typescript-eslint/parser': '*' | |
| 1063 | + eslint: '*' | |
| 1064 | + eslint-import-resolver-node: '*' | |
| 1065 | + eslint-import-resolver-typescript: '*' | |
| 1066 | + eslint-import-resolver-webpack: '*' | |
| 1067 | + peerDependenciesMeta: | |
| 1068 | + '@typescript-eslint/parser': | |
| 1069 | + optional: true | |
| 1070 | + eslint: | |
| 1071 | + optional: true | |
| 1072 | + eslint-import-resolver-node: | |
| 1073 | + optional: true | |
| 1074 | + eslint-import-resolver-typescript: | |
| 1075 | + optional: true | |
| 1076 | + eslint-import-resolver-webpack: | |
| 1077 | + optional: true | |
| 1078 | + | |
| 1079 | + eslint-plugin-import@2.32.0: | |
| 1080 | + resolution: {integrity: sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==} | |
| 1081 | + engines: {node: '>=4'} | |
| 1082 | + peerDependencies: | |
| 1083 | + '@typescript-eslint/parser': '*' | |
| 1084 | + eslint: ^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9 | |
| 1085 | + peerDependenciesMeta: | |
| 1086 | + '@typescript-eslint/parser': | |
| 1087 | + optional: true | |
| 1088 | + | |
| 1089 | + eslint-plugin-jsx-a11y@6.10.2: | |
| 1090 | + resolution: {integrity: sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q==} | |
| 1091 | + engines: {node: '>=4.0'} | |
| 1092 | + peerDependencies: | |
| 1093 | + eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9 | |
| 1094 | + | |
| 1095 | + eslint-plugin-react-hooks@7.1.1: | |
| 1096 | + resolution: {integrity: sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==} | |
| 1097 | + engines: {node: '>=18'} | |
| 1098 | + peerDependencies: | |
| 1099 | + eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0 | |
| 1100 | + | |
| 1101 | + eslint-plugin-react@7.37.5: | |
| 1102 | + resolution: {integrity: sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==} | |
| 1103 | + engines: {node: '>=4'} | |
| 1104 | + peerDependencies: | |
| 1105 | + eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7 | |
| 1106 | + | |
| 1107 | + eslint-scope@8.4.0: | |
| 1108 | + resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==} | |
| 1109 | + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} | |
| 1110 | + | |
| 1111 | + eslint-visitor-keys@3.4.3: | |
| 1112 | + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} | |
| 1113 | + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} | |
| 1114 | + | |
| 1115 | + eslint-visitor-keys@4.2.1: | |
| 1116 | + resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} | |
| 1117 | + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} | |
| 1118 | + | |
| 1119 | + eslint-visitor-keys@5.0.1: | |
| 1120 | + resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} | |
| 1121 | + engines: {node: ^20.19.0 || ^22.13.0 || >=24} | |
| 1122 | + | |
| 1123 | + eslint@9.39.5: | |
| 1124 | + resolution: {integrity: sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==} | |
| 1125 | + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} | |
| 1126 | + deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options. | |
| 1127 | + hasBin: true | |
| 1128 | + peerDependencies: | |
| 1129 | + jiti: '*' | |
| 1130 | + peerDependenciesMeta: | |
| 1131 | + jiti: | |
| 1132 | + optional: true | |
| 1133 | + | |
| 1134 | + espree@10.4.0: | |
| 1135 | + resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} | |
| 1136 | + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} | |
| 1137 | + | |
| 1138 | + esquery@1.7.0: | |
| 1139 | + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} | |
| 1140 | + engines: {node: '>=0.10'} | |
| 1141 | + | |
| 1142 | + esrecurse@4.3.0: | |
| 1143 | + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} | |
| 1144 | + engines: {node: '>=4.0'} | |
| 1145 | + | |
| 1146 | + estraverse@5.3.0: | |
| 1147 | + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} | |
| 1148 | + engines: {node: '>=4.0'} | |
| 1149 | + | |
| 1150 | + esutils@2.0.3: | |
| 1151 | + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} | |
| 1152 | + engines: {node: '>=0.10.0'} | |
| 1153 | + | |
| 1154 | + fancy-canvas@2.1.0: | |
| 1155 | + resolution: {integrity: sha512-nifxXJ95JNLFR2NgRV4/MxVP45G9909wJTEKz5fg/TZS20JJZA6hfgRVh/bC9bwl2zBtBNcYPjiBE4njQHVBwQ==} | |
| 1156 | + | |
| 1157 | + fast-deep-equal@3.1.3: | |
| 1158 | + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} | |
| 1159 | + | |
| 1160 | + fast-glob@3.3.1: | |
| 1161 | + resolution: {integrity: sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==} | |
| 1162 | + engines: {node: '>=8.6.0'} | |
| 1163 | + | |
| 1164 | + fast-json-stable-stringify@2.1.0: | |
| 1165 | + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} | |
| 1166 | + | |
| 1167 | + fast-levenshtein@2.0.6: | |
| 1168 | + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} | |
| 1169 | + | |
| 1170 | + fastq@1.20.3: | |
| 1171 | + resolution: {integrity: sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw==} | |
| 1172 | + | |
| 1173 | + fdir@6.5.0: | |
| 1174 | + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} | |
| 1175 | + engines: {node: '>=12.0.0'} | |
| 1176 | + peerDependencies: | |
| 1177 | + picomatch: ^3 || ^4 | |
| 1178 | + peerDependenciesMeta: | |
| 1179 | + picomatch: | |
| 1180 | + optional: true | |
| 1181 | + | |
| 1182 | + file-entry-cache@8.0.0: | |
| 1183 | + resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} | |
| 1184 | + engines: {node: '>=16.0.0'} | |
| 1185 | + | |
| 1186 | + fill-range@7.1.1: | |
| 1187 | + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} | |
| 1188 | + engines: {node: '>=8'} | |
| 1189 | + | |
| 1190 | + find-up@5.0.0: | |
| 1191 | + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} | |
| 1192 | + engines: {node: '>=10'} | |
| 1193 | + | |
| 1194 | + flat-cache@4.0.1: | |
| 1195 | + resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} | |
| 1196 | + engines: {node: '>=16'} | |
| 1197 | + | |
| 1198 | + flatted@3.4.4: | |
| 1199 | + resolution: {integrity: sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==} | |
| 1200 | + | |
| 1201 | + for-each@0.3.5: | |
| 1202 | + resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} | |
| 1203 | + engines: {node: '>= 0.4'} | |
| 1204 | + | |
| 1205 | + function-bind@1.1.2: | |
| 1206 | + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} | |
| 1207 | + | |
| 1208 | + function.prototype.name@1.2.0: | |
| 1209 | + resolution: {integrity: sha512-jObKIik1P2QjPHP5nz5BaOtUlfgS0fWo8IUByNXkM+o+02sJOi94em77GwJKQSJ3gfPHdgzLNrHc1uokV4P/ew==} | |
| 1210 | + engines: {node: '>= 0.4'} | |
| 1211 | + | |
| 1212 | + functions-have-names@1.2.3: | |
| 1213 | + resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==} | |
| 1214 | + | |
| 1215 | + generator-function@2.0.1: | |
| 1216 | + resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==} | |
| 1217 | + engines: {node: '>= 0.4'} | |
| 1218 | + | |
| 1219 | + gensync@1.0.0-beta.2: | |
| 1220 | + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} | |
| 1221 | + engines: {node: '>=6.9.0'} | |
| 1222 | + | |
| 1223 | + get-intrinsic@1.3.0: | |
| 1224 | + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} | |
| 1225 | + engines: {node: '>= 0.4'} | |
| 1226 | + | |
| 1227 | + get-proto@1.0.1: | |
| 1228 | + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} | |
| 1229 | + engines: {node: '>= 0.4'} | |
| 1230 | + | |
| 1231 | + get-symbol-description@1.1.0: | |
| 1232 | + resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==} | |
| 1233 | + engines: {node: '>= 0.4'} | |
| 1234 | + | |
| 1235 | + get-tsconfig@4.14.3: | |
| 1236 | + resolution: {integrity: sha512-++QEw4DIY7WGoukz+/+A/8dGYPT9l9yIadnmSgZ8Rjr3YVSVDipQSO9CdnJo9ePqFqUUqh+wk9uIaoiAwsiPkA==} | |
| 1237 | + | |
| 1238 | + glob-parent@5.1.2: | |
| 1239 | + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} | |
| 1240 | + engines: {node: '>= 6'} | |
| 1241 | + | |
| 1242 | + glob-parent@6.0.2: | |
| 1243 | + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} | |
| 1244 | + engines: {node: '>=10.13.0'} | |
| 1245 | + | |
| 1246 | + globals@14.0.0: | |
| 1247 | + resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} | |
| 1248 | + engines: {node: '>=18'} | |
| 1249 | + | |
| 1250 | + globals@16.4.0: | |
| 1251 | + resolution: {integrity: sha512-ob/2LcVVaVGCYN+r14cnwnoDPUufjiYgSqRhiFD0Q1iI4Odora5RE8Iv1D24hAz5oMophRGkGz+yuvQmmUMnMw==} | |
| 1252 | + engines: {node: '>=18'} | |
| 1253 | + | |
| 1254 | + globalthis@1.0.4: | |
| 1255 | + resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==} | |
| 1256 | + engines: {node: '>= 0.4'} | |
| 1257 | + | |
| 1258 | + gopd@1.2.0: | |
| 1259 | + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} | |
| 1260 | + engines: {node: '>= 0.4'} | |
| 1261 | + | |
| 1262 | + graceful-fs@4.2.11: | |
| 1263 | + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} | |
| 1264 | + | |
| 1265 | + has-bigints@1.1.0: | |
| 1266 | + resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==} | |
| 1267 | + engines: {node: '>= 0.4'} | |
| 1268 | + | |
| 1269 | + has-flag@4.0.0: | |
| 1270 | + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} | |
| 1271 | + engines: {node: '>=8'} | |
| 1272 | + | |
| 1273 | + has-property-descriptors@1.0.2: | |
| 1274 | + resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} | |
| 1275 | + | |
| 1276 | + has-proto@1.2.0: | |
| 1277 | + resolution: {integrity: sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==} | |
| 1278 | + engines: {node: '>= 0.4'} | |
| 1279 | + | |
| 1280 | + has-symbols@1.1.0: | |
| 1281 | + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} | |
| 1282 | + engines: {node: '>= 0.4'} | |
| 1283 | + | |
| 1284 | + has-tostringtag@1.0.2: | |
| 1285 | + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} | |
| 1286 | + engines: {node: '>= 0.4'} | |
| 1287 | + | |
| 1288 | + hasown@2.0.4: | |
| 1289 | + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} | |
| 1290 | + engines: {node: '>= 0.4'} | |
| 1291 | + | |
| 1292 | + hermes-estree@0.25.1: | |
| 1293 | + resolution: {integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==} | |
| 1294 | + | |
| 1295 | + hermes-parser@0.25.1: | |
| 1296 | + resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==} | |
| 1297 | + | |
| 1298 | + ignore@5.3.2: | |
| 1299 | + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} | |
| 1300 | + engines: {node: '>= 4'} | |
| 1301 | + | |
| 1302 | + ignore@7.0.13: | |
| 1303 | + resolution: {integrity: sha512-2E8Q6rh+1RRSq/YdaGsNfXsEZy9FVqv6BSSrkmZJP7BnaoPnRfKL+YaofPvP+5rzdk144qSm6i4+hvPgUcbV4Q==} | |
| 1304 | + engines: {node: '>= 4'} | |
| 1305 | + | |
| 1306 | + import-fresh@3.3.1: | |
| 1307 | + resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} | |
| 1308 | + engines: {node: '>=6'} | |
| 1309 | + | |
| 1310 | + imurmurhash@0.1.4: | |
| 1311 | + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} | |
| 1312 | + engines: {node: '>=0.8.19'} | |
| 1313 | + | |
| 1314 | + internal-slot@1.1.0: | |
| 1315 | + resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} | |
| 1316 | + engines: {node: '>= 0.4'} | |
| 1317 | + | |
| 1318 | + is-array-buffer@3.0.5: | |
| 1319 | + resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==} | |
| 1320 | + engines: {node: '>= 0.4'} | |
| 1321 | + | |
| 1322 | + is-async-function@2.1.1: | |
| 1323 | + resolution: {integrity: sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==} | |
| 1324 | + engines: {node: '>= 0.4'} | |
| 1325 | + | |
| 1326 | + is-bigint@1.1.0: | |
| 1327 | + resolution: {integrity: sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==} | |
| 1328 | + engines: {node: '>= 0.4'} | |
| 1329 | + | |
| 1330 | + is-boolean-object@1.2.2: | |
| 1331 | + resolution: {integrity: sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==} | |
| 1332 | + engines: {node: '>= 0.4'} | |
| 1333 | + | |
| 1334 | + is-bun-module@2.0.0: | |
| 1335 | + resolution: {integrity: sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ==} | |
| 1336 | + | |
| 1337 | + is-callable@1.2.7: | |
| 1338 | + resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==} | |
| 1339 | + engines: {node: '>= 0.4'} | |
| 1340 | + | |
| 1341 | + is-core-module@2.17.0: | |
| 1342 | + resolution: {integrity: sha512-J/vG0zBCbIKOQFfufSwyXdMrsohyJIUNkrnmo6WZGzoM7tr/lsbfW5b2BvisL6zsyMzK9UxV9L6c7AoFbyXHOA==} | |
| 1343 | + engines: {node: '>= 0.4'} | |
| 1344 | + | |
| 1345 | + is-data-view@1.0.2: | |
| 1346 | + resolution: {integrity: sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==} | |
| 1347 | + engines: {node: '>= 0.4'} | |
| 1348 | + | |
| 1349 | + is-date-object@1.1.0: | |
| 1350 | + resolution: {integrity: sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==} | |
| 1351 | + engines: {node: '>= 0.4'} | |
| 1352 | + | |
| 1353 | + is-document.all@1.0.0: | |
| 1354 | + resolution: {integrity: sha512-+XSoyS05OdBbhFuELhgTCpFNHkpBOJqtsZfUFFpe5QTw+9Sjbh8zitxhQkYAo6wV7e1Vb8cAPvpCk9jGam/82g==} | |
| 1355 | + engines: {node: '>= 0.4'} | |
| 1356 | + | |
| 1357 | + is-extglob@2.1.1: | |
| 1358 | + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} | |
| 1359 | + engines: {node: '>=0.10.0'} | |
| 1360 | + | |
| 1361 | + is-finalizationregistry@1.1.1: | |
| 1362 | + resolution: {integrity: sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==} | |
| 1363 | + engines: {node: '>= 0.4'} | |
| 1364 | + | |
| 1365 | + is-generator-function@1.1.2: | |
| 1366 | + resolution: {integrity: sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==} | |
| 1367 | + engines: {node: '>= 0.4'} | |
| 1368 | + | |
| 1369 | + is-glob@4.0.3: | |
| 1370 | + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} | |
| 1371 | + engines: {node: '>=0.10.0'} | |
| 1372 | + | |
| 1373 | + is-map@2.0.3: | |
| 1374 | + resolution: {integrity: sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==} | |
| 1375 | + engines: {node: '>= 0.4'} | |
| 1376 | + | |
| 1377 | + is-negative-zero@2.0.3: | |
| 1378 | + resolution: {integrity: sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==} | |
| 1379 | + engines: {node: '>= 0.4'} | |
| 1380 | + | |
| 1381 | + is-number-object@1.1.1: | |
| 1382 | + resolution: {integrity: sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==} | |
| 1383 | + engines: {node: '>= 0.4'} | |
| 1384 | + | |
| 1385 | + is-number@7.0.0: | |
| 1386 | + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} | |
| 1387 | + engines: {node: '>=0.12.0'} | |
| 1388 | + | |
| 1389 | + is-regex@1.2.1: | |
| 1390 | + resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} | |
| 1391 | + engines: {node: '>= 0.4'} | |
| 1392 | + | |
| 1393 | + is-set@2.0.3: | |
| 1394 | + resolution: {integrity: sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==} | |
| 1395 | + engines: {node: '>= 0.4'} | |
| 1396 | + | |
| 1397 | + is-shared-array-buffer@1.0.4: | |
| 1398 | + resolution: {integrity: sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==} | |
| 1399 | + engines: {node: '>= 0.4'} | |
| 1400 | + | |
| 1401 | + is-string@1.1.1: | |
| 1402 | + resolution: {integrity: sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==} | |
| 1403 | + engines: {node: '>= 0.4'} | |
| 1404 | + | |
| 1405 | + is-symbol@1.1.1: | |
| 1406 | + resolution: {integrity: sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==} | |
| 1407 | + engines: {node: '>= 0.4'} | |
| 1408 | + | |
| 1409 | + is-typed-array@1.1.15: | |
| 1410 | + resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==} | |
| 1411 | + engines: {node: '>= 0.4'} | |
| 1412 | + | |
| 1413 | + is-weakmap@2.0.2: | |
| 1414 | + resolution: {integrity: sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==} | |
| 1415 | + engines: {node: '>= 0.4'} | |
| 1416 | + | |
| 1417 | + is-weakref@1.1.1: | |
| 1418 | + resolution: {integrity: sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==} | |
| 1419 | + engines: {node: '>= 0.4'} | |
| 1420 | + | |
| 1421 | + is-weakset@2.0.4: | |
| 1422 | + resolution: {integrity: sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==} | |
| 1423 | + engines: {node: '>= 0.4'} | |
| 1424 | + | |
| 1425 | + isarray@2.0.5: | |
| 1426 | + resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} | |
| 1427 | + | |
| 1428 | + isexe@2.0.0: | |
| 1429 | + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} | |
| 1430 | + | |
| 1431 | + iterator.prototype@1.1.5: | |
| 1432 | + resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==} | |
| 1433 | + engines: {node: '>= 0.4'} | |
| 1434 | + | |
| 1435 | + jiti@2.7.0: | |
| 1436 | + resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} | |
| 1437 | + hasBin: true | |
| 1438 | + | |
| 1439 | + js-tokens@4.0.0: | |
| 1440 | + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} | |
| 1441 | + | |
| 1442 | + js-yaml@4.3.2: | |
| 1443 | + resolution: {integrity: sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==} | |
| 1444 | + hasBin: true | |
| 1445 | + | |
| 1446 | + jsesc@3.1.0: | |
| 1447 | + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} | |
| 1448 | + engines: {node: '>=6'} | |
| 1449 | + hasBin: true | |
| 1450 | + | |
| 1451 | + json-buffer@3.0.1: | |
| 1452 | + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} | |
| 1453 | + | |
| 1454 | + json-schema-traverse@0.4.1: | |
| 1455 | + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} | |
| 1456 | + | |
| 1457 | + json-stable-stringify-without-jsonify@1.0.1: | |
| 1458 | + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} | |
| 1459 | + | |
| 1460 | + json5@1.0.2: | |
| 1461 | + resolution: {integrity: sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==} | |
| 1462 | + hasBin: true | |
| 1463 | + | |
| 1464 | + json5@2.2.3: | |
| 1465 | + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} | |
| 1466 | + engines: {node: '>=6'} | |
| 1467 | + hasBin: true | |
| 1468 | + | |
| 1469 | + jsx-ast-utils@3.3.5: | |
| 1470 | + resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} | |
| 1471 | + engines: {node: '>=4.0'} | |
| 1472 | + | |
| 1473 | + keyv@4.5.4: | |
| 1474 | + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} | |
| 1475 | + | |
| 1476 | + language-subtag-registry@0.3.23: | |
| 1477 | + resolution: {integrity: sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==} | |
| 1478 | + | |
| 1479 | + language-tags@1.0.9: | |
| 1480 | + resolution: {integrity: sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==} | |
| 1481 | + engines: {node: '>=0.10'} | |
| 1482 | + | |
| 1483 | + levn@0.4.1: | |
| 1484 | + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} | |
| 1485 | + engines: {node: '>= 0.8.0'} | |
| 1486 | + | |
| 1487 | + lightningcss-android-arm64@1.32.0: | |
| 1488 | + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} | |
| 1489 | + engines: {node: '>= 12.0.0'} | |
| 1490 | + cpu: [arm64] | |
| 1491 | + os: [android] | |
| 1492 | + | |
| 1493 | + lightningcss-darwin-arm64@1.32.0: | |
| 1494 | + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} | |
| 1495 | + engines: {node: '>= 12.0.0'} | |
| 1496 | + cpu: [arm64] | |
| 1497 | + os: [darwin] | |
| 1498 | + | |
| 1499 | + lightningcss-darwin-x64@1.32.0: | |
| 1500 | + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} | |
| 1501 | + engines: {node: '>= 12.0.0'} | |
| 1502 | + cpu: [x64] | |
| 1503 | + os: [darwin] | |
| 1504 | + | |
| 1505 | + lightningcss-freebsd-x64@1.32.0: | |
| 1506 | + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} | |
| 1507 | + engines: {node: '>= 12.0.0'} | |
| 1508 | + cpu: [x64] | |
| 1509 | + os: [freebsd] | |
| 1510 | + | |
| 1511 | + lightningcss-linux-arm-gnueabihf@1.32.0: | |
| 1512 | + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} | |
| 1513 | + engines: {node: '>= 12.0.0'} | |
| 1514 | + cpu: [arm] | |
| 1515 | + os: [linux] | |
| 1516 | + | |
| 1517 | + lightningcss-linux-arm64-gnu@1.32.0: | |
| 1518 | + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} | |
| 1519 | + engines: {node: '>= 12.0.0'} | |
| 1520 | + cpu: [arm64] | |
| 1521 | + os: [linux] | |
| 1522 | + libc: [glibc] | |
| 1523 | + | |
| 1524 | + lightningcss-linux-arm64-musl@1.32.0: | |
| 1525 | + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} | |
| 1526 | + engines: {node: '>= 12.0.0'} | |
| 1527 | + cpu: [arm64] | |
| 1528 | + os: [linux] | |
| 1529 | + libc: [musl] | |
| 1530 | + | |
| 1531 | + lightningcss-linux-x64-gnu@1.32.0: | |
| 1532 | + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} | |
| 1533 | + engines: {node: '>= 12.0.0'} | |
| 1534 | + cpu: [x64] | |
| 1535 | + os: [linux] | |
| 1536 | + libc: [glibc] | |
| 1537 | + | |
| 1538 | + lightningcss-linux-x64-musl@1.32.0: | |
| 1539 | + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} | |
| 1540 | + engines: {node: '>= 12.0.0'} | |
| 1541 | + cpu: [x64] | |
| 1542 | + os: [linux] | |
| 1543 | + libc: [musl] | |
| 1544 | + | |
| 1545 | + lightningcss-win32-arm64-msvc@1.32.0: | |
| 1546 | + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} | |
| 1547 | + engines: {node: '>= 12.0.0'} | |
| 1548 | + cpu: [arm64] | |
| 1549 | + os: [win32] | |
| 1550 | + | |
| 1551 | + lightningcss-win32-x64-msvc@1.32.0: | |
| 1552 | + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} | |
| 1553 | + engines: {node: '>= 12.0.0'} | |
| 1554 | + cpu: [x64] | |
| 1555 | + os: [win32] | |
| 1556 | + | |
| 1557 | + lightningcss@1.32.0: | |
| 1558 | + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} | |
| 1559 | + engines: {node: '>= 12.0.0'} | |
| 1560 | + | |
| 1561 | + lightweight-charts@5.2.1: | |
| 1562 | + resolution: {integrity: sha512-IVwoK1RLFiLPubaKIjNbtjWLnpPMqiABSrTay6whmNa8L1+19292VtHJ+BWyPUuLCwF0tcQlhEWd1CLB2a1nsQ==} | |
| 1563 | + | |
| 1564 | + locate-path@6.0.0: | |
| 1565 | + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} | |
| 1566 | + engines: {node: '>=10'} | |
| 1567 | + | |
| 1568 | + lodash.merge@4.6.2: | |
| 1569 | + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} | |
| 1570 | + | |
| 1571 | + loose-envify@1.4.0: | |
| 1572 | + resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} | |
| 1573 | + hasBin: true | |
| 1574 | + | |
| 1575 | + lru-cache@5.1.1: | |
| 1576 | + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} | |
| 1577 | + | |
| 1578 | + lucide-react@1.60.0: | |
| 1579 | + resolution: {integrity: sha512-UqxOW0FgAfDyb9VmjKb6/BkOzW6yfhf/QNboKtS2NDsscnNRW2CDzlyhbMKBQ1ontmcxuIDqHJ2AVgFR3NI+GA==} | |
| 1580 | + peerDependencies: | |
| 1581 | + '@types/react': '*' | |
| 1582 | + react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 | |
| 1583 | + peerDependenciesMeta: | |
| 1584 | + '@types/react': | |
| 1585 | + optional: true | |
| 1586 | + | |
| 1587 | + magic-string@0.30.21: | |
| 1588 | + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} | |
| 1589 | + | |
| 1590 | + math-intrinsics@1.1.0: | |
| 1591 | + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} | |
| 1592 | + engines: {node: '>= 0.4'} | |
| 1593 | + | |
| 1594 | + merge2@1.4.1: | |
| 1595 | + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} | |
| 1596 | + engines: {node: '>= 8'} | |
| 1597 | + | |
| 1598 | + micromatch@4.0.8: | |
| 1599 | + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} | |
| 1600 | + engines: {node: '>=8.6'} | |
| 1601 | + | |
| 1602 | + minimatch@10.2.6: | |
| 1603 | + resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==} | |
| 1604 | + engines: {node: 18 || 20 || >=22} | |
| 1605 | + | |
| 1606 | + minimatch@3.1.5: | |
| 1607 | + resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} | |
| 1608 | + | |
| 1609 | + minimist@1.2.8: | |
| 1610 | + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} | |
| 1611 | + | |
| 1612 | + ms@2.1.3: | |
| 1613 | + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} | |
| 1614 | + | |
| 1615 | + nanoid@3.3.20: | |
| 1616 | + resolution: {integrity: sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==} | |
| 1617 | + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} | |
| 1618 | + hasBin: true | |
| 1619 | + | |
| 1620 | + napi-postinstall@0.3.5: | |
| 1621 | + resolution: {integrity: sha512-NFMjXXkyodBJnRrM0A6qxFO1OLUbrYLDhP1ZLetlXnyx41gKOAmVyROibq85j7I8dda4aZMs9L7kmRvvjNf7Cg==} | |
| 1622 | + engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} | |
| 1623 | + hasBin: true | |
| 1624 | + | |
| 1625 | + natural-compare@1.4.0: | |
| 1626 | + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} | |
| 1627 | + | |
| 1628 | + next@16.4.2: | |
| 1629 | + resolution: {integrity: sha512-r+oBJojfefUjOiWkBhXgLS+igxX/7BWyK59uHnyOyplMSLtBZ+QLP3okrdYTbrfrbrmNTVWLBPaHDX9KRaIt3w==} | |
| 1630 | + engines: {node: '>=20.9.0'} | |
| 1631 | + hasBin: true | |
| 1632 | + peerDependencies: | |
| 1633 | + '@opentelemetry/api': ^1.1.0 | |
| 1634 | + '@playwright/test': ^1.51.1 | |
| 1635 | + babel-plugin-react-compiler: '*' | |
| 1636 | + react: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 | |
| 1637 | + react-dom: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 | |
| 1638 | + sass: ^1.3.0 | |
| 1639 | + peerDependenciesMeta: | |
| 1640 | + '@opentelemetry/api': | |
| 1641 | + optional: true | |
| 1642 | + '@playwright/test': | |
| 1643 | + optional: true | |
| 1644 | + babel-plugin-react-compiler: | |
| 1645 | + optional: true | |
| 1646 | + sass: | |
| 1647 | + optional: true | |
| 1648 | + | |
| 1649 | + node-exports-info@1.6.2: | |
| 1650 | + resolution: {integrity: sha512-kXs9Go0cah0qHVV2v389IXQLdLCeE1xfFtjOAF+iobu0OIoG1pje8At2vMHyaPMiPMnG/LWP50twML21eMcAag==} | |
| 1651 | + engines: {node: '>= 0.4'} | |
| 1652 | + | |
| 1653 | + node-releases@2.0.59: | |
| 1654 | + resolution: {integrity: sha512-5qr2okC4ItAI0hG5SaV0HoYMpnM/Y6+Oqr7AmPYy/2uEoGvS+GbSKLeRxSS1uAdu59yz0ouzcuXievSgg///WA==} | |
| 1655 | + engines: {node: '>=18'} | |
| 1656 | + | |
| 1657 | + object-assign@4.1.1: | |
| 1658 | + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} | |
| 1659 | + engines: {node: '>=0.10.0'} | |
| 1660 | + | |
| 1661 | + object-inspect@1.13.4: | |
| 1662 | + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} | |
| 1663 | + engines: {node: '>= 0.4'} | |
| 1664 | + | |
| 1665 | + object-keys@1.1.1: | |
| 1666 | + resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} | |
| 1667 | + engines: {node: '>= 0.4'} | |
| 1668 | + | |
| 1669 | + object.assign@4.1.7: | |
| 1670 | + resolution: {integrity: sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==} | |
| 1671 | + engines: {node: '>= 0.4'} | |
| 1672 | + | |
| 1673 | + object.entries@1.1.9: | |
| 1674 | + resolution: {integrity: sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==} | |
| 1675 | + engines: {node: '>= 0.4'} | |
| 1676 | + | |
| 1677 | + object.fromentries@2.0.8: | |
| 1678 | + resolution: {integrity: sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==} | |
| 1679 | + engines: {node: '>= 0.4'} | |
| 1680 | + | |
| 1681 | + object.groupby@1.0.3: | |
| 1682 | + resolution: {integrity: sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==} | |
| 1683 | + engines: {node: '>= 0.4'} | |
| 1684 | + | |
| 1685 | + object.values@1.2.1: | |
| 1686 | + resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==} | |
| 1687 | + engines: {node: '>= 0.4'} | |
| 1688 | + | |
| 1689 | + optionator@0.9.4: | |
| 1690 | + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} | |
| 1691 | + engines: {node: '>= 0.8.0'} | |
| 1692 | + | |
| 1693 | + own-keys@1.0.2: | |
| 1694 | + resolution: {integrity: sha512-19YVAg7T+WTrxggPukVq7DjTv6+PJ867TmhCvBsYwmbFCsZd344rq2Ld1p0wo8f8Qrrhgp82c6FJRqdXWtSEhg==} | |
| 1695 | + engines: {node: '>= 0.4'} | |
| 1696 | + | |
| 1697 | + p-limit@3.1.0: | |
| 1698 | + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} | |
| 1699 | + engines: {node: '>=10'} | |
| 1700 | + | |
| 1701 | + p-locate@5.0.0: | |
| 1702 | + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} | |
| 1703 | + engines: {node: '>=10'} | |
| 1704 | + | |
| 1705 | + parent-module@1.0.1: | |
| 1706 | + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} | |
| 1707 | + engines: {node: '>=6'} | |
| 1708 | + | |
| 1709 | + path-exists@4.0.0: | |
| 1710 | + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} | |
| 1711 | + engines: {node: '>=8'} | |
| 1712 | + | |
| 1713 | + path-key@3.1.1: | |
| 1714 | + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} | |
| 1715 | + engines: {node: '>=8'} | |
| 1716 | + | |
| 1717 | + path-parse@1.0.7: | |
| 1718 | + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} | |
| 1719 | + | |
| 1720 | + picocolors@1.1.1: | |
| 1721 | + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} | |
| 1722 | + | |
| 1723 | + picomatch@2.3.2: | |
| 1724 | + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} | |
| 1725 | + engines: {node: '>=8.6'} | |
| 1726 | + | |
| 1727 | + picomatch@4.0.7: | |
| 1728 | + resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} | |
| 1729 | + engines: {node: '>=12'} | |
| 1730 | + | |
| 1731 | + possible-typed-array-names@1.1.0: | |
| 1732 | + resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} | |
| 1733 | + engines: {node: '>= 0.4'} | |
| 1734 | + | |
| 1735 | + postcss@8.5.23: | |
| 1736 | + resolution: {integrity: sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==} | |
| 1737 | + engines: {node: ^10 || ^12 || >=14} | |
| 1738 | + | |
| 1739 | + prelude-ls@1.2.1: | |
| 1740 | + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} | |
| 1741 | + engines: {node: '>= 0.8.0'} | |
| 1742 | + | |
| 1743 | + prop-types@15.8.1: | |
| 1744 | + resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} | |
| 1745 | + | |
| 1746 | + punycode@2.3.1: | |
| 1747 | + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} | |
| 1748 | + engines: {node: '>=6'} | |
| 1749 | + | |
| 1750 | + queue-microtask@1.2.3: | |
| 1751 | + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} | |
| 1752 | + | |
| 1753 | + react-dom@19.3.0: | |
| 1754 | + resolution: {integrity: sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==} | |
| 1755 | + peerDependencies: | |
| 1756 | + react: ^19.3.0 | |
| 1757 | + | |
| 1758 | + react-is@16.13.1: | |
| 1759 | + resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} | |
| 1760 | + | |
| 1761 | + react@19.3.0: | |
| 1762 | + resolution: {integrity: sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==} | |
| 1763 | + engines: {node: '>=0.10.0'} | |
| 1764 | + | |
| 1765 | + reflect.getprototypeof@1.0.10: | |
| 1766 | + resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} | |
| 1767 | + engines: {node: '>= 0.4'} | |
| 1768 | + | |
| 1769 | + regexp.prototype.flags@1.5.4: | |
| 1770 | + resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} | |
| 1771 | + engines: {node: '>= 0.4'} | |
| 1772 | + | |
| 1773 | + resolve-from@4.0.0: | |
| 1774 | + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} | |
| 1775 | + engines: {node: '>=4'} | |
| 1776 | + | |
| 1777 | + resolve-pkg-maps@1.0.0: | |
| 1778 | + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} | |
| 1779 | + | |
| 1780 | + resolve@2.0.0-next.7: | |
| 1781 | + resolution: {integrity: sha512-tqt+NBWwyaMgw3zDsnygx4CByWjQEJHOPMdslYhppaQSJUtL/D4JO9CcBBlhPoI8lz9oJIDXkwXfhF4aWqP8xQ==} | |
| 1782 | + engines: {node: '>= 0.4'} | |
| 1783 | + hasBin: true | |
| 1784 | + | |
| 1785 | + reusify@1.1.0: | |
| 1786 | + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} | |
| 1787 | + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} | |
| 1788 | + | |
| 1789 | + run-parallel@1.2.0: | |
| 1790 | + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} | |
| 1791 | + | |
| 1792 | + safe-array-concat@1.1.4: | |
| 1793 | + resolution: {integrity: sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==} | |
| 1794 | + engines: {node: '>=0.4'} | |
| 1795 | + | |
| 1796 | + safe-push-apply@1.0.0: | |
| 1797 | + resolution: {integrity: sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==} | |
| 1798 | + engines: {node: '>= 0.4'} | |
| 1799 | + | |
| 1800 | + safe-regex-test@1.1.0: | |
| 1801 | + resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} | |
| 1802 | + engines: {node: '>= 0.4'} | |
| 1803 | + | |
| 1804 | + scheduler@0.28.0: | |
| 1805 | + resolution: {integrity: sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==} | |
| 1806 | + | |
| 1807 | + semver@6.3.1: | |
| 1808 | + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} | |
| 1809 | + hasBin: true | |
| 1810 | + | |
| 1811 | + semver@7.8.5: | |
| 1812 | + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} | |
| 1813 | + engines: {node: '>=10'} | |
| 1814 | + hasBin: true | |
| 1815 | + | |
| 1816 | + set-function-length@1.2.2: | |
| 1817 | + resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} | |
| 1818 | + engines: {node: '>= 0.4'} | |
| 1819 | + | |
| 1820 | + set-function-name@2.0.2: | |
| 1821 | + resolution: {integrity: sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==} | |
| 1822 | + engines: {node: '>= 0.4'} | |
| 1823 | + | |
| 1824 | + set-proto@1.0.0: | |
| 1825 | + resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==} | |
| 1826 | + engines: {node: '>= 0.4'} | |
| 1827 | + | |
| 1828 | + sharp@0.35.5: | |
| 1829 | + resolution: {integrity: sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g==} | |
| 1830 | + engines: {node: '>=20.9.0'} | |
| 1831 | + peerDependencies: | |
| 1832 | + '@types/node': '*' | |
| 1833 | + peerDependenciesMeta: | |
| 1834 | + '@types/node': | |
| 1835 | + optional: true | |
| 1836 | + | |
| 1837 | + shebang-command@2.0.0: | |
| 1838 | + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} | |
| 1839 | + engines: {node: '>=8'} | |
| 1840 | + | |
| 1841 | + shebang-regex@3.0.0: | |
| 1842 | + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} | |
| 1843 | + engines: {node: '>=8'} | |
| 1844 | + | |
| 1845 | + side-channel-list@1.0.1: | |
| 1846 | + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} | |
| 1847 | + engines: {node: '>= 0.4'} | |
| 1848 | + | |
| 1849 | + side-channel-map@1.0.1: | |
| 1850 | + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} | |
| 1851 | + engines: {node: '>= 0.4'} | |
| 1852 | + | |
| 1853 | + side-channel-weakmap@1.0.2: | |
| 1854 | + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} | |
| 1855 | + engines: {node: '>= 0.4'} | |
| 1856 | + | |
| 1857 | + side-channel@1.1.1: | |
| 1858 | + resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} | |
| 1859 | + engines: {node: '>= 0.4'} | |
| 1860 | + | |
| 1861 | + source-map-js@1.2.2: | |
| 1862 | + resolution: {integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==} | |
| 1863 | + engines: {node: '>=0.10.0'} | |
| 1864 | + | |
| 1865 | + stable-hash@0.0.5: | |
| 1866 | + resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==} | |
| 1867 | + | |
| 1868 | + stop-iteration-iterator@1.1.0: | |
| 1869 | + resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} | |
| 1870 | + engines: {node: '>= 0.4'} | |
| 1871 | + | |
| 1872 | + string.prototype.includes@2.0.1: | |
| 1873 | + resolution: {integrity: sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==} | |
| 1874 | + engines: {node: '>= 0.4'} | |
| 1875 | + | |
| 1876 | + string.prototype.matchall@4.1.0: | |
| 1877 | + resolution: {integrity: sha512-tHNHTxInrYLCga9O9YGxWA3G9/nnzQw8UGAyqGx3Ar1pSTTzIuM4woFSq4SowkXCjJIwq5sIiQvEfRI9tCH1qQ==} | |
| 1878 | + engines: {node: '>= 0.4'} | |
| 1879 | + | |
| 1880 | + string.prototype.repeat@1.0.0: | |
| 1881 | + resolution: {integrity: sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==} | |
| 1882 | + | |
| 1883 | + string.prototype.trim@1.2.11: | |
| 1884 | + resolution: {integrity: sha512-PwvK7BU+CMTJGYQCTZb5RWXIML92lftJLhQz1tBzgKiqGxJaMlBAa48POXaNAC2s4y8jr3EFqrkF9+44neS46w==} | |
| 1885 | + engines: {node: '>= 0.4'} | |
| 1886 | + | |
| 1887 | + string.prototype.trimend@1.0.10: | |
| 1888 | + resolution: {integrity: sha512-2+3aDAOmPTmuFwjDnmJG2ctEkQKVki7vOSqaxkv42Mowj1V6PnvuwFCRrR5lChUux1TBskPjfkeTOhqczDMxTw==} | |
| 1889 | + engines: {node: '>= 0.4'} | |
| 1890 | + | |
| 1891 | + string.prototype.trimstart@1.0.8: | |
| 1892 | + resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==} | |
| 1893 | + engines: {node: '>= 0.4'} | |
| 1894 | + | |
| 1895 | + strip-bom@3.0.0: | |
| 1896 | + resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} | |
| 1897 | + engines: {node: '>=4'} | |
| 1898 | + | |
| 1899 | + strip-json-comments@3.1.1: | |
| 1900 | + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} | |
| 1901 | + engines: {node: '>=8'} | |
| 1902 | + | |
| 1903 | + styled-jsx@5.1.6: | |
| 1904 | + resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==} | |
| 1905 | + engines: {node: '>= 12.0.0'} | |
| 1906 | + peerDependencies: | |
| 1907 | + '@babel/core': '*' | |
| 1908 | + babel-plugin-macros: '*' | |
| 1909 | + react: '>= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0' | |
| 1910 | + peerDependenciesMeta: | |
| 1911 | + '@babel/core': | |
| 1912 | + optional: true | |
| 1913 | + babel-plugin-macros: | |
| 1914 | + optional: true | |
| 1915 | + | |
| 1916 | + supports-color@7.2.0: | |
| 1917 | + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} | |
| 1918 | + engines: {node: '>=8'} | |
| 1919 | + | |
| 1920 | + supports-preserve-symlinks-flag@1.0.0: | |
| 1921 | + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} | |
| 1922 | + engines: {node: '>= 0.4'} | |
| 1923 | + | |
| 1924 | + tailwindcss@4.3.3: | |
| 1925 | + resolution: {integrity: sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==} | |
| 1926 | + | |
| 1927 | + tapable@2.3.3: | |
| 1928 | + resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} | |
| 1929 | + engines: {node: '>=6'} | |
| 1930 | + | |
| 1931 | + tinyglobby@0.2.17: | |
| 1932 | + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} | |
| 1933 | + engines: {node: '>=12.0.0'} | |
| 1934 | + | |
| 1935 | + to-regex-range@5.0.1: | |
| 1936 | + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} | |
| 1937 | + engines: {node: '>=8.0'} | |
| 1938 | + | |
| 1939 | + ts-api-utils@2.5.0: | |
| 1940 | + resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} | |
| 1941 | + engines: {node: '>=18.12'} | |
| 1942 | + peerDependencies: | |
| 1943 | + typescript: '>=4.8.4' | |
| 1944 | + | |
| 1945 | + tsconfig-paths@3.15.0: | |
| 1946 | + resolution: {integrity: sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==} | |
| 1947 | + | |
| 1948 | + tslib@2.8.1: | |
| 1949 | + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} | |
| 1950 | + | |
| 1951 | + type-check@0.4.0: | |
| 1952 | + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} | |
| 1953 | + engines: {node: '>= 0.8.0'} | |
| 1954 | + | |
| 1955 | + typed-array-buffer@1.0.3: | |
| 1956 | + resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} | |
| 1957 | + engines: {node: '>= 0.4'} | |
| 1958 | + | |
| 1959 | + typed-array-byte-length@1.0.3: | |
| 1960 | + resolution: {integrity: sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==} | |
| 1961 | + engines: {node: '>= 0.4'} | |
| 1962 | + | |
| 1963 | + typed-array-byte-offset@1.0.5: | |
| 1964 | + resolution: {integrity: sha512-0FHJvLPqZ7KJzp17O13jfsAjsqazgrxBu2zEK95PmUz8lv2+GjRuxUInCr2Rk9Dms3ihN21zJ929ZO43yJ95QQ==} | |
| 1965 | + engines: {node: '>= 0.4'} | |
| 1966 | + | |
| 1967 | + typed-array-length@1.0.8: | |
| 1968 | + resolution: {integrity: sha512-phPGCwqr2+Qo0fwniCE8e4pKnGu/yFb5nD5Y8bf0EEeiI5GklnACYA9GFy/DrAeRrKHXvHn+1SUsOWgJp6RO+g==} | |
| 1969 | + engines: {node: '>= 0.4'} | |
| 1970 | + | |
| 1971 | + typescript-eslint@8.72.0: | |
| 1972 | + resolution: {integrity: sha512-r26Fkc8As+mZvqp7SLsMmNFyYUup/6CjK7/eHBHmhOw6GH6n6YFxHVFrG0jN1c5CMpDzelLPxvgeCOzx7BjN+A==} | |
| 1973 | + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} | |
| 1974 | + peerDependencies: | |
| 1975 | + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 | |
| 1976 | + typescript: '>=4.8.4 <6.1.0' | |
| 1977 | + | |
| 1978 | + typescript@5.9.3: | |
| 1979 | + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} | |
| 1980 | + engines: {node: '>=14.17'} | |
| 1981 | + hasBin: true | |
| 1982 | + | |
| 1983 | + unbox-primitive@1.1.0: | |
| 1984 | + resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} | |
| 1985 | + engines: {node: '>= 0.4'} | |
| 1986 | + | |
| 1987 | + undici-types@6.21.0: | |
| 1988 | + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} | |
| 1989 | + | |
| 1990 | + unrs-resolver@1.12.2: | |
| 1991 | + resolution: {integrity: sha512-dmlRxBJJayXjqTwC+JtF1HhJmgf3ftQ3YejFcZrf4+KKtJv0qDsK1pjqaaVjG7wJ5NJ6UVP1OqRMQ71Z4C3rxQ==} | |
| 1992 | + | |
| 1993 | + update-browserslist-db@1.3.4: | |
| 1994 | + resolution: {integrity: sha512-t8wonIOdQyOjtPd2VktlBu6QjTVTks6v8bSi7kd0r2Vll8TK1JYDZtRRdbmCHOAMphkZgrzK+L+vXK4pyZ8t4A==} | |
| 1995 | + hasBin: true | |
| 1996 | + peerDependencies: | |
| 1997 | + browserslist: '>= 4.21.0' | |
| 1998 | + | |
| 1999 | + uri-js@4.4.1: | |
| 2000 | + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} | |
| 2001 | + | |
| 2002 | + which-boxed-primitive@1.1.1: | |
| 2003 | + resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} | |
| 2004 | + engines: {node: '>= 0.4'} | |
| 2005 | + | |
| 2006 | + which-builtin-type@1.2.1: | |
| 2007 | + resolution: {integrity: sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==} | |
| 2008 | + engines: {node: '>= 0.4'} | |
| 2009 | + | |
| 2010 | + which-collection@1.0.2: | |
| 2011 | + resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==} | |
| 2012 | + engines: {node: '>= 0.4'} | |
| 2013 | + | |
| 2014 | + which-typed-array@1.1.24: | |
| 2015 | + resolution: {integrity: sha512-wk4Mf4pR5mRP7eYuuTBCIQ9d0ud2Fv2jRLQpfgnRjbOxAFHmjKFValgTpitVKzJJS8ajnYQV2Du1SZ8j6b/EUQ==} | |
| 2016 | + engines: {node: '>= 0.4'} | |
| 2017 | + | |
| 2018 | + which@2.0.2: | |
| 2019 | + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} | |
| 2020 | + engines: {node: '>= 8'} | |
| 2021 | + hasBin: true | |
| 2022 | + | |
| 2023 | + word-wrap@1.2.5: | |
| 2024 | + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} | |
| 2025 | + engines: {node: '>=0.10.0'} | |
| 2026 | + | |
| 2027 | + yallist@3.1.1: | |
| 2028 | + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} | |
| 2029 | + | |
| 2030 | + yocto-queue@0.1.0: | |
| 2031 | + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} | |
| 2032 | + engines: {node: '>=10'} | |
| 2033 | + | |
| 2034 | + zod-validation-error@4.0.2: | |
| 2035 | + resolution: {integrity: sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==} | |
| 2036 | + engines: {node: '>=18.0.0'} | |
| 2037 | + peerDependencies: | |
| 2038 | + zod: ^3.25.0 || ^4.0.0 | |
| 2039 | + | |
| 2040 | + zod@4.6.7: | |
| 2041 | + resolution: {integrity: sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==} | |
| 2042 | + | |
| 2043 | +snapshots: | |
| 2044 | + | |
| 2045 | + '@alloc/quick-lru@5.3.0': {} | |
| 2046 | + | |
| 2047 | + '@babel/code-frame@7.29.7': | |
| 2048 | + dependencies: | |
| 2049 | + '@babel/helper-validator-identifier': 7.29.7 | |
| 2050 | + js-tokens: 4.0.0 | |
| 2051 | + picocolors: 1.1.1 | |
| 2052 | + | |
| 2053 | + '@babel/compat-data@7.29.7': {} | |
| 2054 | + | |
| 2055 | + '@babel/core@7.29.7': | |
| 2056 | + dependencies: | |
| 2057 | + '@babel/code-frame': 7.29.7 | |
| 2058 | + '@babel/generator': 7.29.8 | |
| 2059 | + '@babel/helper-compilation-targets': 7.29.7 | |
| 2060 | + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) | |
| 2061 | + '@babel/helpers': 7.29.10 | |
| 2062 | + '@babel/parser': 7.29.9 | |
| 2063 | + '@babel/template': 7.29.7 | |
| 2064 | + '@babel/traverse': 7.29.10 | |
| 2065 | + '@babel/types': 7.29.8 | |
| 2066 | + '@jridgewell/remapping': 2.3.5 | |
| 2067 | + convert-source-map: 2.0.0 | |
| 2068 | + debug: 4.4.3 | |
| 2069 | + gensync: 1.0.0-beta.2 | |
| 2070 | + json5: 2.2.3 | |
| 2071 | + semver: 6.3.1 | |
| 2072 | + transitivePeerDependencies: | |
| 2073 | + - supports-color | |
| 2074 | + | |
| 2075 | + '@babel/generator@7.29.8': | |
| 2076 | + dependencies: | |
| 2077 | + '@babel/parser': 7.29.9 | |
| 2078 | + '@babel/types': 7.29.8 | |
| 2079 | + '@jridgewell/gen-mapping': 0.3.13 | |
| 2080 | + '@jridgewell/trace-mapping': 0.3.31 | |
| 2081 | + jsesc: 3.1.0 | |
| 2082 | + | |
| 2083 | + '@babel/helper-compilation-targets@7.29.7': | |
| 2084 | + dependencies: | |
| 2085 | + '@babel/compat-data': 7.29.7 | |
| 2086 | + '@babel/helper-validator-option': 7.29.7 | |
| 2087 | + browserslist: 4.29.4 | |
| 2088 | + lru-cache: 5.1.1 | |
| 2089 | + semver: 6.3.1 | |
| 2090 | + | |
| 2091 | + '@babel/helper-globals@7.29.7': {} | |
| 2092 | + | |
| 2093 | + '@babel/helper-module-imports@7.29.7': | |
| 2094 | + dependencies: | |
| 2095 | + '@babel/traverse': 7.29.10 | |
| 2096 | + '@babel/types': 7.29.8 | |
| 2097 | + transitivePeerDependencies: | |
| 2098 | + - supports-color | |
| 2099 | + | |
| 2100 | + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': | |
| 2101 | + dependencies: | |
| 2102 | + '@babel/core': 7.29.7 | |
| 2103 | + '@babel/helper-module-imports': 7.29.7 | |
| 2104 | + '@babel/helper-validator-identifier': 7.29.7 | |
| 2105 | + '@babel/traverse': 7.29.10 | |
| 2106 | + transitivePeerDependencies: | |
| 2107 | + - supports-color | |
| 2108 | + | |
| 2109 | + '@babel/helper-string-parser@7.29.7': {} | |
| 2110 | + | |
| 2111 | + '@babel/helper-validator-identifier@7.29.7': {} | |
| 2112 | + | |
| 2113 | + '@babel/helper-validator-option@7.29.7': {} | |
| 2114 | + | |
| 2115 | + '@babel/helpers@7.29.10': | |
| 2116 | + dependencies: | |
| 2117 | + '@babel/template': 7.29.7 | |
| 2118 | + '@babel/types': 7.29.8 | |
| 2119 | + | |
| 2120 | + '@babel/parser@7.29.9': | |
| 2121 | + dependencies: | |
| 2122 | + '@babel/types': 7.29.8 | |
| 2123 | + | |
| 2124 | + '@babel/template@7.29.7': | |
| 2125 | + dependencies: | |
| 2126 | + '@babel/code-frame': 7.29.7 | |
| 2127 | + '@babel/parser': 7.29.9 | |
| 2128 | + '@babel/types': 7.29.8 | |
| 2129 | + | |
| 2130 | + '@babel/traverse@7.29.10': | |
| 2131 | + dependencies: | |
| 2132 | + '@babel/code-frame': 7.29.7 | |
| 2133 | + '@babel/generator': 7.29.8 | |
| 2134 | + '@babel/helper-globals': 7.29.7 | |
| 2135 | + '@babel/parser': 7.29.9 | |
| 2136 | + '@babel/template': 7.29.7 | |
| 2137 | + '@babel/types': 7.29.8 | |
| 2138 | + debug: 4.4.3 | |
| 2139 | + transitivePeerDependencies: | |
| 2140 | + - supports-color | |
| 2141 | + | |
| 2142 | + '@babel/types@7.29.8': | |
| 2143 | + dependencies: | |
| 2144 | + '@babel/helper-string-parser': 7.29.7 | |
| 2145 | + '@babel/helper-validator-identifier': 7.29.7 | |
| 2146 | + | |
| 2147 | + '@emnapi/core@1.10.0': | |
| 2148 | + dependencies: | |
| 2149 | + '@emnapi/wasi-threads': 1.2.1 | |
| 2150 | + tslib: 2.8.1 | |
| 2151 | + optional: true | |
| 2152 | + | |
| 2153 | + '@emnapi/runtime@1.10.0': | |
| 2154 | + dependencies: | |
| 2155 | + tslib: 2.8.1 | |
| 2156 | + optional: true | |
| 2157 | + | |
| 2158 | + '@emnapi/runtime@1.11.3': | |
| 2159 | + dependencies: | |
| 2160 | + tslib: 2.8.1 | |
| 2161 | + optional: true | |
| 2162 | + | |
| 2163 | + '@emnapi/wasi-threads@1.2.1': | |
| 2164 | + dependencies: | |
| 2165 | + tslib: 2.8.1 | |
| 2166 | + optional: true | |
| 2167 | + | |
| 2168 | + '@eslint-community/eslint-utils@4.10.1(eslint@9.39.5(jiti@2.7.0))': | |
| 2169 | + dependencies: | |
| 2170 | + eslint: 9.39.5(jiti@2.7.0) | |
| 2171 | + eslint-visitor-keys: 3.4.3 | |
| 2172 | + | |
| 2173 | + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.5(jiti@2.7.0))': | |
| 2174 | + dependencies: | |
| 2175 | + eslint: 9.39.5(jiti@2.7.0) | |
| 2176 | + eslint-visitor-keys: 3.4.3 | |
| 2177 | + | |
| 2178 | + '@eslint-community/regexpp@4.12.2': {} | |
| 2179 | + | |
| 2180 | + '@eslint/config-array@0.21.2': | |
| 2181 | + dependencies: | |
| 2182 | + '@eslint/object-schema': 2.1.7 | |
| 2183 | + debug: 4.4.3 | |
| 2184 | + minimatch: 3.1.5 | |
| 2185 | + transitivePeerDependencies: | |
| 2186 | + - supports-color | |
| 2187 | + | |
| 2188 | + '@eslint/config-helpers@0.4.2': | |
| 2189 | + dependencies: | |
| 2190 | + '@eslint/core': 0.17.0 | |
| 2191 | + | |
| 2192 | + '@eslint/core@0.17.0': | |
| 2193 | + dependencies: | |
| 2194 | + '@types/json-schema': 7.0.15 | |
| 2195 | + | |
| 2196 | + '@eslint/eslintrc@3.3.7': | |
| 2197 | + dependencies: | |
| 2198 | + ajv: 6.15.0 | |
| 2199 | + debug: 4.4.3 | |
| 2200 | + espree: 10.4.0 | |
| 2201 | + globals: 14.0.0 | |
| 2202 | + ignore: 5.3.2 | |
| 2203 | + import-fresh: 3.3.1 | |
| 2204 | + js-yaml: 4.3.2 | |
| 2205 | + minimatch: 3.1.5 | |
| 2206 | + strip-json-comments: 3.1.1 | |
| 2207 | + transitivePeerDependencies: | |
| 2208 | + - supports-color | |
| 2209 | + | |
| 2210 | + '@eslint/js@9.39.5': {} | |
| 2211 | + | |
| 2212 | + '@eslint/object-schema@2.1.7': {} | |
| 2213 | + | |
| 2214 | + '@eslint/plugin-kit@0.4.1': | |
| 2215 | + dependencies: | |
| 2216 | + '@eslint/core': 0.17.0 | |
| 2217 | + levn: 0.4.1 | |
| 2218 | + | |
| 2219 | + '@humanfs/core@0.19.2': | |
| 2220 | + dependencies: | |
| 2221 | + '@humanfs/types': 0.15.0 | |
| 2222 | + | |
| 2223 | + '@humanfs/node@0.16.8': | |
| 2224 | + dependencies: | |
| 2225 | + '@humanfs/core': 0.19.2 | |
| 2226 | + '@humanfs/types': 0.15.0 | |
| 2227 | + '@humanwhocodes/retry': 0.4.3 | |
| 2228 | + | |
| 2229 | + '@humanfs/types@0.15.0': {} | |
| 2230 | + | |
| 2231 | + '@humanwhocodes/module-importer@1.0.1': {} | |
| 2232 | + | |
| 2233 | + '@humanwhocodes/retry@0.4.3': {} | |
| 2234 | + | |
| 2235 | + '@img/colour@1.1.0': | |
| 2236 | + optional: true | |
| 2237 | + | |
| 2238 | + '@img/sharp-darwin-arm64@0.35.5': | |
| 2239 | + optionalDependencies: | |
| 2240 | + '@img/sharp-libvips-darwin-arm64': 1.3.4 | |
| 2241 | + optional: true | |
| 2242 | + | |
| 2243 | + '@img/sharp-darwin-x64@0.35.5': | |
| 2244 | + optionalDependencies: | |
| 2245 | + '@img/sharp-libvips-darwin-x64': 1.3.4 | |
| 2246 | + optional: true | |
| 2247 | + | |
| 2248 | + '@img/sharp-freebsd-wasm32@0.35.5': | |
| 2249 | + dependencies: | |
| 2250 | + '@img/sharp-wasm32': 0.35.5 | |
| 2251 | + optional: true | |
| 2252 | + | |
| 2253 | + '@img/sharp-libvips-darwin-arm64@1.3.4': | |
| 2254 | + optional: true | |
| 2255 | + | |
| 2256 | + '@img/sharp-libvips-darwin-x64@1.3.4': | |
| 2257 | + optional: true | |
| 2258 | + | |
| 2259 | + '@img/sharp-libvips-linux-arm64@1.3.4': | |
| 2260 | + optional: true | |
| 2261 | + | |
| 2262 | + '@img/sharp-libvips-linux-arm@1.3.4': | |
| 2263 | + optional: true | |
| 2264 | + | |
| 2265 | + '@img/sharp-libvips-linux-ppc64@1.3.4': | |
| 2266 | + optional: true | |
| 2267 | + | |
| 2268 | + '@img/sharp-libvips-linux-riscv64@1.3.4': | |
| 2269 | + optional: true | |
| 2270 | + | |
| 2271 | + '@img/sharp-libvips-linux-s390x@1.3.4': | |
| 2272 | + optional: true | |
| 2273 | + | |
| 2274 | + '@img/sharp-libvips-linux-x64@1.3.4': | |
| 2275 | + optional: true | |
| 2276 | + | |
| 2277 | + '@img/sharp-libvips-linuxmusl-arm64@1.3.4': | |
| 2278 | + optional: true | |
| 2279 | + | |
| 2280 | + '@img/sharp-libvips-linuxmusl-x64@1.3.4': | |
| 2281 | + optional: true | |
| 2282 | + | |
| 2283 | + '@img/sharp-linux-arm64@0.35.5': | |
| 2284 | + optionalDependencies: | |
| 2285 | + '@img/sharp-libvips-linux-arm64': 1.3.4 | |
| 2286 | + optional: true | |
| 2287 | + | |
| 2288 | + '@img/sharp-linux-arm@0.35.5': | |
| 2289 | + optionalDependencies: | |
| 2290 | + '@img/sharp-libvips-linux-arm': 1.3.4 | |
| 2291 | + optional: true | |
| 2292 | + | |
| 2293 | + '@img/sharp-linux-ppc64@0.35.5': | |
| 2294 | + optionalDependencies: | |
| 2295 | + '@img/sharp-libvips-linux-ppc64': 1.3.4 | |
| 2296 | + optional: true | |
| 2297 | + | |
| 2298 | + '@img/sharp-linux-riscv64@0.35.5': | |
| 2299 | + optionalDependencies: | |
| 2300 | + '@img/sharp-libvips-linux-riscv64': 1.3.4 | |
| 2301 | + optional: true | |
| 2302 | + | |
| 2303 | + '@img/sharp-linux-s390x@0.35.5': | |
| 2304 | + optionalDependencies: | |
| 2305 | + '@img/sharp-libvips-linux-s390x': 1.3.4 | |
| 2306 | + optional: true | |
| 2307 | + | |
| 2308 | + '@img/sharp-linux-x64@0.35.5': | |
| 2309 | + optionalDependencies: | |
| 2310 | + '@img/sharp-libvips-linux-x64': 1.3.4 | |
| 2311 | + optional: true | |
| 2312 | + | |
| 2313 | + '@img/sharp-linuxmusl-arm64@0.35.5': | |
| 2314 | + optionalDependencies: | |
| 2315 | + '@img/sharp-libvips-linuxmusl-arm64': 1.3.4 | |
| 2316 | + optional: true | |
| 2317 | + | |
| 2318 | + '@img/sharp-linuxmusl-x64@0.35.5': | |
| 2319 | + optionalDependencies: | |
| 2320 | + '@img/sharp-libvips-linuxmusl-x64': 1.3.4 | |
| 2321 | + optional: true | |
| 2322 | + | |
| 2323 | + '@img/sharp-wasm32@0.35.5': | |
| 2324 | + dependencies: | |
| 2325 | + '@emnapi/runtime': 1.11.3 | |
| 2326 | + optional: true | |
| 2327 | + | |
| 2328 | + '@img/sharp-webcontainers-wasm32@0.35.5': | |
| 2329 | + dependencies: | |
| 2330 | + '@img/sharp-wasm32': 0.35.5 | |
| 2331 | + optional: true | |
| 2332 | + | |
| 2333 | + '@img/sharp-win32-arm64@0.35.5': | |
| 2334 | + optional: true | |
| 2335 | + | |
| 2336 | + '@img/sharp-win32-ia32@0.35.5': | |
| 2337 | + optional: true | |
| 2338 | + | |
| 2339 | + '@img/sharp-win32-x64@0.35.5': | |
| 2340 | + optional: true | |
| 2341 | + | |
| 2342 | + '@jridgewell/gen-mapping@0.3.13': | |
| 2343 | + dependencies: | |
| 2344 | + '@jridgewell/sourcemap-codec': 1.6.0 | |
| 2345 | + '@jridgewell/trace-mapping': 0.3.31 | |
| 2346 | + | |
| 2347 | + '@jridgewell/remapping@2.3.5': | |
| 2348 | + dependencies: | |
| 2349 | + '@jridgewell/gen-mapping': 0.3.13 | |
| 2350 | + '@jridgewell/trace-mapping': 0.3.31 | |
| 2351 | + | |
| 2352 | + '@jridgewell/resolve-uri@3.1.2': {} | |
| 2353 | + | |
| 2354 | + '@jridgewell/sourcemap-codec@1.6.0': {} | |
| 2355 | + | |
| 2356 | + '@jridgewell/trace-mapping@0.3.31': | |
| 2357 | + dependencies: | |
| 2358 | + '@jridgewell/resolve-uri': 3.1.2 | |
| 2359 | + '@jridgewell/sourcemap-codec': 1.6.0 | |
| 2360 | + | |
| 2361 | + '@napi-rs/wasm-runtime@1.2.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': | |
| 2362 | + dependencies: | |
| 2363 | + '@emnapi/core': 1.10.0 | |
| 2364 | + '@emnapi/runtime': 1.10.0 | |
| 2365 | + '@tybys/wasm-util': 0.10.4 | |
| 2366 | + optional: true | |
| 2367 | + | |
| 2368 | + '@next/env@16.4.2': {} | |
| 2369 | + | |
| 2370 | + '@next/eslint-plugin-next@16.4.2(eslint@9.39.5(jiti@2.7.0))': | |
| 2371 | + dependencies: | |
| 2372 | + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.5(jiti@2.7.0)) | |
| 2373 | + fast-glob: 3.3.1 | |
| 2374 | + transitivePeerDependencies: | |
| 2375 | + - eslint | |
| 2376 | + | |
| 2377 | + '@next/swc-darwin-arm64@16.4.2': | |
| 2378 | + optional: true | |
| 2379 | + | |
| 2380 | + '@next/swc-darwin-x64@16.4.2': | |
| 2381 | + optional: true | |
| 2382 | + | |
| 2383 | + '@next/swc-linux-arm64-gnu@16.4.2': | |
| 2384 | + optional: true | |
| 2385 | + | |
| 2386 | + '@next/swc-linux-arm64-musl@16.4.2': | |
| 2387 | + optional: true | |
| 2388 | + | |
| 2389 | + '@next/swc-linux-x64-gnu@16.4.2': | |
| 2390 | + optional: true | |
| 2391 | + | |
| 2392 | + '@next/swc-linux-x64-musl@16.4.2': | |
| 2393 | + optional: true | |
| 2394 | + | |
| 2395 | + '@next/swc-win32-arm64-msvc@16.4.2': | |
| 2396 | + optional: true | |
| 2397 | + | |
| 2398 | + '@next/swc-win32-x64-msvc@16.4.2': | |
| 2399 | + optional: true | |
| 2400 | + | |
| 2401 | + '@nodelib/fs.scandir@2.1.5': | |
| 2402 | + dependencies: | |
| 2403 | + '@nodelib/fs.stat': 2.0.5 | |
| 2404 | + run-parallel: 1.2.0 | |
| 2405 | + | |
| 2406 | + '@nodelib/fs.stat@2.0.5': {} | |
| 2407 | + | |
| 2408 | + '@nodelib/fs.walk@1.2.8': | |
| 2409 | + dependencies: | |
| 2410 | + '@nodelib/fs.scandir': 2.1.5 | |
| 2411 | + fastq: 1.20.3 | |
| 2412 | + | |
| 2413 | + '@nolyfill/is-core-module@1.0.39': {} | |
| 2414 | + | |
| 2415 | + '@rtsao/scc@1.1.0': {} | |
| 2416 | + | |
| 2417 | + '@swc/helpers@0.5.23': | |
| 2418 | + dependencies: | |
| 2419 | + tslib: 2.8.1 | |
| 2420 | + | |
| 2421 | + '@tailwindcss/node@4.3.3': | |
| 2422 | + dependencies: | |
| 2423 | + '@jridgewell/remapping': 2.3.5 | |
| 2424 | + enhanced-resolve: 5.26.0 | |
| 2425 | + jiti: 2.7.0 | |
| 2426 | + lightningcss: 1.32.0 | |
| 2427 | + magic-string: 0.30.21 | |
| 2428 | + source-map-js: 1.2.2 | |
| 2429 | + tailwindcss: 4.3.3 | |
| 2430 | + | |
| 2431 | + '@tailwindcss/oxide-android-arm64@4.3.3': | |
| 2432 | + optional: true | |
| 2433 | + | |
| 2434 | + '@tailwindcss/oxide-darwin-arm64@4.3.3': | |
| 2435 | + optional: true | |
| 2436 | + | |
| 2437 | + '@tailwindcss/oxide-darwin-x64@4.3.3': | |
| 2438 | + optional: true | |
| 2439 | + | |
| 2440 | + '@tailwindcss/oxide-freebsd-x64@4.3.3': | |
| 2441 | + optional: true | |
| 2442 | + | |
| 2443 | + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3': | |
| 2444 | + optional: true | |
| 2445 | + | |
| 2446 | + '@tailwindcss/oxide-linux-arm64-gnu@4.3.3': | |
| 2447 | + optional: true | |
| 2448 | + | |
| 2449 | + '@tailwindcss/oxide-linux-arm64-musl@4.3.3': | |
| 2450 | + optional: true | |
| 2451 | + | |
| 2452 | + '@tailwindcss/oxide-linux-x64-gnu@4.3.3': | |
| 2453 | + optional: true | |
| 2454 | + | |
| 2455 | + '@tailwindcss/oxide-linux-x64-musl@4.3.3': | |
| 2456 | + optional: true | |
| 2457 | + | |
| 2458 | + '@tailwindcss/oxide-wasm32-wasi@4.3.3': | |
| 2459 | + optional: true | |
| 2460 | + | |
| 2461 | + '@tailwindcss/oxide-win32-arm64-msvc@4.3.3': | |
| 2462 | + optional: true | |
| 2463 | + | |
| 2464 | + '@tailwindcss/oxide-win32-x64-msvc@4.3.3': | |
| 2465 | + optional: true | |
| 2466 | + | |
| 2467 | + '@tailwindcss/oxide@4.3.3': | |
| 2468 | + optionalDependencies: | |
| 2469 | + '@tailwindcss/oxide-android-arm64': 4.3.3 | |
| 2470 | + '@tailwindcss/oxide-darwin-arm64': 4.3.3 | |
| 2471 | + '@tailwindcss/oxide-darwin-x64': 4.3.3 | |
| 2472 | + '@tailwindcss/oxide-freebsd-x64': 4.3.3 | |
| 2473 | + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.3.3 | |
| 2474 | + '@tailwindcss/oxide-linux-arm64-gnu': 4.3.3 | |
| 2475 | + '@tailwindcss/oxide-linux-arm64-musl': 4.3.3 | |
| 2476 | + '@tailwindcss/oxide-linux-x64-gnu': 4.3.3 | |
| 2477 | + '@tailwindcss/oxide-linux-x64-musl': 4.3.3 | |
| 2478 | + '@tailwindcss/oxide-wasm32-wasi': 4.3.3 | |
| 2479 | + '@tailwindcss/oxide-win32-arm64-msvc': 4.3.3 | |
| 2480 | + '@tailwindcss/oxide-win32-x64-msvc': 4.3.3 | |
| 2481 | + | |
| 2482 | + '@tailwindcss/turbopack@4.3.3': | |
| 2483 | + dependencies: | |
| 2484 | + '@alloc/quick-lru': 5.3.0 | |
| 2485 | + '@tailwindcss/node': 4.3.3 | |
| 2486 | + '@tailwindcss/oxide': 4.3.3 | |
| 2487 | + tailwindcss: 4.3.3 | |
| 2488 | + | |
| 2489 | + '@tanstack/query-core@5.104.5': {} | |
| 2490 | + | |
| 2491 | + '@tanstack/react-query@5.104.5(@types/react@19.3.0)(react@19.3.0)': | |
| 2492 | + dependencies: | |
| 2493 | + '@tanstack/query-core': 5.104.5 | |
| 2494 | + react: 19.3.0 | |
| 2495 | + optionalDependencies: | |
| 2496 | + '@types/react': 19.3.0 | |
| 2497 | + | |
| 2498 | + '@tybys/wasm-util@0.10.4': | |
| 2499 | + dependencies: | |
| 2500 | + tslib: 2.8.1 | |
| 2501 | + optional: true | |
| 2502 | + | |
| 2503 | + '@types/estree@1.0.9': {} | |
| 2504 | + | |
| 2505 | + '@types/json-schema@7.0.15': {} | |
| 2506 | + | |
| 2507 | + '@types/json5@0.0.29': {} | |
| 2508 | + | |
| 2509 | + '@types/node@20.19.43': | |
| 2510 | + dependencies: | |
| 2511 | + undici-types: 6.21.0 | |
| 2512 | + | |
| 2513 | + '@types/react-dom@19.3.0(@types/react@19.3.0)': | |
| 2514 | + dependencies: | |
| 2515 | + '@types/react': 19.3.0 | |
| 2516 | + | |
| 2517 | + '@types/react@19.3.0': | |
| 2518 | + dependencies: | |
| 2519 | + csstype: 3.2.3 | |
| 2520 | + | |
| 2521 | + '@typescript-eslint/eslint-plugin@8.72.0(@typescript-eslint/parser@8.72.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3)': | |
| 2522 | + dependencies: | |
| 2523 | + '@eslint-community/regexpp': 4.12.2 | |
| 2524 | + '@typescript-eslint/parser': 8.72.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) | |
| 2525 | + '@typescript-eslint/scope-manager': 8.72.0 | |
| 2526 | + '@typescript-eslint/type-utils': 8.72.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) | |
| 2527 | + '@typescript-eslint/utils': 8.72.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) | |
| 2528 | + '@typescript-eslint/visitor-keys': 8.72.0 | |
| 2529 | + eslint: 9.39.5(jiti@2.7.0) | |
| 2530 | + ignore: 7.0.13 | |
| 2531 | + natural-compare: 1.4.0 | |
| 2532 | + ts-api-utils: 2.5.0(typescript@5.9.3) | |
| 2533 | + typescript: 5.9.3 | |
| 2534 | + transitivePeerDependencies: | |
| 2535 | + - supports-color | |
| 2536 | + | |
| 2537 | + '@typescript-eslint/parser@8.72.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3)': | |
| 2538 | + dependencies: | |
| 2539 | + '@typescript-eslint/scope-manager': 8.72.0 | |
| 2540 | + '@typescript-eslint/types': 8.72.0 | |
| 2541 | + '@typescript-eslint/typescript-estree': 8.72.0(typescript@5.9.3) | |
| 2542 | + '@typescript-eslint/visitor-keys': 8.72.0 | |
| 2543 | + debug: 4.4.3 | |
| 2544 | + eslint: 9.39.5(jiti@2.7.0) | |
| 2545 | + typescript: 5.9.3 | |
| 2546 | + transitivePeerDependencies: | |
| 2547 | + - supports-color | |
| 2548 | + | |
| 2549 | + '@typescript-eslint/project-service@8.72.0(typescript@5.9.3)': | |
| 2550 | + dependencies: | |
| 2551 | + '@typescript-eslint/tsconfig-utils': 8.72.0(typescript@5.9.3) | |
| 2552 | + '@typescript-eslint/types': 8.72.0 | |
| 2553 | + debug: 4.4.3 | |
| 2554 | + typescript: 5.9.3 | |
| 2555 | + transitivePeerDependencies: | |
| 2556 | + - supports-color | |
| 2557 | + | |
| 2558 | + '@typescript-eslint/scope-manager@8.72.0': | |
| 2559 | + dependencies: | |
| 2560 | + '@typescript-eslint/types': 8.72.0 | |
| 2561 | + '@typescript-eslint/visitor-keys': 8.72.0 | |
| 2562 | + | |
| 2563 | + '@typescript-eslint/tsconfig-utils@8.72.0(typescript@5.9.3)': | |
| 2564 | + dependencies: | |
| 2565 | + typescript: 5.9.3 | |
| 2566 | + | |
| 2567 | + '@typescript-eslint/type-utils@8.72.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3)': | |
| 2568 | + dependencies: | |
| 2569 | + '@typescript-eslint/types': 8.72.0 | |
| 2570 | + '@typescript-eslint/typescript-estree': 8.72.0(typescript@5.9.3) | |
| 2571 | + '@typescript-eslint/utils': 8.72.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) | |
| 2572 | + debug: 4.4.3 | |
| 2573 | + eslint: 9.39.5(jiti@2.7.0) | |
| 2574 | + ts-api-utils: 2.5.0(typescript@5.9.3) | |
| 2575 | + typescript: 5.9.3 | |
| 2576 | + transitivePeerDependencies: | |
| 2577 | + - supports-color | |
| 2578 | + | |
| 2579 | + '@typescript-eslint/types@8.72.0': {} | |
| 2580 | + | |
| 2581 | + '@typescript-eslint/typescript-estree@8.72.0(typescript@5.9.3)': | |
| 2582 | + dependencies: | |
| 2583 | + '@typescript-eslint/project-service': 8.72.0(typescript@5.9.3) | |
| 2584 | + '@typescript-eslint/tsconfig-utils': 8.72.0(typescript@5.9.3) | |
| 2585 | + '@typescript-eslint/types': 8.72.0 | |
| 2586 | + '@typescript-eslint/visitor-keys': 8.72.0 | |
| 2587 | + debug: 4.4.3 | |
| 2588 | + minimatch: 10.2.6 | |
| 2589 | + semver: 7.8.5 | |
| 2590 | + tinyglobby: 0.2.17 | |
| 2591 | + ts-api-utils: 2.5.0(typescript@5.9.3) | |
| 2592 | + typescript: 5.9.3 | |
| 2593 | + transitivePeerDependencies: | |
| 2594 | + - supports-color | |
| 2595 | + | |
| 2596 | + '@typescript-eslint/utils@8.72.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3)': | |
| 2597 | + dependencies: | |
| 2598 | + '@eslint-community/eslint-utils': 4.10.1(eslint@9.39.5(jiti@2.7.0)) | |
| 2599 | + '@typescript-eslint/scope-manager': 8.72.0 | |
| 2600 | + '@typescript-eslint/types': 8.72.0 | |
| 2601 | + '@typescript-eslint/typescript-estree': 8.72.0(typescript@5.9.3) | |
| 2602 | + eslint: 9.39.5(jiti@2.7.0) | |
| 2603 | + typescript: 5.9.3 | |
| 2604 | + transitivePeerDependencies: | |
| 2605 | + - supports-color | |
| 2606 | + | |
| 2607 | + '@typescript-eslint/visitor-keys@8.72.0': | |
| 2608 | + dependencies: | |
| 2609 | + '@typescript-eslint/types': 8.72.0 | |
| 2610 | + eslint-visitor-keys: 5.0.1 | |
| 2611 | + | |
| 2612 | + '@unrs/resolver-binding-android-arm-eabi@1.12.2': | |
| 2613 | + optional: true | |
| 2614 | + | |
| 2615 | + '@unrs/resolver-binding-android-arm64@1.12.2': | |
| 2616 | + optional: true | |
| 2617 | + | |
| 2618 | + '@unrs/resolver-binding-darwin-arm64@1.12.2': | |
| 2619 | + optional: true | |
| 2620 | + | |
| 2621 | + '@unrs/resolver-binding-darwin-x64@1.12.2': | |
| 2622 | + optional: true | |
| 2623 | + | |
| 2624 | + '@unrs/resolver-binding-freebsd-x64@1.12.2': | |
| 2625 | + optional: true | |
| 2626 | + | |
| 2627 | + '@unrs/resolver-binding-linux-arm-gnueabihf@1.12.2': | |
| 2628 | + optional: true | |
| 2629 | + | |
| 2630 | + '@unrs/resolver-binding-linux-arm-musleabihf@1.12.2': | |
| 2631 | + optional: true | |
| 2632 | + | |
| 2633 | + '@unrs/resolver-binding-linux-arm64-gnu@1.12.2': | |
| 2634 | + optional: true | |
| 2635 | + | |
| 2636 | + '@unrs/resolver-binding-linux-arm64-musl@1.12.2': | |
| 2637 | + optional: true | |
| 2638 | + | |
| 2639 | + '@unrs/resolver-binding-linux-loong64-gnu@1.12.2': | |
| 2640 | + optional: true | |
| 2641 | + | |
| 2642 | + '@unrs/resolver-binding-linux-loong64-musl@1.12.2': | |
| 2643 | + optional: true | |
| 2644 | + | |
| 2645 | + '@unrs/resolver-binding-linux-ppc64-gnu@1.12.2': | |
| 2646 | + optional: true | |
| 2647 | + | |
| 2648 | + '@unrs/resolver-binding-linux-riscv64-gnu@1.12.2': | |
| 2649 | + optional: true | |
| 2650 | + | |
| 2651 | + '@unrs/resolver-binding-linux-riscv64-musl@1.12.2': | |
| 2652 | + optional: true | |
| 2653 | + | |
| 2654 | + '@unrs/resolver-binding-linux-s390x-gnu@1.12.2': | |
| 2655 | + optional: true | |
| 2656 | + | |
| 2657 | + '@unrs/resolver-binding-linux-x64-gnu@1.12.2': | |
| 2658 | + optional: true | |
| 2659 | + | |
| 2660 | + '@unrs/resolver-binding-linux-x64-musl@1.12.2': | |
| 2661 | + optional: true | |
| 2662 | + | |
| 2663 | + '@unrs/resolver-binding-openharmony-arm64@1.12.2': | |
| 2664 | + optional: true | |
| 2665 | + | |
| 2666 | + '@unrs/resolver-binding-wasm32-wasi@1.12.2': | |
| 2667 | + dependencies: | |
| 2668 | + '@emnapi/core': 1.10.0 | |
| 2669 | + '@emnapi/runtime': 1.10.0 | |
| 2670 | + '@napi-rs/wasm-runtime': 1.2.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) | |
| 2671 | + optional: true | |
| 2672 | + | |
| 2673 | + '@unrs/resolver-binding-win32-arm64-msvc@1.12.2': | |
| 2674 | + optional: true | |
| 2675 | + | |
| 2676 | + '@unrs/resolver-binding-win32-ia32-msvc@1.12.2': | |
| 2677 | + optional: true | |
| 2678 | + | |
| 2679 | + '@unrs/resolver-binding-win32-x64-msvc@1.12.2': | |
| 2680 | + optional: true | |
| 2681 | + | |
| 2682 | + acorn-jsx@5.3.2(acorn@8.19.0): | |
| 2683 | + dependencies: | |
| 2684 | + acorn: 8.19.0 | |
| 2685 | + | |
| 2686 | + acorn@8.19.0: {} | |
| 2687 | + | |
| 2688 | + ajv@6.15.0: | |
| 2689 | + dependencies: | |
| 2690 | + fast-deep-equal: 3.1.3 | |
| 2691 | + fast-json-stable-stringify: 2.1.0 | |
| 2692 | + json-schema-traverse: 0.4.1 | |
| 2693 | + uri-js: 4.4.1 | |
| 2694 | + | |
| 2695 | + ansi-styles@4.3.0: | |
| 2696 | + dependencies: | |
| 2697 | + color-convert: 2.0.1 | |
| 2698 | + | |
| 2699 | + argparse@2.0.1: {} | |
| 2700 | + | |
| 2701 | + aria-query@5.3.2: {} | |
| 2702 | + | |
| 2703 | + array-buffer-byte-length@1.0.2: | |
| 2704 | + dependencies: | |
| 2705 | + call-bound: 1.0.4 | |
| 2706 | + is-array-buffer: 3.0.5 | |
| 2707 | + | |
| 2708 | + array-includes@3.2.0: | |
| 2709 | + dependencies: | |
| 2710 | + call-bind: 1.0.9 | |
| 2711 | + call-bound: 1.0.4 | |
| 2712 | + define-properties: 1.2.1 | |
| 2713 | + es-abstract: 1.24.2 | |
| 2714 | + es-object-atoms: 1.1.2 | |
| 2715 | + es-shim-unscopables: 1.1.0 | |
| 2716 | + is-string: 1.1.1 | |
| 2717 | + math-intrinsics: 1.1.0 | |
| 2718 | + | |
| 2719 | + array.prototype.findlast@1.2.5: | |
| 2720 | + dependencies: | |
| 2721 | + call-bind: 1.0.9 | |
| 2722 | + define-properties: 1.2.1 | |
| 2723 | + es-abstract: 1.24.2 | |
| 2724 | + es-errors: 1.3.0 | |
| 2725 | + es-object-atoms: 1.1.2 | |
| 2726 | + es-shim-unscopables: 1.1.0 | |
| 2727 | + | |
| 2728 | + array.prototype.findlastindex@1.2.6: | |
| 2729 | + dependencies: | |
| 2730 | + call-bind: 1.0.9 | |
| 2731 | + call-bound: 1.0.4 | |
| 2732 | + define-properties: 1.2.1 | |
| 2733 | + es-abstract: 1.24.2 | |
| 2734 | + es-errors: 1.3.0 | |
| 2735 | + es-object-atoms: 1.1.2 | |
| 2736 | + es-shim-unscopables: 1.1.0 | |
| 2737 | + | |
| 2738 | + array.prototype.flat@1.3.3: | |
| 2739 | + dependencies: | |
| 2740 | + call-bind: 1.0.9 | |
| 2741 | + define-properties: 1.2.1 | |
| 2742 | + es-abstract: 1.24.2 | |
| 2743 | + es-shim-unscopables: 1.1.0 | |
| 2744 | + | |
| 2745 | + array.prototype.flatmap@1.3.3: | |
| 2746 | + dependencies: | |
| 2747 | + call-bind: 1.0.9 | |
| 2748 | + define-properties: 1.2.1 | |
| 2749 | + es-abstract: 1.24.2 | |
| 2750 | + es-shim-unscopables: 1.1.0 | |
| 2751 | + | |
| 2752 | + array.prototype.tosorted@1.1.4: | |
| 2753 | + dependencies: | |
| 2754 | + call-bind: 1.0.9 | |
| 2755 | + define-properties: 1.2.1 | |
| 2756 | + es-abstract: 1.24.2 | |
| 2757 | + es-errors: 1.3.0 | |
| 2758 | + es-shim-unscopables: 1.1.0 | |
| 2759 | + | |
| 2760 | + arraybuffer.prototype.slice@1.0.4: | |
| 2761 | + dependencies: | |
| 2762 | + array-buffer-byte-length: 1.0.2 | |
| 2763 | + call-bind: 1.0.9 | |
| 2764 | + define-properties: 1.2.1 | |
| 2765 | + es-abstract: 1.24.2 | |
| 2766 | + es-errors: 1.3.0 | |
| 2767 | + get-intrinsic: 1.3.0 | |
| 2768 | + is-array-buffer: 3.0.5 | |
| 2769 | + | |
| 2770 | + ast-types-flow@0.0.8: {} | |
| 2771 | + | |
| 2772 | + async-function@1.0.0: {} | |
| 2773 | + | |
| 2774 | + available-typed-arrays@1.0.7: | |
| 2775 | + dependencies: | |
| 2776 | + possible-typed-array-names: 1.1.0 | |
| 2777 | + | |
| 2778 | + axe-core@4.14.0: {} | |
| 2779 | + | |
| 2780 | + axobject-query@4.1.0: {} | |
| 2781 | + | |
| 2782 | + balanced-match@1.0.2: {} | |
| 2783 | + | |
| 2784 | + balanced-match@4.0.4: {} | |
| 2785 | + | |
| 2786 | + baseline-browser-mapping@2.11.30: {} | |
| 2787 | + | |
| 2788 | + brace-expansion@1.1.21: | |
| 2789 | + dependencies: | |
| 2790 | + balanced-match: 1.0.2 | |
| 2791 | + concat-map: 0.0.1 | |
| 2792 | + | |
| 2793 | + brace-expansion@5.0.12: | |
| 2794 | + dependencies: | |
| 2795 | + balanced-match: 4.0.4 | |
| 2796 | + | |
| 2797 | + braces@3.0.3: | |
| 2798 | + dependencies: | |
| 2799 | + fill-range: 7.1.1 | |
| 2800 | + | |
| 2801 | + browserslist@4.29.4: | |
| 2802 | + dependencies: | |
| 2803 | + baseline-browser-mapping: 2.11.30 | |
| 2804 | + caniuse-lite: 1.0.30001817 | |
| 2805 | + electron-to-chromium: 1.5.462 | |
| 2806 | + node-releases: 2.0.59 | |
| 2807 | + update-browserslist-db: 1.3.4(browserslist@4.29.4) | |
| 2808 | + | |
| 2809 | + call-bind-apply-helpers@1.0.2: | |
| 2810 | + dependencies: | |
| 2811 | + es-errors: 1.3.0 | |
| 2812 | + function-bind: 1.1.2 | |
| 2813 | + | |
| 2814 | + call-bind@1.0.9: | |
| 2815 | + dependencies: | |
| 2816 | + call-bind-apply-helpers: 1.0.2 | |
| 2817 | + es-define-property: 1.0.1 | |
| 2818 | + get-intrinsic: 1.3.0 | |
| 2819 | + set-function-length: 1.2.2 | |
| 2820 | + | |
| 2821 | + call-bound@1.0.4: | |
| 2822 | + dependencies: | |
| 2823 | + call-bind-apply-helpers: 1.0.2 | |
| 2824 | + get-intrinsic: 1.3.0 | |
| 2825 | + | |
| 2826 | + callsites@3.1.0: {} | |
| 2827 | + | |
| 2828 | + caniuse-lite@1.0.30001817: {} | |
| 2829 | + | |
| 2830 | + chalk@4.1.2: | |
| 2831 | + dependencies: | |
| 2832 | + ansi-styles: 4.3.0 | |
| 2833 | + supports-color: 7.2.0 | |
| 2834 | + | |
| 2835 | + client-only@0.0.1: {} | |
| 2836 | + | |
| 2837 | + color-convert@2.0.1: | |
| 2838 | + dependencies: | |
| 2839 | + color-name: 1.1.4 | |
| 2840 | + | |
| 2841 | + color-name@1.1.4: {} | |
| 2842 | + | |
| 2843 | + concat-map@0.0.1: {} | |
| 2844 | + | |
| 2845 | + convert-source-map@2.0.0: {} | |
| 2846 | + | |
| 2847 | + cross-spawn@7.0.6: | |
| 2848 | + dependencies: | |
| 2849 | + path-key: 3.1.1 | |
| 2850 | + shebang-command: 2.0.0 | |
| 2851 | + which: 2.0.2 | |
| 2852 | + | |
| 2853 | + csstype@3.2.3: {} | |
| 2854 | + | |
| 2855 | + damerau-levenshtein@1.0.8: {} | |
| 2856 | + | |
| 2857 | + data-view-buffer@1.0.2: | |
| 2858 | + dependencies: | |
| 2859 | + call-bound: 1.0.4 | |
| 2860 | + es-errors: 1.3.0 | |
| 2861 | + is-data-view: 1.0.2 | |
| 2862 | + | |
| 2863 | + data-view-byte-length@1.0.2: | |
| 2864 | + dependencies: | |
| 2865 | + call-bound: 1.0.4 | |
| 2866 | + es-errors: 1.3.0 | |
| 2867 | + is-data-view: 1.0.2 | |
| 2868 | + | |
| 2869 | + data-view-byte-offset@1.0.1: | |
| 2870 | + dependencies: | |
| 2871 | + call-bound: 1.0.4 | |
| 2872 | + es-errors: 1.3.0 | |
| 2873 | + is-data-view: 1.0.2 | |
| 2874 | + | |
| 2875 | + debug@3.2.7: | |
| 2876 | + dependencies: | |
| 2877 | + ms: 2.1.3 | |
| 2878 | + | |
| 2879 | + debug@4.4.3: | |
| 2880 | + dependencies: | |
| 2881 | + ms: 2.1.3 | |
| 2882 | + | |
| 2883 | + deep-is@0.1.4: {} | |
| 2884 | + | |
| 2885 | + define-data-property@1.1.4: | |
| 2886 | + dependencies: | |
| 2887 | + es-define-property: 1.0.1 | |
| 2888 | + es-errors: 1.3.0 | |
| 2889 | + gopd: 1.2.0 | |
| 2890 | + | |
| 2891 | + define-properties@1.2.1: | |
| 2892 | + dependencies: | |
| 2893 | + define-data-property: 1.1.4 | |
| 2894 | + has-property-descriptors: 1.0.2 | |
| 2895 | + object-keys: 1.1.1 | |
| 2896 | + | |
| 2897 | + detect-libc@2.1.2: {} | |
| 2898 | + | |
| 2899 | + doctrine@2.1.0: | |
| 2900 | + dependencies: | |
| 2901 | + esutils: 2.0.3 | |
| 2902 | + | |
| 2903 | + dunder-proto@1.0.1: | |
| 2904 | + dependencies: | |
| 2905 | + call-bind-apply-helpers: 1.0.2 | |
| 2906 | + es-errors: 1.3.0 | |
| 2907 | + gopd: 1.2.0 | |
| 2908 | + | |
| 2909 | + electron-to-chromium@1.5.462: {} | |
| 2910 | + | |
| 2911 | + emoji-regex@9.2.2: {} | |
| 2912 | + | |
| 2913 | + enhanced-resolve@5.26.0: | |
| 2914 | + dependencies: | |
| 2915 | + graceful-fs: 4.2.11 | |
| 2916 | + tapable: 2.3.3 | |
| 2917 | + | |
| 2918 | + es-abstract-get@1.0.0: | |
| 2919 | + dependencies: | |
| 2920 | + es-errors: 1.3.0 | |
| 2921 | + es-object-atoms: 1.1.2 | |
| 2922 | + is-callable: 1.2.7 | |
| 2923 | + object-inspect: 1.13.4 | |
| 2924 | + | |
| 2925 | + es-abstract@1.24.2: | |
| 2926 | + dependencies: | |
| 2927 | + array-buffer-byte-length: 1.0.2 | |
| 2928 | + arraybuffer.prototype.slice: 1.0.4 | |
| 2929 | + available-typed-arrays: 1.0.7 | |
| 2930 | + call-bind: 1.0.9 | |
| 2931 | + call-bound: 1.0.4 | |
| 2932 | + data-view-buffer: 1.0.2 | |
| 2933 | + data-view-byte-length: 1.0.2 | |
| 2934 | + data-view-byte-offset: 1.0.1 | |
| 2935 | + es-define-property: 1.0.1 | |
| 2936 | + es-errors: 1.3.0 | |
| 2937 | + es-object-atoms: 1.1.2 | |
| 2938 | + es-set-tostringtag: 2.1.0 | |
| 2939 | + es-to-primitive: 1.3.4 | |
| 2940 | + function.prototype.name: 1.2.0 | |
| 2941 | + get-intrinsic: 1.3.0 | |
| 2942 | + get-proto: 1.0.1 | |
| 2943 | + get-symbol-description: 1.1.0 | |
| 2944 | + globalthis: 1.0.4 | |
| 2945 | + gopd: 1.2.0 | |
| 2946 | + has-property-descriptors: 1.0.2 | |
| 2947 | + has-proto: 1.2.0 | |
| 2948 | + has-symbols: 1.1.0 | |
| 2949 | + hasown: 2.0.4 | |
| 2950 | + internal-slot: 1.1.0 | |
| 2951 | + is-array-buffer: 3.0.5 | |
| 2952 | + is-callable: 1.2.7 | |
| 2953 | + is-data-view: 1.0.2 | |
| 2954 | + is-negative-zero: 2.0.3 | |
| 2955 | + is-regex: 1.2.1 | |
| 2956 | + is-set: 2.0.3 | |
| 2957 | + is-shared-array-buffer: 1.0.4 | |
| 2958 | + is-string: 1.1.1 | |
| 2959 | + is-typed-array: 1.1.15 | |
| 2960 | + is-weakref: 1.1.1 | |
| 2961 | + math-intrinsics: 1.1.0 | |
| 2962 | + object-inspect: 1.13.4 | |
| 2963 | + object-keys: 1.1.1 | |
| 2964 | + object.assign: 4.1.7 | |
| 2965 | + own-keys: 1.0.2 | |
| 2966 | + regexp.prototype.flags: 1.5.4 | |
| 2967 | + safe-array-concat: 1.1.4 | |
| 2968 | + safe-push-apply: 1.0.0 | |
| 2969 | + safe-regex-test: 1.1.0 | |
| 2970 | + set-proto: 1.0.0 | |
| 2971 | + stop-iteration-iterator: 1.1.0 | |
| 2972 | + string.prototype.trim: 1.2.11 | |
| 2973 | + string.prototype.trimend: 1.0.10 | |
| 2974 | + string.prototype.trimstart: 1.0.8 | |
| 2975 | + typed-array-buffer: 1.0.3 | |
| 2976 | + typed-array-byte-length: 1.0.3 | |
| 2977 | + typed-array-byte-offset: 1.0.5 | |
| 2978 | + typed-array-length: 1.0.8 | |
| 2979 | + unbox-primitive: 1.1.0 | |
| 2980 | + which-typed-array: 1.1.24 | |
| 2981 | + | |
| 2982 | + es-define-property@1.0.1: {} | |
| 2983 | + | |
| 2984 | + es-errors@1.3.0: {} | |
| 2985 | + | |
| 2986 | + es-iterator-helpers@1.4.0: | |
| 2987 | + dependencies: | |
| 2988 | + call-bind: 1.0.9 | |
| 2989 | + call-bound: 1.0.4 | |
| 2990 | + define-properties: 1.2.1 | |
| 2991 | + es-abstract: 1.24.2 | |
| 2992 | + es-errors: 1.3.0 | |
| 2993 | + es-set-tostringtag: 2.1.0 | |
| 2994 | + function-bind: 1.1.2 | |
| 2995 | + get-intrinsic: 1.3.0 | |
| 2996 | + globalthis: 1.0.4 | |
| 2997 | + gopd: 1.2.0 | |
| 2998 | + has-property-descriptors: 1.0.2 | |
| 2999 | + has-proto: 1.2.0 | |
| 3000 | + has-symbols: 1.1.0 | |
| 3001 | + internal-slot: 1.1.0 | |
| 3002 | + iterator.prototype: 1.1.5 | |
| 3003 | + math-intrinsics: 1.1.0 | |
| 3004 | + | |
| 3005 | + es-object-atoms@1.1.2: | |
| 3006 | + dependencies: | |
| 3007 | + es-errors: 1.3.0 | |
| 3008 | + | |
| 3009 | + es-set-tostringtag@2.1.0: | |
| 3010 | + dependencies: | |
| 3011 | + es-errors: 1.3.0 | |
| 3012 | + get-intrinsic: 1.3.0 | |
| 3013 | + has-tostringtag: 1.0.2 | |
| 3014 | + hasown: 2.0.4 | |
| 3015 | + | |
| 3016 | + es-shim-unscopables@1.1.0: | |
| 3017 | + dependencies: | |
| 3018 | + hasown: 2.0.4 | |
| 3019 | + | |
| 3020 | + es-to-primitive@1.3.4: | |
| 3021 | + dependencies: | |
| 3022 | + es-abstract-get: 1.0.0 | |
| 3023 | + es-define-property: 1.0.1 | |
| 3024 | + es-errors: 1.3.0 | |
| 3025 | + is-callable: 1.2.7 | |
| 3026 | + is-date-object: 1.1.0 | |
| 3027 | + is-symbol: 1.1.1 | |
| 3028 | + | |
| 3029 | + escalade@3.2.0: {} | |
| 3030 | + | |
| 3031 | + escape-string-regexp@4.0.0: {} | |
| 3032 | + | |
| 3033 | + eslint-config-next@16.4.2(@typescript-eslint/parser@8.72.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3): | |
| 3034 | + dependencies: | |
| 3035 | + '@next/eslint-plugin-next': 16.4.2(eslint@9.39.5(jiti@2.7.0)) | |
| 3036 | + eslint: 9.39.5(jiti@2.7.0) | |
| 3037 | + eslint-import-resolver-node: 0.3.10 | |
| 3038 | + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.5(jiti@2.7.0)) | |
| 3039 | + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.72.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0)) | |
| 3040 | + eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.5(jiti@2.7.0)) | |
| 3041 | + eslint-plugin-react: 7.37.5(eslint@9.39.5(jiti@2.7.0)) | |
| 3042 | + eslint-plugin-react-hooks: 7.1.1(eslint@9.39.5(jiti@2.7.0)) | |
| 3043 | + globals: 16.4.0 | |
| 3044 | + typescript-eslint: 8.72.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) | |
| 3045 | + optionalDependencies: | |
| 3046 | + typescript: 5.9.3 | |
| 3047 | + transitivePeerDependencies: | |
| 3048 | + - '@typescript-eslint/parser' | |
| 3049 | + - eslint-import-resolver-webpack | |
| 3050 | + - eslint-plugin-import-x | |
| 3051 | + - supports-color | |
| 3052 | + | |
| 3053 | + eslint-import-resolver-node@0.3.10: | |
| 3054 | + dependencies: | |
| 3055 | + debug: 3.2.7 | |
| 3056 | + is-core-module: 2.17.0 | |
| 3057 | + resolve: 2.0.0-next.7 | |
| 3058 | + transitivePeerDependencies: | |
| 3059 | + - supports-color | |
| 3060 | + | |
| 3061 | + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.5(jiti@2.7.0)): | |
| 3062 | + dependencies: | |
| 3063 | + '@nolyfill/is-core-module': 1.0.39 | |
| 3064 | + debug: 4.4.3 | |
| 3065 | + eslint: 9.39.5(jiti@2.7.0) | |
| 3066 | + get-tsconfig: 4.14.3 | |
| 3067 | + is-bun-module: 2.0.0 | |
| 3068 | + stable-hash: 0.0.5 | |
| 3069 | + tinyglobby: 0.2.17 | |
| 3070 | + unrs-resolver: 1.12.2 | |
| 3071 | + optionalDependencies: | |
| 3072 | + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.72.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0)) | |
| 3073 | + transitivePeerDependencies: | |
| 3074 | + - supports-color | |
| 3075 | + | |
| 3076 | + eslint-module-utils@2.14.0(@typescript-eslint/parser@8.72.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0)): | |
| 3077 | + dependencies: | |
| 3078 | + debug: 3.2.7 | |
| 3079 | + optionalDependencies: | |
| 3080 | + '@typescript-eslint/parser': 8.72.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) | |
| 3081 | + eslint: 9.39.5(jiti@2.7.0) | |
| 3082 | + eslint-import-resolver-node: 0.3.10 | |
| 3083 | + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.5(jiti@2.7.0)) | |
| 3084 | + transitivePeerDependencies: | |
| 3085 | + - supports-color | |
| 3086 | + | |
| 3087 | + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.72.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0)): | |
| 3088 | + dependencies: | |
| 3089 | + '@rtsao/scc': 1.1.0 | |
| 3090 | + array-includes: 3.2.0 | |
| 3091 | + array.prototype.findlastindex: 1.2.6 | |
| 3092 | + array.prototype.flat: 1.3.3 | |
| 3093 | + array.prototype.flatmap: 1.3.3 | |
| 3094 | + debug: 3.2.7 | |
| 3095 | + doctrine: 2.1.0 | |
| 3096 | + eslint: 9.39.5(jiti@2.7.0) | |
| 3097 | + eslint-import-resolver-node: 0.3.10 | |
| 3098 | + eslint-module-utils: 2.14.0(@typescript-eslint/parser@8.72.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0)) | |
| 3099 | + hasown: 2.0.4 | |
| 3100 | + is-core-module: 2.17.0 | |
| 3101 | + is-glob: 4.0.3 | |
| 3102 | + minimatch: 3.1.5 | |
| 3103 | + object.fromentries: 2.0.8 | |
| 3104 | + object.groupby: 1.0.3 | |
| 3105 | + object.values: 1.2.1 | |
| 3106 | + semver: 6.3.1 | |
| 3107 | + string.prototype.trimend: 1.0.10 | |
| 3108 | + tsconfig-paths: 3.15.0 | |
| 3109 | + optionalDependencies: | |
| 3110 | + '@typescript-eslint/parser': 8.72.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) | |
| 3111 | + transitivePeerDependencies: | |
| 3112 | + - eslint-import-resolver-typescript | |
| 3113 | + - eslint-import-resolver-webpack | |
| 3114 | + - supports-color | |
| 3115 | + | |
| 3116 | + eslint-plugin-jsx-a11y@6.10.2(eslint@9.39.5(jiti@2.7.0)): | |
| 3117 | + dependencies: | |
| 3118 | + aria-query: 5.3.2 | |
| 3119 | + array-includes: 3.2.0 | |
| 3120 | + array.prototype.flatmap: 1.3.3 | |
| 3121 | + ast-types-flow: 0.0.8 | |
| 3122 | + axe-core: 4.14.0 | |
| 3123 | + axobject-query: 4.1.0 | |
| 3124 | + damerau-levenshtein: 1.0.8 | |
| 3125 | + emoji-regex: 9.2.2 | |
| 3126 | + eslint: 9.39.5(jiti@2.7.0) | |
| 3127 | + hasown: 2.0.4 | |
| 3128 | + jsx-ast-utils: 3.3.5 | |
| 3129 | + language-tags: 1.0.9 | |
| 3130 | + minimatch: 3.1.5 | |
| 3131 | + object.fromentries: 2.0.8 | |
| 3132 | + safe-regex-test: 1.1.0 | |
| 3133 | + string.prototype.includes: 2.0.1 | |
| 3134 | + | |
| 3135 | + eslint-plugin-react-hooks@7.1.1(eslint@9.39.5(jiti@2.7.0)): | |
| 3136 | + dependencies: | |
| 3137 | + '@babel/core': 7.29.7 | |
| 3138 | + '@babel/parser': 7.29.9 | |
| 3139 | + eslint: 9.39.5(jiti@2.7.0) | |
| 3140 | + hermes-parser: 0.25.1 | |
| 3141 | + zod: 4.6.7 | |
| 3142 | + zod-validation-error: 4.0.2(zod@4.6.7) | |
| 3143 | + transitivePeerDependencies: | |
| 3144 | + - supports-color | |
| 3145 | + | |
| 3146 | + eslint-plugin-react@7.37.5(eslint@9.39.5(jiti@2.7.0)): | |
| 3147 | + dependencies: | |
| 3148 | + array-includes: 3.2.0 | |
| 3149 | + array.prototype.findlast: 1.2.5 | |
| 3150 | + array.prototype.flatmap: 1.3.3 | |
| 3151 | + array.prototype.tosorted: 1.1.4 | |
| 3152 | + doctrine: 2.1.0 | |
| 3153 | + es-iterator-helpers: 1.4.0 | |
| 3154 | + eslint: 9.39.5(jiti@2.7.0) | |
| 3155 | + estraverse: 5.3.0 | |
| 3156 | + hasown: 2.0.4 | |
| 3157 | + jsx-ast-utils: 3.3.5 | |
| 3158 | + minimatch: 3.1.5 | |
| 3159 | + object.entries: 1.1.9 | |
| 3160 | + object.fromentries: 2.0.8 | |
| 3161 | + object.values: 1.2.1 | |
| 3162 | + prop-types: 15.8.1 | |
| 3163 | + resolve: 2.0.0-next.7 | |
| 3164 | + semver: 6.3.1 | |
| 3165 | + string.prototype.matchall: 4.1.0 | |
| 3166 | + string.prototype.repeat: 1.0.0 | |
| 3167 | + | |
| 3168 | + eslint-scope@8.4.0: | |
| 3169 | + dependencies: | |
| 3170 | + esrecurse: 4.3.0 | |
| 3171 | + estraverse: 5.3.0 | |
| 3172 | + | |
| 3173 | + eslint-visitor-keys@3.4.3: {} | |
| 3174 | + | |
| 3175 | + eslint-visitor-keys@4.2.1: {} | |
| 3176 | + | |
| 3177 | + eslint-visitor-keys@5.0.1: {} | |
| 3178 | + | |
| 3179 | + eslint@9.39.5(jiti@2.7.0): | |
| 3180 | + dependencies: | |
| 3181 | + '@eslint-community/eslint-utils': 4.10.1(eslint@9.39.5(jiti@2.7.0)) | |
| 3182 | + '@eslint-community/regexpp': 4.12.2 | |
| 3183 | + '@eslint/config-array': 0.21.2 | |
| 3184 | + '@eslint/config-helpers': 0.4.2 | |
| 3185 | + '@eslint/core': 0.17.0 | |
| 3186 | + '@eslint/eslintrc': 3.3.7 | |
| 3187 | + '@eslint/js': 9.39.5 | |
| 3188 | + '@eslint/plugin-kit': 0.4.1 | |
| 3189 | + '@humanfs/node': 0.16.8 | |
| 3190 | + '@humanwhocodes/module-importer': 1.0.1 | |
| 3191 | + '@humanwhocodes/retry': 0.4.3 | |
| 3192 | + '@types/estree': 1.0.9 | |
| 3193 | + ajv: 6.15.0 | |
| 3194 | + chalk: 4.1.2 | |
| 3195 | + cross-spawn: 7.0.6 | |
| 3196 | + debug: 4.4.3 | |
| 3197 | + escape-string-regexp: 4.0.0 | |
| 3198 | + eslint-scope: 8.4.0 | |
| 3199 | + eslint-visitor-keys: 4.2.1 | |
| 3200 | + espree: 10.4.0 | |
| 3201 | + esquery: 1.7.0 | |
| 3202 | + esutils: 2.0.3 | |
| 3203 | + fast-deep-equal: 3.1.3 | |
| 3204 | + file-entry-cache: 8.0.0 | |
| 3205 | + find-up: 5.0.0 | |
| 3206 | + glob-parent: 6.0.2 | |
| 3207 | + ignore: 5.3.2 | |
| 3208 | + imurmurhash: 0.1.4 | |
| 3209 | + is-glob: 4.0.3 | |
| 3210 | + json-stable-stringify-without-jsonify: 1.0.1 | |
| 3211 | + lodash.merge: 4.6.2 | |
| 3212 | + minimatch: 3.1.5 | |
| 3213 | + natural-compare: 1.4.0 | |
| 3214 | + optionator: 0.9.4 | |
| 3215 | + optionalDependencies: | |
| 3216 | + jiti: 2.7.0 | |
| 3217 | + transitivePeerDependencies: | |
| 3218 | + - supports-color | |
| 3219 | + | |
| 3220 | + espree@10.4.0: | |
| 3221 | + dependencies: | |
| 3222 | + acorn: 8.19.0 | |
| 3223 | + acorn-jsx: 5.3.2(acorn@8.19.0) | |
| 3224 | + eslint-visitor-keys: 4.2.1 | |
| 3225 | + | |
| 3226 | + esquery@1.7.0: | |
| 3227 | + dependencies: | |
| 3228 | + estraverse: 5.3.0 | |
| 3229 | + | |
| 3230 | + esrecurse@4.3.0: | |
| 3231 | + dependencies: | |
| 3232 | + estraverse: 5.3.0 | |
| 3233 | + | |
| 3234 | + estraverse@5.3.0: {} | |
| 3235 | + | |
| 3236 | + esutils@2.0.3: {} | |
| 3237 | + | |
| 3238 | + fancy-canvas@2.1.0: {} | |
| 3239 | + | |
| 3240 | + fast-deep-equal@3.1.3: {} | |
| 3241 | + | |
| 3242 | + fast-glob@3.3.1: | |
| 3243 | + dependencies: | |
| 3244 | + '@nodelib/fs.stat': 2.0.5 | |
| 3245 | + '@nodelib/fs.walk': 1.2.8 | |
| 3246 | + glob-parent: 5.1.2 | |
| 3247 | + merge2: 1.4.1 | |
| 3248 | + micromatch: 4.0.8 | |
| 3249 | + | |
| 3250 | + fast-json-stable-stringify@2.1.0: {} | |
| 3251 | + | |
| 3252 | + fast-levenshtein@2.0.6: {} | |
| 3253 | + | |
| 3254 | + fastq@1.20.3: | |
| 3255 | + dependencies: | |
| 3256 | + reusify: 1.1.0 | |
| 3257 | + | |
| 3258 | + fdir@6.5.0(picomatch@4.0.7): | |
| 3259 | + optionalDependencies: | |
| 3260 | + picomatch: 4.0.7 | |
| 3261 | + | |
| 3262 | + file-entry-cache@8.0.0: | |
| 3263 | + dependencies: | |
| 3264 | + flat-cache: 4.0.1 | |
| 3265 | + | |
| 3266 | + fill-range@7.1.1: | |
| 3267 | + dependencies: | |
| 3268 | + to-regex-range: 5.0.1 | |
| 3269 | + | |
| 3270 | + find-up@5.0.0: | |
| 3271 | + dependencies: | |
| 3272 | + locate-path: 6.0.0 | |
| 3273 | + path-exists: 4.0.0 | |
| 3274 | + | |
| 3275 | + flat-cache@4.0.1: | |
| 3276 | + dependencies: | |
| 3277 | + flatted: 3.4.4 | |
| 3278 | + keyv: 4.5.4 | |
| 3279 | + | |
| 3280 | + flatted@3.4.4: {} | |
| 3281 | + | |
| 3282 | + for-each@0.3.5: | |
| 3283 | + dependencies: | |
| 3284 | + is-callable: 1.2.7 | |
| 3285 | + | |
| 3286 | + function-bind@1.1.2: {} | |
| 3287 | + | |
| 3288 | + function.prototype.name@1.2.0: | |
| 3289 | + dependencies: | |
| 3290 | + call-bind: 1.0.9 | |
| 3291 | + call-bound: 1.0.4 | |
| 3292 | + es-define-property: 1.0.1 | |
| 3293 | + es-errors: 1.3.0 | |
| 3294 | + functions-have-names: 1.2.3 | |
| 3295 | + has-property-descriptors: 1.0.2 | |
| 3296 | + hasown: 2.0.4 | |
| 3297 | + is-callable: 1.2.7 | |
| 3298 | + is-document.all: 1.0.0 | |
| 3299 | + | |
| 3300 | + functions-have-names@1.2.3: {} | |
| 3301 | + | |
| 3302 | + generator-function@2.0.1: {} | |
| 3303 | + | |
| 3304 | + gensync@1.0.0-beta.2: {} | |
| 3305 | + | |
| 3306 | + get-intrinsic@1.3.0: | |
| 3307 | + dependencies: | |
| 3308 | + call-bind-apply-helpers: 1.0.2 | |
| 3309 | + es-define-property: 1.0.1 | |
| 3310 | + es-errors: 1.3.0 | |
| 3311 | + es-object-atoms: 1.1.2 | |
| 3312 | + function-bind: 1.1.2 | |
| 3313 | + get-proto: 1.0.1 | |
| 3314 | + gopd: 1.2.0 | |
| 3315 | + has-symbols: 1.1.0 | |
| 3316 | + hasown: 2.0.4 | |
| 3317 | + math-intrinsics: 1.1.0 | |
| 3318 | + | |
| 3319 | + get-proto@1.0.1: | |
| 3320 | + dependencies: | |
| 3321 | + dunder-proto: 1.0.1 | |
| 3322 | + es-object-atoms: 1.1.2 | |
| 3323 | + | |
| 3324 | + get-symbol-description@1.1.0: | |
| 3325 | + dependencies: | |
| 3326 | + call-bound: 1.0.4 | |
| 3327 | + es-errors: 1.3.0 | |
| 3328 | + get-intrinsic: 1.3.0 | |
| 3329 | + | |
| 3330 | + get-tsconfig@4.14.3: | |
| 3331 | + dependencies: | |
| 3332 | + resolve-pkg-maps: 1.0.0 | |
| 3333 | + | |
| 3334 | + glob-parent@5.1.2: | |
| 3335 | + dependencies: | |
| 3336 | + is-glob: 4.0.3 | |
| 3337 | + | |
| 3338 | + glob-parent@6.0.2: | |
| 3339 | + dependencies: | |
| 3340 | + is-glob: 4.0.3 | |
| 3341 | + | |
| 3342 | + globals@14.0.0: {} | |
| 3343 | + | |
| 3344 | + globals@16.4.0: {} | |
| 3345 | + | |
| 3346 | + globalthis@1.0.4: | |
| 3347 | + dependencies: | |
| 3348 | + define-properties: 1.2.1 | |
| 3349 | + gopd: 1.2.0 | |
| 3350 | + | |
| 3351 | + gopd@1.2.0: {} | |
| 3352 | + | |
| 3353 | + graceful-fs@4.2.11: {} | |
| 3354 | + | |
| 3355 | + has-bigints@1.1.0: {} | |
| 3356 | + | |
| 3357 | + has-flag@4.0.0: {} | |
| 3358 | + | |
| 3359 | + has-property-descriptors@1.0.2: | |
| 3360 | + dependencies: | |
| 3361 | + es-define-property: 1.0.1 | |
| 3362 | + | |
| 3363 | + has-proto@1.2.0: | |
| 3364 | + dependencies: | |
| 3365 | + dunder-proto: 1.0.1 | |
| 3366 | + | |
| 3367 | + has-symbols@1.1.0: {} | |
| 3368 | + | |
| 3369 | + has-tostringtag@1.0.2: | |
| 3370 | + dependencies: | |
| 3371 | + has-symbols: 1.1.0 | |
| 3372 | + | |
| 3373 | + hasown@2.0.4: | |
| 3374 | + dependencies: | |
| 3375 | + function-bind: 1.1.2 | |
| 3376 | + | |
| 3377 | + hermes-estree@0.25.1: {} | |
| 3378 | + | |
| 3379 | + hermes-parser@0.25.1: | |
| 3380 | + dependencies: | |
| 3381 | + hermes-estree: 0.25.1 | |
| 3382 | + | |
| 3383 | + ignore@5.3.2: {} | |
| 3384 | + | |
| 3385 | + ignore@7.0.13: {} | |
| 3386 | + | |
| 3387 | + import-fresh@3.3.1: | |
| 3388 | + dependencies: | |
| 3389 | + parent-module: 1.0.1 | |
| 3390 | + resolve-from: 4.0.0 | |
| 3391 | + | |
| 3392 | + imurmurhash@0.1.4: {} | |
| 3393 | + | |
| 3394 | + internal-slot@1.1.0: | |
| 3395 | + dependencies: | |
| 3396 | + es-errors: 1.3.0 | |
| 3397 | + hasown: 2.0.4 | |
| 3398 | + side-channel: 1.1.1 | |
| 3399 | + | |
| 3400 | + is-array-buffer@3.0.5: | |
| 3401 | + dependencies: | |
| 3402 | + call-bind: 1.0.9 | |
| 3403 | + call-bound: 1.0.4 | |
| 3404 | + get-intrinsic: 1.3.0 | |
| 3405 | + | |
| 3406 | + is-async-function@2.1.1: | |
| 3407 | + dependencies: | |
| 3408 | + async-function: 1.0.0 | |
| 3409 | + call-bound: 1.0.4 | |
| 3410 | + get-proto: 1.0.1 | |
| 3411 | + has-tostringtag: 1.0.2 | |
| 3412 | + safe-regex-test: 1.1.0 | |
| 3413 | + | |
| 3414 | + is-bigint@1.1.0: | |
| 3415 | + dependencies: | |
| 3416 | + has-bigints: 1.1.0 | |
| 3417 | + | |
| 3418 | + is-boolean-object@1.2.2: | |
| 3419 | + dependencies: | |
| 3420 | + call-bound: 1.0.4 | |
| 3421 | + has-tostringtag: 1.0.2 | |
| 3422 | + | |
| 3423 | + is-bun-module@2.0.0: | |
| 3424 | + dependencies: | |
| 3425 | + semver: 7.8.5 | |
| 3426 | + | |
| 3427 | + is-callable@1.2.7: {} | |
| 3428 | + | |
| 3429 | + is-core-module@2.17.0: | |
| 3430 | + dependencies: | |
| 3431 | + hasown: 2.0.4 | |
| 3432 | + | |
| 3433 | + is-data-view@1.0.2: | |
| 3434 | + dependencies: | |
| 3435 | + call-bound: 1.0.4 | |
| 3436 | + get-intrinsic: 1.3.0 | |
| 3437 | + is-typed-array: 1.1.15 | |
| 3438 | + | |
| 3439 | + is-date-object@1.1.0: | |
| 3440 | + dependencies: | |
| 3441 | + call-bound: 1.0.4 | |
| 3442 | + has-tostringtag: 1.0.2 | |
| 3443 | + | |
| 3444 | + is-document.all@1.0.0: | |
| 3445 | + dependencies: | |
| 3446 | + call-bound: 1.0.4 | |
| 3447 | + | |
| 3448 | + is-extglob@2.1.1: {} | |
| 3449 | + | |
| 3450 | + is-finalizationregistry@1.1.1: | |
| 3451 | + dependencies: | |
| 3452 | + call-bound: 1.0.4 | |
| 3453 | + | |
| 3454 | + is-generator-function@1.1.2: | |
| 3455 | + dependencies: | |
| 3456 | + call-bound: 1.0.4 | |
| 3457 | + generator-function: 2.0.1 | |
| 3458 | + get-proto: 1.0.1 | |
| 3459 | + has-tostringtag: 1.0.2 | |
| 3460 | + safe-regex-test: 1.1.0 | |
| 3461 | + | |
| 3462 | + is-glob@4.0.3: | |
| 3463 | + dependencies: | |
| 3464 | + is-extglob: 2.1.1 | |
| 3465 | + | |
| 3466 | + is-map@2.0.3: {} | |
| 3467 | + | |
| 3468 | + is-negative-zero@2.0.3: {} | |
| 3469 | + | |
| 3470 | + is-number-object@1.1.1: | |
| 3471 | + dependencies: | |
| 3472 | + call-bound: 1.0.4 | |
| 3473 | + has-tostringtag: 1.0.2 | |
| 3474 | + | |
| 3475 | + is-number@7.0.0: {} | |
| 3476 | + | |
| 3477 | + is-regex@1.2.1: | |
| 3478 | + dependencies: | |
| 3479 | + call-bound: 1.0.4 | |
| 3480 | + gopd: 1.2.0 | |
| 3481 | + has-tostringtag: 1.0.2 | |
| 3482 | + hasown: 2.0.4 | |
| 3483 | + | |
| 3484 | + is-set@2.0.3: {} | |
| 3485 | + | |
| 3486 | + is-shared-array-buffer@1.0.4: | |
| 3487 | + dependencies: | |
| 3488 | + call-bound: 1.0.4 | |
| 3489 | + | |
| 3490 | + is-string@1.1.1: | |
| 3491 | + dependencies: | |
| 3492 | + call-bound: 1.0.4 | |
| 3493 | + has-tostringtag: 1.0.2 | |
| 3494 | + | |
| 3495 | + is-symbol@1.1.1: | |
| 3496 | + dependencies: | |
| 3497 | + call-bound: 1.0.4 | |
| 3498 | + has-symbols: 1.1.0 | |
| 3499 | + safe-regex-test: 1.1.0 | |
| 3500 | + | |
| 3501 | + is-typed-array@1.1.15: | |
| 3502 | + dependencies: | |
| 3503 | + which-typed-array: 1.1.24 | |
| 3504 | + | |
| 3505 | + is-weakmap@2.0.2: {} | |
| 3506 | + | |
| 3507 | + is-weakref@1.1.1: | |
| 3508 | + dependencies: | |
| 3509 | + call-bound: 1.0.4 | |
| 3510 | + | |
| 3511 | + is-weakset@2.0.4: | |
| 3512 | + dependencies: | |
| 3513 | + call-bound: 1.0.4 | |
| 3514 | + get-intrinsic: 1.3.0 | |
| 3515 | + | |
| 3516 | + isarray@2.0.5: {} | |
| 3517 | + | |
| 3518 | + isexe@2.0.0: {} | |
| 3519 | + | |
| 3520 | + iterator.prototype@1.1.5: | |
| 3521 | + dependencies: | |
| 3522 | + define-data-property: 1.1.4 | |
| 3523 | + es-object-atoms: 1.1.2 | |
| 3524 | + get-intrinsic: 1.3.0 | |
| 3525 | + get-proto: 1.0.1 | |
| 3526 | + has-symbols: 1.1.0 | |
| 3527 | + set-function-name: 2.0.2 | |
| 3528 | + | |
| 3529 | + jiti@2.7.0: {} | |
| 3530 | + | |
| 3531 | + js-tokens@4.0.0: {} | |
| 3532 | + | |
| 3533 | + js-yaml@4.3.2: | |
| 3534 | + dependencies: | |
| 3535 | + argparse: 2.0.1 | |
| 3536 | + | |
| 3537 | + jsesc@3.1.0: {} | |
| 3538 | + | |
| 3539 | + json-buffer@3.0.1: {} | |
| 3540 | + | |
| 3541 | + json-schema-traverse@0.4.1: {} | |
| 3542 | + | |
| 3543 | + json-stable-stringify-without-jsonify@1.0.1: {} | |
| 3544 | + | |
| 3545 | + json5@1.0.2: | |
| 3546 | + dependencies: | |
| 3547 | + minimist: 1.2.8 | |
| 3548 | + | |
| 3549 | + json5@2.2.3: {} | |
| 3550 | + | |
| 3551 | + jsx-ast-utils@3.3.5: | |
| 3552 | + dependencies: | |
| 3553 | + array-includes: 3.2.0 | |
| 3554 | + array.prototype.flat: 1.3.3 | |
| 3555 | + object.assign: 4.1.7 | |
| 3556 | + object.values: 1.2.1 | |
| 3557 | + | |
| 3558 | + keyv@4.5.4: | |
| 3559 | + dependencies: | |
| 3560 | + json-buffer: 3.0.1 | |
| 3561 | + | |
| 3562 | + language-subtag-registry@0.3.23: {} | |
| 3563 | + | |
| 3564 | + language-tags@1.0.9: | |
| 3565 | + dependencies: | |
| 3566 | + language-subtag-registry: 0.3.23 | |
| 3567 | + | |
| 3568 | + levn@0.4.1: | |
| 3569 | + dependencies: | |
| 3570 | + prelude-ls: 1.2.1 | |
| 3571 | + type-check: 0.4.0 | |
| 3572 | + | |
| 3573 | + lightningcss-android-arm64@1.32.0: | |
| 3574 | + optional: true | |
| 3575 | + | |
| 3576 | + lightningcss-darwin-arm64@1.32.0: | |
| 3577 | + optional: true | |
| 3578 | + | |
| 3579 | + lightningcss-darwin-x64@1.32.0: | |
| 3580 | + optional: true | |
| 3581 | + | |
| 3582 | + lightningcss-freebsd-x64@1.32.0: | |
| 3583 | + optional: true | |
| 3584 | + | |
| 3585 | + lightningcss-linux-arm-gnueabihf@1.32.0: | |
| 3586 | + optional: true | |
| 3587 | + | |
| 3588 | + lightningcss-linux-arm64-gnu@1.32.0: | |
| 3589 | + optional: true | |
| 3590 | + | |
| 3591 | + lightningcss-linux-arm64-musl@1.32.0: | |
| 3592 | + optional: true | |
| 3593 | + | |
| 3594 | + lightningcss-linux-x64-gnu@1.32.0: | |
| 3595 | + optional: true | |
| 3596 | + | |
| 3597 | + lightningcss-linux-x64-musl@1.32.0: | |
| 3598 | + optional: true | |
| 3599 | + | |
| 3600 | + lightningcss-win32-arm64-msvc@1.32.0: | |
| 3601 | + optional: true | |
| 3602 | + | |
| 3603 | + lightningcss-win32-x64-msvc@1.32.0: | |
| 3604 | + optional: true | |
| 3605 | + | |
| 3606 | + lightningcss@1.32.0: | |
| 3607 | + dependencies: | |
| 3608 | + detect-libc: 2.1.2 | |
| 3609 | + optionalDependencies: | |
| 3610 | + lightningcss-android-arm64: 1.32.0 | |
| 3611 | + lightningcss-darwin-arm64: 1.32.0 | |
| 3612 | + lightningcss-darwin-x64: 1.32.0 | |
| 3613 | + lightningcss-freebsd-x64: 1.32.0 | |
| 3614 | + lightningcss-linux-arm-gnueabihf: 1.32.0 | |
| 3615 | + lightningcss-linux-arm64-gnu: 1.32.0 | |
| 3616 | + lightningcss-linux-arm64-musl: 1.32.0 | |
| 3617 | + lightningcss-linux-x64-gnu: 1.32.0 | |
| 3618 | + lightningcss-linux-x64-musl: 1.32.0 | |
| 3619 | + lightningcss-win32-arm64-msvc: 1.32.0 | |
| 3620 | + lightningcss-win32-x64-msvc: 1.32.0 | |
| 3621 | + | |
| 3622 | + lightweight-charts@5.2.1: | |
| 3623 | + dependencies: | |
| 3624 | + fancy-canvas: 2.1.0 | |
| 3625 | + | |
| 3626 | + locate-path@6.0.0: | |
| 3627 | + dependencies: | |
| 3628 | + p-locate: 5.0.0 | |
| 3629 | + | |
| 3630 | + lodash.merge@4.6.2: {} | |
| 3631 | + | |
| 3632 | + loose-envify@1.4.0: | |
| 3633 | + dependencies: | |
| 3634 | + js-tokens: 4.0.0 | |
| 3635 | + | |
| 3636 | + lru-cache@5.1.1: | |
| 3637 | + dependencies: | |
| 3638 | + yallist: 3.1.1 | |
| 3639 | + | |
| 3640 | + lucide-react@1.60.0(@types/react@19.3.0)(react@19.3.0): | |
| 3641 | + dependencies: | |
| 3642 | + react: 19.3.0 | |
| 3643 | + optionalDependencies: | |
| 3644 | + '@types/react': 19.3.0 | |
| 3645 | + | |
| 3646 | + magic-string@0.30.21: | |
| 3647 | + dependencies: | |
| 3648 | + '@jridgewell/sourcemap-codec': 1.6.0 | |
| 3649 | + | |
| 3650 | + math-intrinsics@1.1.0: {} | |
| 3651 | + | |
| 3652 | + merge2@1.4.1: {} | |
| 3653 | + | |
| 3654 | + micromatch@4.0.8: | |
| 3655 | + dependencies: | |
| 3656 | + braces: 3.0.3 | |
| 3657 | + picomatch: 2.3.2 | |
| 3658 | + | |
| 3659 | + minimatch@10.2.6: | |
| 3660 | + dependencies: | |
| 3661 | + brace-expansion: 5.0.12 | |
| 3662 | + | |
| 3663 | + minimatch@3.1.5: | |
| 3664 | + dependencies: | |
| 3665 | + brace-expansion: 1.1.21 | |
| 3666 | + | |
| 3667 | + minimist@1.2.8: {} | |
| 3668 | + | |
| 3669 | + ms@2.1.3: {} | |
| 3670 | + | |
| 3671 | + nanoid@3.3.20: {} | |
| 3672 | + | |
| 3673 | + napi-postinstall@0.3.5: {} | |
| 3674 | + | |
| 3675 | + natural-compare@1.4.0: {} | |
| 3676 | + | |
| 3677 | + next@16.4.2(@babel/core@7.29.7)(@types/node@20.19.43)(react-dom@19.3.0(react@19.3.0))(react@19.3.0): | |
| 3678 | + dependencies: | |
| 3679 | + '@next/env': 16.4.2 | |
| 3680 | + '@swc/helpers': 0.5.23 | |
| 3681 | + baseline-browser-mapping: 2.11.30 | |
| 3682 | + caniuse-lite: 1.0.30001817 | |
| 3683 | + postcss: 8.5.23 | |
| 3684 | + react: 19.3.0 | |
| 3685 | + react-dom: 19.3.0(react@19.3.0) | |
| 3686 | + styled-jsx: 5.1.6(@babel/core@7.29.7)(react@19.3.0) | |
| 3687 | + optionalDependencies: | |
| 3688 | + '@next/swc-darwin-arm64': 16.4.2 | |
| 3689 | + '@next/swc-darwin-x64': 16.4.2 | |
| 3690 | + '@next/swc-linux-arm64-gnu': 16.4.2 | |
| 3691 | + '@next/swc-linux-arm64-musl': 16.4.2 | |
| 3692 | + '@next/swc-linux-x64-gnu': 16.4.2 | |
| 3693 | + '@next/swc-linux-x64-musl': 16.4.2 | |
| 3694 | + '@next/swc-win32-arm64-msvc': 16.4.2 | |
| 3695 | + '@next/swc-win32-x64-msvc': 16.4.2 | |
| 3696 | + sharp: 0.35.5(@types/node@20.19.43) | |
| 3697 | + transitivePeerDependencies: | |
| 3698 | + - '@babel/core' | |
| 3699 | + - '@types/node' | |
| 3700 | + - babel-plugin-macros | |
| 3701 | + | |
| 3702 | + node-exports-info@1.6.2: | |
| 3703 | + dependencies: | |
| 3704 | + array.prototype.flatmap: 1.3.3 | |
| 3705 | + es-errors: 1.3.0 | |
| 3706 | + object.entries: 1.1.9 | |
| 3707 | + semver: 6.3.1 | |
| 3708 | + | |
| 3709 | + node-releases@2.0.59: {} | |
| 3710 | + | |
| 3711 | + object-assign@4.1.1: {} | |
| 3712 | + | |
| 3713 | + object-inspect@1.13.4: {} | |
| 3714 | + | |
| 3715 | + object-keys@1.1.1: {} | |
| 3716 | + | |
| 3717 | + object.assign@4.1.7: | |
| 3718 | + dependencies: | |
| 3719 | + call-bind: 1.0.9 | |
| 3720 | + call-bound: 1.0.4 | |
| 3721 | + define-properties: 1.2.1 | |
| 3722 | + es-object-atoms: 1.1.2 | |
| 3723 | + has-symbols: 1.1.0 | |
| 3724 | + object-keys: 1.1.1 | |
| 3725 | + | |
| 3726 | + object.entries@1.1.9: | |
| 3727 | + dependencies: | |
| 3728 | + call-bind: 1.0.9 | |
| 3729 | + call-bound: 1.0.4 | |
| 3730 | + define-properties: 1.2.1 | |
| 3731 | + es-object-atoms: 1.1.2 | |
| 3732 | + | |
| 3733 | + object.fromentries@2.0.8: | |
| 3734 | + dependencies: | |
| 3735 | + call-bind: 1.0.9 | |
| 3736 | + define-properties: 1.2.1 | |
| 3737 | + es-abstract: 1.24.2 | |
| 3738 | + es-object-atoms: 1.1.2 | |
| 3739 | + | |
| 3740 | + object.groupby@1.0.3: | |
| 3741 | + dependencies: | |
| 3742 | + call-bind: 1.0.9 | |
| 3743 | + define-properties: 1.2.1 | |
| 3744 | + es-abstract: 1.24.2 | |
| 3745 | + | |
| 3746 | + object.values@1.2.1: | |
| 3747 | + dependencies: | |
| 3748 | + call-bind: 1.0.9 | |
| 3749 | + call-bound: 1.0.4 | |
| 3750 | + define-properties: 1.2.1 | |
| 3751 | + es-object-atoms: 1.1.2 | |
| 3752 | + | |
| 3753 | + optionator@0.9.4: | |
| 3754 | + dependencies: | |
| 3755 | + deep-is: 0.1.4 | |
| 3756 | + fast-levenshtein: 2.0.6 | |
| 3757 | + levn: 0.4.1 | |
| 3758 | + prelude-ls: 1.2.1 | |
| 3759 | + type-check: 0.4.0 | |
| 3760 | + word-wrap: 1.2.5 | |
| 3761 | + | |
| 3762 | + own-keys@1.0.2: | |
| 3763 | + dependencies: | |
| 3764 | + call-bound: 1.0.4 | |
| 3765 | + get-intrinsic: 1.3.0 | |
| 3766 | + object-keys: 1.1.1 | |
| 3767 | + safe-push-apply: 1.0.0 | |
| 3768 | + | |
| 3769 | + p-limit@3.1.0: | |
| 3770 | + dependencies: | |
| 3771 | + yocto-queue: 0.1.0 | |
| 3772 | + | |
| 3773 | + p-locate@5.0.0: | |
| 3774 | + dependencies: | |
| 3775 | + p-limit: 3.1.0 | |
| 3776 | + | |
| 3777 | + parent-module@1.0.1: | |
| 3778 | + dependencies: | |
| 3779 | + callsites: 3.1.0 | |
| 3780 | + | |
| 3781 | + path-exists@4.0.0: {} | |
| 3782 | + | |
| 3783 | + path-key@3.1.1: {} | |
| 3784 | + | |
| 3785 | + path-parse@1.0.7: {} | |
| 3786 | + | |
| 3787 | + picocolors@1.1.1: {} | |
| 3788 | + | |
| 3789 | + picomatch@2.3.2: {} | |
| 3790 | + | |
| 3791 | + picomatch@4.0.7: {} | |
| 3792 | + | |
| 3793 | + possible-typed-array-names@1.1.0: {} | |
| 3794 | + | |
| 3795 | + postcss@8.5.23: | |
| 3796 | + dependencies: | |
| 3797 | + nanoid: 3.3.20 | |
| 3798 | + picocolors: 1.1.1 | |
| 3799 | + source-map-js: 1.2.2 | |
| 3800 | + | |
| 3801 | + prelude-ls@1.2.1: {} | |
| 3802 | + | |
| 3803 | + prop-types@15.8.1: | |
| 3804 | + dependencies: | |
| 3805 | + loose-envify: 1.4.0 | |
| 3806 | + object-assign: 4.1.1 | |
| 3807 | + react-is: 16.13.1 | |
| 3808 | + | |
| 3809 | + punycode@2.3.1: {} | |
| 3810 | + | |
| 3811 | + queue-microtask@1.2.3: {} | |
| 3812 | + | |
| 3813 | + react-dom@19.3.0(react@19.3.0): | |
| 3814 | + dependencies: | |
| 3815 | + react: 19.3.0 | |
| 3816 | + scheduler: 0.28.0 | |
| 3817 | + | |
| 3818 | + react-is@16.13.1: {} | |
| 3819 | + | |
| 3820 | + react@19.3.0: {} | |
| 3821 | + | |
| 3822 | + reflect.getprototypeof@1.0.10: | |
| 3823 | + dependencies: | |
| 3824 | + call-bind: 1.0.9 | |
| 3825 | + define-properties: 1.2.1 | |
| 3826 | + es-abstract: 1.24.2 | |
| 3827 | + es-errors: 1.3.0 | |
| 3828 | + es-object-atoms: 1.1.2 | |
| 3829 | + get-intrinsic: 1.3.0 | |
| 3830 | + get-proto: 1.0.1 | |
| 3831 | + which-builtin-type: 1.2.1 | |
| 3832 | + | |
| 3833 | + regexp.prototype.flags@1.5.4: | |
| 3834 | + dependencies: | |
| 3835 | + call-bind: 1.0.9 | |
| 3836 | + define-properties: 1.2.1 | |
| 3837 | + es-errors: 1.3.0 | |
| 3838 | + get-proto: 1.0.1 | |
| 3839 | + gopd: 1.2.0 | |
| 3840 | + set-function-name: 2.0.2 | |
| 3841 | + | |
| 3842 | + resolve-from@4.0.0: {} | |
| 3843 | + | |
| 3844 | + resolve-pkg-maps@1.0.0: {} | |
| 3845 | + | |
| 3846 | + resolve@2.0.0-next.7: | |
| 3847 | + dependencies: | |
| 3848 | + es-errors: 1.3.0 | |
| 3849 | + is-core-module: 2.17.0 | |
| 3850 | + node-exports-info: 1.6.2 | |
| 3851 | + object-keys: 1.1.1 | |
| 3852 | + path-parse: 1.0.7 | |
| 3853 | + supports-preserve-symlinks-flag: 1.0.0 | |
| 3854 | + | |
| 3855 | + reusify@1.1.0: {} | |
| 3856 | + | |
| 3857 | + run-parallel@1.2.0: | |
| 3858 | + dependencies: | |
| 3859 | + queue-microtask: 1.2.3 | |
| 3860 | + | |
| 3861 | + safe-array-concat@1.1.4: | |
| 3862 | + dependencies: | |
| 3863 | + call-bind: 1.0.9 | |
| 3864 | + call-bound: 1.0.4 | |
| 3865 | + get-intrinsic: 1.3.0 | |
| 3866 | + has-symbols: 1.1.0 | |
| 3867 | + isarray: 2.0.5 | |
| 3868 | + | |
| 3869 | + safe-push-apply@1.0.0: | |
| 3870 | + dependencies: | |
| 3871 | + es-errors: 1.3.0 | |
| 3872 | + isarray: 2.0.5 | |
| 3873 | + | |
| 3874 | + safe-regex-test@1.1.0: | |
| 3875 | + dependencies: | |
| 3876 | + call-bound: 1.0.4 | |
| 3877 | + es-errors: 1.3.0 | |
| 3878 | + is-regex: 1.2.1 | |
| 3879 | + | |
| 3880 | + scheduler@0.28.0: {} | |
| 3881 | + | |
| 3882 | + semver@6.3.1: {} | |
| 3883 | + | |
| 3884 | + semver@7.8.5: {} | |
| 3885 | + | |
| 3886 | + set-function-length@1.2.2: | |
| 3887 | + dependencies: | |
| 3888 | + define-data-property: 1.1.4 | |
| 3889 | + es-errors: 1.3.0 | |
| 3890 | + function-bind: 1.1.2 | |
| 3891 | + get-intrinsic: 1.3.0 | |
| 3892 | + gopd: 1.2.0 | |
| 3893 | + has-property-descriptors: 1.0.2 | |
| 3894 | + | |
| 3895 | + set-function-name@2.0.2: | |
| 3896 | + dependencies: | |
| 3897 | + define-data-property: 1.1.4 | |
| 3898 | + es-errors: 1.3.0 | |
| 3899 | + functions-have-names: 1.2.3 | |
| 3900 | + has-property-descriptors: 1.0.2 | |
| 3901 | + | |
| 3902 | + set-proto@1.0.0: | |
| 3903 | + dependencies: | |
| 3904 | + dunder-proto: 1.0.1 | |
| 3905 | + es-errors: 1.3.0 | |
| 3906 | + es-object-atoms: 1.1.2 | |
| 3907 | + | |
| 3908 | + sharp@0.35.5(@types/node@20.19.43): | |
| 3909 | + dependencies: | |
| 3910 | + '@img/colour': 1.1.0 | |
| 3911 | + detect-libc: 2.1.2 | |
| 3912 | + semver: 7.8.5 | |
| 3913 | + optionalDependencies: | |
| 3914 | + '@img/sharp-darwin-arm64': 0.35.5 | |
| 3915 | + '@img/sharp-darwin-x64': 0.35.5 | |
| 3916 | + '@img/sharp-freebsd-wasm32': 0.35.5 | |
| 3917 | + '@img/sharp-libvips-darwin-arm64': 1.3.4 | |
| 3918 | + '@img/sharp-libvips-darwin-x64': 1.3.4 | |
| 3919 | + '@img/sharp-libvips-linux-arm': 1.3.4 | |
| 3920 | + '@img/sharp-libvips-linux-arm64': 1.3.4 | |
| 3921 | + '@img/sharp-libvips-linux-ppc64': 1.3.4 | |
| 3922 | + '@img/sharp-libvips-linux-riscv64': 1.3.4 | |
| 3923 | + '@img/sharp-libvips-linux-s390x': 1.3.4 | |
| 3924 | + '@img/sharp-libvips-linux-x64': 1.3.4 | |
| 3925 | + '@img/sharp-libvips-linuxmusl-arm64': 1.3.4 | |
| 3926 | + '@img/sharp-libvips-linuxmusl-x64': 1.3.4 | |
| 3927 | + '@img/sharp-linux-arm': 0.35.5 | |
| 3928 | + '@img/sharp-linux-arm64': 0.35.5 | |
| 3929 | + '@img/sharp-linux-ppc64': 0.35.5 | |
| 3930 | + '@img/sharp-linux-riscv64': 0.35.5 | |
| 3931 | + '@img/sharp-linux-s390x': 0.35.5 | |
| 3932 | + '@img/sharp-linux-x64': 0.35.5 | |
| 3933 | + '@img/sharp-linuxmusl-arm64': 0.35.5 | |
| 3934 | + '@img/sharp-linuxmusl-x64': 0.35.5 | |
| 3935 | + '@img/sharp-webcontainers-wasm32': 0.35.5 | |
| 3936 | + '@img/sharp-win32-arm64': 0.35.5 | |
| 3937 | + '@img/sharp-win32-ia32': 0.35.5 | |
| 3938 | + '@img/sharp-win32-x64': 0.35.5 | |
| 3939 | + '@types/node': 20.19.43 | |
| 3940 | + optional: true | |
| 3941 | + | |
| 3942 | + shebang-command@2.0.0: | |
| 3943 | + dependencies: | |
| 3944 | + shebang-regex: 3.0.0 | |
| 3945 | + | |
| 3946 | + shebang-regex@3.0.0: {} | |
| 3947 | + | |
| 3948 | + side-channel-list@1.0.1: | |
| 3949 | + dependencies: | |
| 3950 | + es-errors: 1.3.0 | |
| 3951 | + object-inspect: 1.13.4 | |
| 3952 | + | |
| 3953 | + side-channel-map@1.0.1: | |
| 3954 | + dependencies: | |
| 3955 | + call-bound: 1.0.4 | |
| 3956 | + es-errors: 1.3.0 | |
| 3957 | + get-intrinsic: 1.3.0 | |
| 3958 | + object-inspect: 1.13.4 | |
| 3959 | + | |
| 3960 | + side-channel-weakmap@1.0.2: | |
| 3961 | + dependencies: | |
| 3962 | + call-bound: 1.0.4 | |
| 3963 | + es-errors: 1.3.0 | |
| 3964 | + get-intrinsic: 1.3.0 | |
| 3965 | + object-inspect: 1.13.4 | |
| 3966 | + side-channel-map: 1.0.1 | |
| 3967 | + | |
| 3968 | + side-channel@1.1.1: | |
| 3969 | + dependencies: | |
| 3970 | + es-errors: 1.3.0 | |
| 3971 | + object-inspect: 1.13.4 | |
| 3972 | + side-channel-list: 1.0.1 | |
| 3973 | + side-channel-map: 1.0.1 | |
| 3974 | + side-channel-weakmap: 1.0.2 | |
| 3975 | + | |
| 3976 | + source-map-js@1.2.2: {} | |
| 3977 | + | |
| 3978 | + stable-hash@0.0.5: {} | |
| 3979 | + | |
| 3980 | + stop-iteration-iterator@1.1.0: | |
| 3981 | + dependencies: | |
| 3982 | + es-errors: 1.3.0 | |
| 3983 | + internal-slot: 1.1.0 | |
| 3984 | + | |
| 3985 | + string.prototype.includes@2.0.1: | |
| 3986 | + dependencies: | |
| 3987 | + call-bind: 1.0.9 | |
| 3988 | + define-properties: 1.2.1 | |
| 3989 | + es-abstract: 1.24.2 | |
| 3990 | + | |
| 3991 | + string.prototype.matchall@4.1.0: | |
| 3992 | + dependencies: | |
| 3993 | + call-bind: 1.0.9 | |
| 3994 | + call-bound: 1.0.4 | |
| 3995 | + define-properties: 1.2.1 | |
| 3996 | + es-abstract: 1.24.2 | |
| 3997 | + es-errors: 1.3.0 | |
| 3998 | + es-object-atoms: 1.1.2 | |
| 3999 | + get-intrinsic: 1.3.0 | |
| 4000 | + gopd: 1.2.0 | |
| 4001 | + has-symbols: 1.1.0 | |
| 4002 | + internal-slot: 1.1.0 | |
| 4003 | + regexp.prototype.flags: 1.5.4 | |
| 4004 | + set-function-name: 2.0.2 | |
| 4005 | + side-channel: 1.1.1 | |
| 4006 | + | |
| 4007 | + string.prototype.repeat@1.0.0: | |
| 4008 | + dependencies: | |
| 4009 | + define-properties: 1.2.1 | |
| 4010 | + es-abstract: 1.24.2 | |
| 4011 | + | |
| 4012 | + string.prototype.trim@1.2.11: | |
| 4013 | + dependencies: | |
| 4014 | + call-bind: 1.0.9 | |
| 4015 | + call-bound: 1.0.4 | |
| 4016 | + define-data-property: 1.1.4 | |
| 4017 | + define-properties: 1.2.1 | |
| 4018 | + es-abstract: 1.24.2 | |
| 4019 | + es-object-atoms: 1.1.2 | |
| 4020 | + has-property-descriptors: 1.0.2 | |
| 4021 | + safe-regex-test: 1.1.0 | |
| 4022 | + | |
| 4023 | + string.prototype.trimend@1.0.10: | |
| 4024 | + dependencies: | |
| 4025 | + call-bind: 1.0.9 | |
| 4026 | + call-bound: 1.0.4 | |
| 4027 | + define-properties: 1.2.1 | |
| 4028 | + es-object-atoms: 1.1.2 | |
| 4029 | + | |
| 4030 | + string.prototype.trimstart@1.0.8: | |
| 4031 | + dependencies: | |
| 4032 | + call-bind: 1.0.9 | |
| 4033 | + define-properties: 1.2.1 | |
| 4034 | + es-object-atoms: 1.1.2 | |
| 4035 | + | |
| 4036 | + strip-bom@3.0.0: {} | |
| 4037 | + | |
| 4038 | + strip-json-comments@3.1.1: {} | |
| 4039 | + | |
| 4040 | + styled-jsx@5.1.6(@babel/core@7.29.7)(react@19.3.0): | |
| 4041 | + dependencies: | |
| 4042 | + client-only: 0.0.1 | |
| 4043 | + react: 19.3.0 | |
| 4044 | + optionalDependencies: | |
| 4045 | + '@babel/core': 7.29.7 | |
| 4046 | + | |
| 4047 | + supports-color@7.2.0: | |
| 4048 | + dependencies: | |
| 4049 | + has-flag: 4.0.0 | |
| 4050 | + | |
| 4051 | + supports-preserve-symlinks-flag@1.0.0: {} | |
| 4052 | + | |
| 4053 | + tailwindcss@4.3.3: {} | |
| 4054 | + | |
| 4055 | + tapable@2.3.3: {} | |
| 4056 | + | |
| 4057 | + tinyglobby@0.2.17: | |
| 4058 | + dependencies: | |
| 4059 | + fdir: 6.5.0(picomatch@4.0.7) | |
| 4060 | + picomatch: 4.0.7 | |
| 4061 | + | |
| 4062 | + to-regex-range@5.0.1: | |
| 4063 | + dependencies: | |
| 4064 | + is-number: 7.0.0 | |
| 4065 | + | |
| 4066 | + ts-api-utils@2.5.0(typescript@5.9.3): | |
| 4067 | + dependencies: | |
| 4068 | + typescript: 5.9.3 | |
| 4069 | + | |
| 4070 | + tsconfig-paths@3.15.0: | |
| 4071 | + dependencies: | |
| 4072 | + '@types/json5': 0.0.29 | |
| 4073 | + json5: 1.0.2 | |
| 4074 | + minimist: 1.2.8 | |
| 4075 | + strip-bom: 3.0.0 | |
| 4076 | + | |
| 4077 | + tslib@2.8.1: {} | |
| 4078 | + | |
| 4079 | + type-check@0.4.0: | |
| 4080 | + dependencies: | |
| 4081 | + prelude-ls: 1.2.1 | |
| 4082 | + | |
| 4083 | + typed-array-buffer@1.0.3: | |
| 4084 | + dependencies: | |
| 4085 | + call-bound: 1.0.4 | |
| 4086 | + es-errors: 1.3.0 | |
| 4087 | + is-typed-array: 1.1.15 | |
| 4088 | + | |
| 4089 | + typed-array-byte-length@1.0.3: | |
| 4090 | + dependencies: | |
| 4091 | + call-bind: 1.0.9 | |
| 4092 | + for-each: 0.3.5 | |
| 4093 | + gopd: 1.2.0 | |
| 4094 | + has-proto: 1.2.0 | |
| 4095 | + is-typed-array: 1.1.15 | |
| 4096 | + | |
| 4097 | + typed-array-byte-offset@1.0.5: | |
| 4098 | + dependencies: | |
| 4099 | + available-typed-arrays: 1.0.7 | |
| 4100 | + call-bind: 1.0.9 | |
| 4101 | + for-each: 0.3.5 | |
| 4102 | + gopd: 1.2.0 | |
| 4103 | + is-typed-array: 1.1.15 | |
| 4104 | + reflect.getprototypeof: 1.0.10 | |
| 4105 | + | |
| 4106 | + typed-array-length@1.0.8: | |
| 4107 | + dependencies: | |
| 4108 | + call-bind: 1.0.9 | |
| 4109 | + for-each: 0.3.5 | |
| 4110 | + gopd: 1.2.0 | |
| 4111 | + is-typed-array: 1.1.15 | |
| 4112 | + possible-typed-array-names: 1.1.0 | |
| 4113 | + reflect.getprototypeof: 1.0.10 | |
| 4114 | + | |
| 4115 | + typescript-eslint@8.72.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3): | |
| 4116 | + dependencies: | |
| 4117 | + '@typescript-eslint/eslint-plugin': 8.72.0(@typescript-eslint/parser@8.72.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) | |
| 4118 | + '@typescript-eslint/parser': 8.72.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) | |
| 4119 | + '@typescript-eslint/typescript-estree': 8.72.0(typescript@5.9.3) | |
| 4120 | + '@typescript-eslint/utils': 8.72.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) | |
| 4121 | + eslint: 9.39.5(jiti@2.7.0) | |
| 4122 | + typescript: 5.9.3 | |
| 4123 | + transitivePeerDependencies: | |
| 4124 | + - supports-color | |
| 4125 | + | |
| 4126 | + typescript@5.9.3: {} | |
| 4127 | + | |
| 4128 | + unbox-primitive@1.1.0: | |
| 4129 | + dependencies: | |
| 4130 | + call-bound: 1.0.4 | |
| 4131 | + has-bigints: 1.1.0 | |
| 4132 | + has-symbols: 1.1.0 | |
| 4133 | + which-boxed-primitive: 1.1.1 | |
| 4134 | + | |
| 4135 | + undici-types@6.21.0: {} | |
| 4136 | + | |
| 4137 | + unrs-resolver@1.12.2: | |
| 4138 | + dependencies: | |
| 4139 | + napi-postinstall: 0.3.5 | |
| 4140 | + optionalDependencies: | |
| 4141 | + '@unrs/resolver-binding-android-arm-eabi': 1.12.2 | |
| 4142 | + '@unrs/resolver-binding-android-arm64': 1.12.2 | |
| 4143 | + '@unrs/resolver-binding-darwin-arm64': 1.12.2 | |
| 4144 | + '@unrs/resolver-binding-darwin-x64': 1.12.2 | |
| 4145 | + '@unrs/resolver-binding-freebsd-x64': 1.12.2 | |
| 4146 | + '@unrs/resolver-binding-linux-arm-gnueabihf': 1.12.2 | |
| 4147 | + '@unrs/resolver-binding-linux-arm-musleabihf': 1.12.2 | |
| 4148 | + '@unrs/resolver-binding-linux-arm64-gnu': 1.12.2 | |
| 4149 | + '@unrs/resolver-binding-linux-arm64-musl': 1.12.2 | |
| 4150 | + '@unrs/resolver-binding-linux-loong64-gnu': 1.12.2 | |
| 4151 | + '@unrs/resolver-binding-linux-loong64-musl': 1.12.2 | |
| 4152 | + '@unrs/resolver-binding-linux-ppc64-gnu': 1.12.2 | |
| 4153 | + '@unrs/resolver-binding-linux-riscv64-gnu': 1.12.2 | |
| 4154 | + '@unrs/resolver-binding-linux-riscv64-musl': 1.12.2 | |
| 4155 | + '@unrs/resolver-binding-linux-s390x-gnu': 1.12.2 | |
| 4156 | + '@unrs/resolver-binding-linux-x64-gnu': 1.12.2 | |
| 4157 | + '@unrs/resolver-binding-linux-x64-musl': 1.12.2 | |
| 4158 | + '@unrs/resolver-binding-openharmony-arm64': 1.12.2 | |
| 4159 | + '@unrs/resolver-binding-wasm32-wasi': 1.12.2 | |
| 4160 | + '@unrs/resolver-binding-win32-arm64-msvc': 1.12.2 | |
| 4161 | + '@unrs/resolver-binding-win32-ia32-msvc': 1.12.2 | |
| 4162 | + '@unrs/resolver-binding-win32-x64-msvc': 1.12.2 | |
| 4163 | + | |
| 4164 | + update-browserslist-db@1.3.4(browserslist@4.29.4): | |
| 4165 | + dependencies: | |
| 4166 | + browserslist: 4.29.4 | |
| 4167 | + escalade: 3.2.0 | |
| 4168 | + picocolors: 1.1.1 | |
| 4169 | + | |
| 4170 | + uri-js@4.4.1: | |
| 4171 | + dependencies: | |
| 4172 | + punycode: 2.3.1 | |
| 4173 | + | |
| 4174 | + which-boxed-primitive@1.1.1: | |
| 4175 | + dependencies: | |
| 4176 | + is-bigint: 1.1.0 | |
| 4177 | + is-boolean-object: 1.2.2 | |
| 4178 | + is-number-object: 1.1.1 | |
| 4179 | + is-string: 1.1.1 | |
| 4180 | + is-symbol: 1.1.1 | |
| 4181 | + | |
| 4182 | + which-builtin-type@1.2.1: | |
| 4183 | + dependencies: | |
| 4184 | + call-bound: 1.0.4 | |
| 4185 | + function.prototype.name: 1.2.0 | |
| 4186 | + has-tostringtag: 1.0.2 | |
| 4187 | + is-async-function: 2.1.1 | |
| 4188 | + is-date-object: 1.1.0 | |
| 4189 | + is-finalizationregistry: 1.1.1 | |
| 4190 | + is-generator-function: 1.1.2 | |
| 4191 | + is-regex: 1.2.1 | |
| 4192 | + is-weakref: 1.1.1 | |
| 4193 | + isarray: 2.0.5 | |
| 4194 | + which-boxed-primitive: 1.1.1 | |
| 4195 | + which-collection: 1.0.2 | |
| 4196 | + which-typed-array: 1.1.24 | |
| 4197 | + | |
| 4198 | + which-collection@1.0.2: | |
| 4199 | + dependencies: | |
| 4200 | + is-map: 2.0.3 | |
| 4201 | + is-set: 2.0.3 | |
| 4202 | + is-weakmap: 2.0.2 | |
| 4203 | + is-weakset: 2.0.4 | |
| 4204 | + | |
| 4205 | + which-typed-array@1.1.24: | |
| 4206 | + dependencies: | |
| 4207 | + available-typed-arrays: 1.0.7 | |
| 4208 | + call-bind: 1.0.9 | |
| 4209 | + call-bound: 1.0.4 | |
| 4210 | + for-each: 0.3.5 | |
| 4211 | + get-proto: 1.0.1 | |
| 4212 | + gopd: 1.2.0 | |
| 4213 | + has-tostringtag: 1.0.2 | |
| 4214 | + | |
| 4215 | + which@2.0.2: | |
| 4216 | + dependencies: | |
| 4217 | + isexe: 2.0.0 | |
| 4218 | + | |
| 4219 | + word-wrap@1.2.5: {} | |
| 4220 | + | |
| 4221 | + yallist@3.1.1: {} | |
| 4222 | + | |
| 4223 | + yocto-queue@0.1.0: {} | |
| 4224 | + | |
| 4225 | + zod-validation-error@4.0.2(zod@4.6.7): | |
| 4226 | + dependencies: | |
| 4227 | + zod: 4.6.7 | |
| 4228 | + | |
| 4229 | + zod@4.6.7: {} | |
added
apps/web/pnpm-workspace.yaml
+3 −0
@@ -0,0 +1,3 @@ | ||
| 1 | +allowBuilds: | |
| 2 | + sharp: false | |
| 3 | + unrs-resolver: false | |
added
apps/web/public/file.svg
+1 −0
@@ -0,0 +1 @@ | ||
| 1 | +<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg> | |
| \ No newline at end of file | ||
added
apps/web/public/globe.svg
+1 −0
@@ -0,0 +1 @@ | ||
| 1 | +<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg> | |
| \ No newline at end of file | ||
added
apps/web/public/next.svg
+1 −0
@@ -0,0 +1 @@ | ||
| 1 | +<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg> | |
| \ No newline at end of file | ||
added
apps/web/public/vercel.svg
+1 −0
@@ -0,0 +1 @@ | ||
| 1 | +<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg> | |
| \ No newline at end of file | ||
added
apps/web/public/window.svg
+1 −0
@@ -0,0 +1 @@ | ||
| 1 | +<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg> | |
| \ No newline at end of file | ||
added
apps/web/src/app/(app)/backtests/[id]/page.tsx
+78 −0
@@ -0,0 +1,78 @@ | ||
| 1 | +"use client"; | |
| 2 | +import { useQuery } from "@tanstack/react-query"; | |
| 3 | +import { Download, FileText } from "lucide-react"; | |
| 4 | +import Link from "next/link"; | |
| 5 | +import { useParams } from "next/navigation"; | |
| 6 | +import { useState } from "react"; | |
| 7 | +import { StatusBadge } from "../../page"; | |
| 8 | +import { LinesChart } from "@/components/charts"; | |
| 9 | +import { BenchmarkTable, EquityVsBenchmarks, MetricsGrid, Provenance, RejectedOrders, RollingSharpe, SensitivityHeatmap, ShuffledTrades, StressTable, TradeLedger, Underwater, WalkForward } from "@/components/run-views"; | |
| 10 | +import { Badge, Card, Empty, ErrorBox, PageHeader, Spinner } from "@/components/ui"; | |
| 11 | +import { api } from "@/lib/api"; | |
| 12 | +import { fmtTs } from "@/lib/format"; | |
| 13 | +import type { Run, StrategyMeta } from "@/lib/types"; | |
| 14 | + | |
| 15 | +export default function RunPage() { | |
| 16 | + const { id } = useParams<{ id: string }>(); | |
| 17 | + const run = useQuery({ queryKey: ["run", id], queryFn: () => api<Run>(`/api/backtests/${id}`), refetchInterval: (q) => (q.state.data && (q.state.data.status === "done" || q.state.data.status === "failed") ? false : 1_500) }); | |
| 18 | + const strategies = useQuery({ queryKey: ["strategies"], queryFn: () => api<{ items: StrategyMeta[] }>("/api/strategies") }); | |
| 19 | + const [page, setPage] = useState(0); | |
| 20 | + if (run.isLoading) return <Spinner label="Loading run…" />; | |
| 21 | + if (run.error) return <ErrorBox error={run.error} />; | |
| 22 | + const r = run.data!; | |
| 23 | + const meta = strategies.data?.items.find((s) => s.name === r.strategy); | |
| 24 | + const a = r.artifacts; | |
| 25 | + const m = r.metrics; | |
| 26 | + return ( | |
| 27 | + <> | |
| 28 | + <PageHeader | |
| 29 | + title={`${meta?.label ?? r.strategy} · ${r.symbol} · ${r.interval_min}m`} | |
| 30 | + subtitle={<span className="flex flex-wrap items-center gap-2"><StatusBadge status={r.status} />{meta?.experimental && <Badge tone="warn">experimental — not validated for live use</Badge>}{r.label && <span className="text-fg-muted">{r.label}</span>}{r.start_ts && <span className="num text-fg-dim">{fmtTs(r.start_ts)} → {fmtTs(r.end_ts)}</span>}</span>} | |
| 31 | + actions={<> | |
| 32 | + <Link href={`/reports/${r.id}`} className="inline-flex h-8 items-center gap-1 rounded-md border border-line-strong px-2.5 text-xs hover:bg-bg-sub"><FileText className="h-3.5 w-3.5" /> Report</Link> | |
| 33 | + <a href={`/api/backtests/${r.id}`} className="inline-flex h-8 items-center gap-1 rounded-md border border-line-strong px-2.5 text-xs hover:bg-bg-sub" download={`backtest-${r.id}.json`}><Download className="h-3.5 w-3.5" /> JSON</a> | |
| 34 | + <a href={`/api/backtests/${r.id}/trades.csv`} className="inline-flex h-8 items-center gap-1 rounded-md border border-line-strong px-2.5 text-xs hover:bg-bg-sub" download><Download className="h-3.5 w-3.5" /> Trades CSV</a> | |
| 35 | + </>} | |
| 36 | + /> | |
| 37 | + {r.status === "failed" && <ErrorBox error={r.error ?? "run failed"} />} | |
| 38 | + {(r.status === "queued" || r.status === "running") && <Spinner label={`Backtest ${r.status}… (benchmarks, bootstrap and stress scenarios run in the same job)`} />} | |
| 39 | + {r.status === "done" && a && m && ( | |
| 40 | + <div className="flex flex-col gap-4"> | |
| 41 | + <Card title="Performance (in-sample window)" subtitle="Net of fees, spread, slippage and impact. Sharpe shows a 95% block-bootstrap interval; the deflated Sharpe is the probability the Sharpe exceeds what the best of N random trials would produce."><MetricsGrid m={m} /></Card> | |
| 42 | + <div className="grid gap-4 xl:grid-cols-3"> | |
| 43 | + <Card title="Equity vs benchmarks" className="xl:col-span-2"><EquityVsBenchmarks a={a} label={r.strategy} /></Card> | |
| 44 | + <Card title="Benchmarks" subtitle="Same window, same cost model."><BenchmarkTable a={a} m={m} /></Card> | |
| 45 | + </div> | |
| 46 | + <div className="grid gap-4 md:grid-cols-2"> | |
| 47 | + <Card title="Underwater (drawdown)"><Underwater a={a} /></Card> | |
| 48 | + <Card title="Rolling Sharpe"><RollingSharpe a={a} /></Card> | |
| 49 | + </div> | |
| 50 | + {a.holdout ? ( | |
| 51 | + <Card title="Out-of-sample holdout" subtitle={`Final ${Math.round(Number(r.provenance?.holdout_frac ?? 0) * 100)}% of the data, never used for selection. Starts ${fmtTs(a.holdout.start_ts)}.`}> | |
| 52 | + <MetricsGrid m={a.holdout.metrics} compact /> | |
| 53 | + <div className="mt-3"><LinesChart series={[{ id: "h", label: "holdout equity", color: "#22c55e", points: a.holdout.equity.map((p) => ({ ts: p.ts, value: p.equity })) }]} height={200} /></div> | |
| 54 | + </Card> | |
| 55 | + ) : <Card title="Out-of-sample holdout"><Empty title="No holdout window">Set a holdout fraction > 0 in the Strategy Lab to reserve a final test slice.</Empty></Card>} | |
| 56 | + <Card title="Walk-forward folds"><WalkForward wf={a.walk_forward} /></Card> | |
| 57 | + <div className="grid gap-4 md:grid-cols-2"> | |
| 58 | + <Card title="Parameter sensitivity"><SensitivityHeatmap s={a.sensitivity} /></Card> | |
| 59 | + <Card title="Stress scenarios" subtitle="Re-runs with adverse cost assumptions and a simulated exchange outage gap."><StressTable stress={a.stress} base={m} /></Card> | |
| 60 | + </div> | |
| 61 | + <div className="grid gap-4 md:grid-cols-2"> | |
| 62 | + <Card title="Shuffled-trades Monte-Carlo" subtitle="Drawdown distribution when round-trip P&Ls are reordered."><ShuffledTrades s={a.shuffled_trades} /></Card> | |
| 63 | + <Card title="Rejected orders" subtitle="Blocked by the risk gateway during simulation."><RejectedOrders rejected={a.rejected} /></Card> | |
| 64 | + </div> | |
| 65 | + <Card title={`Trade ledger (${a.trades.length}${a.trades.length >= 2000 ? ", last 2000 shown" : ""})`}><TradeLedger trades={a.trades} page={page} setPage={setPage} /></Card> | |
| 66 | + <Card title="Provenance" subtitle="Everything needed to reproduce this run."><Provenance run={r} /></Card> | |
| 67 | + {meta && ( | |
| 68 | + <Card title="Strategy notes"> | |
| 69 | + <p className="text-xs text-fg-muted">{meta.hypothesis}</p> | |
| 70 | + {meta.failure_modes.length > 0 && <p className="mt-2 text-xs"><span className="text-fg-dim">Known failure modes:</span> {meta.failure_modes.join(" · ")}</p>} | |
| 71 | + {meta.references.length > 0 && <p className="mt-1 text-xs"><span className="text-fg-dim">References:</span> {meta.references.join("; ")}</p>} | |
| 72 | + </Card> | |
| 73 | + )} | |
| 74 | + </div> | |
| 75 | + )} | |
| 76 | + </> | |
| 77 | + ); | |
| 78 | +} | |
added
apps/web/src/app/(app)/backtests/page.tsx
+82 −0
@@ -0,0 +1,82 @@ | ||
| 1 | +"use client"; | |
| 2 | +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; | |
| 3 | +import { Trash2 } from "lucide-react"; | |
| 4 | +import Link from "next/link"; | |
| 5 | +import { useMemo, useState } from "react"; | |
| 6 | +import { StatusBadge } from "../page"; | |
| 7 | +import { LinesChart, type LineSpec } from "@/components/charts"; | |
| 8 | +import { Button, Card, Empty, ErrorBox, PageHeader, Spinner, Table, Td, Th } from "@/components/ui"; | |
| 9 | +import { api } from "@/lib/api"; | |
| 10 | +import { fmtIso, fmtNum, fmtPct, fmtPctPlain, fmtRatio, signClass } from "@/lib/format"; | |
| 11 | +import type { EquityPoint, Metrics, RunRow } from "@/lib/types"; | |
| 12 | + | |
| 13 | +const COLORS = ["#4f8cff", "#22c55e", "#f59e0b", "#ef4444", "#a855f7", "#14b8a6", "#f472b6", "#eab308"]; | |
| 14 | + | |
| 15 | +type CompareItem = { id: string; strategy: string; symbol: string; interval_min: number; params: Record<string, unknown>; metrics: Metrics; equity: EquityPoint[]; label: string | null }; | |
| 16 | + | |
| 17 | +export default function BacktestsPage() { | |
| 18 | + const qc = useQueryClient(); | |
| 19 | + const runs = useQuery({ | |
| 20 | + queryKey: ["runs", 200], | |
| 21 | + queryFn: () => api<{ items: RunRow[] }>("/api/backtests?limit=200"), | |
| 22 | + refetchInterval: (q) => (q.state.data?.items.some((r) => r.status === "queued" || r.status === "running") ? 2_000 : 15_000), | |
| 23 | + }); | |
| 24 | + const [sel, setSel] = useState<string[]>([]); | |
| 25 | + const compare = useQuery({ queryKey: ["compare", sel], queryFn: () => api<{ items: CompareItem[] }>("/api/backtests/compare", { method: "POST", json: sel }), enabled: sel.length >= 2 }); | |
| 26 | + const del = useMutation({ mutationFn: (id: string) => api(`/api/backtests/${id}`, { method: "DELETE" }), onSuccess: () => qc.invalidateQueries({ queryKey: ["runs"] }) }); | |
| 27 | + const toggle = (id: string) => setSel((s) => (s.includes(id) ? s.filter((x) => x !== id) : s.length < 8 ? [...s, id] : s)); | |
| 28 | + | |
| 29 | + const series: LineSpec[] = useMemo(() => (compare.data?.items ?? []).map((it, i) => { | |
| 30 | + const e0 = it.equity[0]?.equity ?? 1; | |
| 31 | + return { id: it.id, label: `${it.strategy} ${it.symbol} ${it.interval_min}m`, color: COLORS[i % COLORS.length], points: it.equity.map((p) => ({ ts: p.ts, value: (p.equity / e0 - 1) * 100 })) }; | |
| 32 | + }), [compare.data]); | |
| 33 | + | |
| 34 | + return ( | |
| 35 | + <> | |
| 36 | + <PageHeader title="Backtest Explorer" subtitle="Every run records provenance (commit, dataset hash, parameters, costs). Select 2–8 done runs to compare." /> | |
| 37 | + {sel.length >= 2 && ( | |
| 38 | + <Card title={`Comparison (${sel.length} runs)`} subtitle="Equity normalized to the start of each run, in percent." className="mb-4" actions={<Button size="sm" onClick={() => setSel([])}>Clear</Button>}> | |
| 39 | + {compare.isLoading && <Spinner />} | |
| 40 | + {compare.error && <ErrorBox error={compare.error} />} | |
| 41 | + {compare.data && ( | |
| 42 | + <> | |
| 43 | + <LinesChart series={series} priceFormat="percent" height={280} /> | |
| 44 | + <Table className="mt-3"> | |
| 45 | + <thead><tr><Th>Run</Th><Th>Params</Th><Th align="right">CAGR</Th><Th align="right">Vol</Th><Th align="right">Sharpe</Th><Th align="right">Sortino</Th><Th align="right">Max DD</Th><Th align="right">Calmar</Th><Th align="right">Turnover</Th><Th align="right">Trades</Th><Th align="right">Fee drag</Th></tr></thead> | |
| 46 | + <tbody>{compare.data.items.map((it, i) => ( | |
| 47 | + <tr key={it.id}> | |
| 48 | + <Td><span className="mr-1.5 inline-block h-2 w-2 rounded-full" style={{ background: COLORS[i % COLORS.length] }} /><Link href={`/backtests/${it.id}`} className="text-accent hover:underline">{it.strategy} {it.symbol} {it.interval_min}m</Link>{it.label && <span className="ml-1 text-fg-dim">({it.label})</span>}</Td> | |
| 49 | + <Td className="max-w-56 truncate font-mono text-[11px] text-fg-muted">{JSON.stringify(it.params)}</Td> | |
| 50 | + <Td align="right" num className={signClass(it.metrics.cagr)}>{fmtPct(it.metrics.cagr)}</Td><Td align="right" num>{fmtPctPlain(it.metrics.volatility)}</Td><Td align="right" num>{fmtRatio(it.metrics.sharpe)}</Td><Td align="right" num>{fmtRatio(it.metrics.sortino)}</Td> | |
| 51 | + <Td align="right" num className="text-down">{fmtPct(it.metrics.max_drawdown)}</Td><Td align="right" num>{fmtRatio(it.metrics.calmar)}</Td><Td align="right" num>{fmtRatio(it.metrics.turnover_annual, 1)}×</Td><Td align="right" num>{it.metrics.n_trades}</Td><Td align="right" num>{fmtPctPlain(it.metrics.fee_drag, 2)}</Td> | |
| 52 | + </tr> | |
| 53 | + ))}</tbody> | |
| 54 | + </Table> | |
| 55 | + </> | |
| 56 | + )} | |
| 57 | + </Card> | |
| 58 | + )} | |
| 59 | + <Card title="Runs" actions={<Link href="/lab" className="text-xs text-accent hover:underline">New backtest →</Link>}> | |
| 60 | + {runs.isLoading && <Spinner />} | |
| 61 | + {runs.error && <ErrorBox error={runs.error} />} | |
| 62 | + {runs.data && (runs.data.items.length ? ( | |
| 63 | + <Table> | |
| 64 | + <thead><tr><Th><span className="sr-only">Compare</span></Th><Th>Status</Th><Th>Strategy</Th><Th>Symbol</Th><Th align="right">Interval</Th><Th align="right">Sharpe</Th><Th align="right">Return</Th><Th align="right">Max DD</Th><Th align="right">Trades</Th><Th>Label</Th><Th>Created</Th><Th><span className="sr-only">Delete</span></Th></tr></thead> | |
| 65 | + <tbody>{runs.data.items.map((r) => ( | |
| 66 | + <tr key={r.id} className="hover:bg-bg-sub"> | |
| 67 | + <Td><input type="checkbox" aria-label={`Select run ${r.id}`} disabled={r.status !== "done"} checked={sel.includes(r.id)} onChange={() => toggle(r.id)} /></Td> | |
| 68 | + <Td><Link href={`/backtests/${r.id}`} title={r.error ?? undefined}><StatusBadge status={r.status} /></Link></Td> | |
| 69 | + <Td><Link href={`/backtests/${r.id}`} className="font-medium text-accent hover:underline">{r.strategy}</Link></Td> | |
| 70 | + <Td>{r.symbol}</Td><Td align="right" num>{r.interval_min}m</Td> | |
| 71 | + <Td align="right" num>{fmtNum(r.sharpe)}</Td><Td align="right" num className={signClass(r.total_return)}>{fmtPct(r.total_return)}</Td><Td align="right" num className="text-down">{fmtPct(r.max_drawdown)}</Td><Td align="right" num>{r.n_trades ?? "—"}</Td> | |
| 72 | + <Td className="text-fg-muted">{r.label ?? ""}{r.error && <span className="block max-w-64 truncate text-[11px] text-down" title={r.error}>{r.error}</span>}</Td> | |
| 73 | + <Td num className="text-fg-dim">{fmtIso(r.created_at)}</Td> | |
| 74 | + <Td><button aria-label="Delete run" className="rounded p-1 text-fg-dim hover:bg-bg-sub hover:text-down" onClick={() => { if (confirm("Delete this run and its artifacts?")) del.mutate(r.id); }}><Trash2 className="h-3.5 w-3.5" /></button></Td> | |
| 75 | + </tr> | |
| 76 | + ))}</tbody> | |
| 77 | + </Table> | |
| 78 | + ) : <Empty title="No backtests yet">Launch one from the Strategy Lab.</Empty>)} | |
| 79 | + </Card> | |
| 80 | + </> | |
| 81 | + ); | |
| 82 | +} | |
added
apps/web/src/app/(app)/execution/page.tsx
+35 −0
@@ -0,0 +1,35 @@ | ||
| 1 | +import { Lock } from "lucide-react"; | |
| 2 | +import { Badge, Card, Empty, PageHeader } from "@/components/ui"; | |
| 3 | + | |
| 4 | +const CHECKLIST = [ | |
| 5 | + ["Owner confirmation in the UI (second confirmation)", "Live mode can never be enabled by a background process or an agent."], | |
| 6 | + ["Trade-key permission verified by preflight", "Separate least-privilege key with order permission only; withdrawal permission is never used."], | |
| 7 | + ["Risk budget selected", "Per-order, per-symbol, daily-loss and drawdown limits acknowledged for the live book."], | |
| 8 | + ["Small-size canary completed", "A documented minimum-size order cycle before any strategy is allowed to route live."], | |
| 9 | + ["Feature flag KTRADE_LIVE_TRADING_ENABLED=true", "Server-side gate; off by default and in every deployment so far."], | |
| 10 | +]; | |
| 11 | + | |
| 12 | +export default function ExecutionPage() { | |
| 13 | + return ( | |
| 14 | + <> | |
| 15 | + <PageHeader title="Execution Console" subtitle="Order lifecycle, routing status, Kraken error codes, latency and reconciliation. Paper routing arrives in Phase 4; the live adapter in Phase 5." /> | |
| 16 | + <div className="mb-4 flex gap-2" role="tablist" aria-label="Execution environment"> | |
| 17 | + <button role="tab" aria-selected="true" className="rounded-md border border-accent bg-accent-soft px-3 py-1.5 text-xs">Paper</button> | |
| 18 | + <button role="tab" aria-selected="false" aria-disabled="true" disabled className="inline-flex items-center gap-1.5 rounded-md border border-line-strong px-3 py-1.5 text-xs text-fg-dim"><Lock className="h-3 w-3" /> Live <Badge tone="muted">locked</Badge></button> | |
| 19 | + </div> | |
| 20 | + <div className="grid gap-4 md:grid-cols-2"> | |
| 21 | + <Card title="Order lifecycle"><Empty title="No orders">No paper or live orders have been created. Order ids are idempotent client ids (cl_ord_id) so retries can never duplicate an order.</Empty></Card> | |
| 22 | + <Card title="Live activation checklist" subtitle="All items are required, in order. None is satisfied."> | |
| 23 | + <ol className="space-y-2"> | |
| 24 | + {CHECKLIST.map(([t, d]) => ( | |
| 25 | + <li key={t} className="flex items-start gap-2 text-xs"> | |
| 26 | + <input type="checkbox" disabled aria-label={t} className="mt-0.5" /> | |
| 27 | + <span><span className="font-medium text-fg">{t}</span><br /><span className="text-fg-dim">{d}</span></span> | |
| 28 | + </li> | |
| 29 | + ))} | |
| 30 | + </ol> | |
| 31 | + </Card> | |
| 32 | + </div> | |
| 33 | + </> | |
| 34 | + ); | |
| 35 | +} | |
added
apps/web/src/app/(app)/lab/page.tsx
+190 −0
@@ -0,0 +1,190 @@ | ||
| 1 | +"use client"; | |
| 2 | +import { useMutation, useQuery } from "@tanstack/react-query"; | |
| 3 | +import Link from "next/link"; | |
| 4 | +import { useRouter } from "next/navigation"; | |
| 5 | +import { useMemo, useState } from "react"; | |
| 6 | +import { StatusBadge } from "../page"; | |
| 7 | +import { useSystemStatus } from "@/components/shell"; | |
| 8 | +import { Badge, Button, Card, Empty, ErrorBox, Field, Input, PageHeader, Select, Spinner, Table, Td, Textarea, Th, cx } from "@/components/ui"; | |
| 9 | +import { api, ApiError } from "@/lib/api"; | |
| 10 | +import { fmtIso, fmtNum, fmtPct, signClass } from "@/lib/format"; | |
| 11 | +import type { ParamSpec, RunRow, StrategyMeta } from "@/lib/types"; | |
| 12 | + | |
| 13 | +const DEFAULT_GRIDS: Record<string, Record<string, unknown[]>> = { | |
| 14 | + tsmom: { lookback: [24, 48, 96], ema_fast: [10, 20], ema_slow: [60, 120] }, | |
| 15 | + ma_ensemble: { slow_mult: [2, 3, 5], target_vol: [0.3, 0.5] }, | |
| 16 | + zscore_mr: { window: [24, 48, 96], entry_z: [1.5, 2.0, 2.5] }, | |
| 17 | + simple_momentum: { lookback: [12, 24, 48] }, | |
| 18 | + equal_risk: { vol_lookback: [24, 48, 96] }, | |
| 19 | +}; | |
| 20 | + | |
| 21 | +function defaultParams(s: StrategyMeta) { | |
| 22 | + return Object.fromEntries(s.params.map((p) => [p.name, p.default])); | |
| 23 | +} | |
| 24 | + | |
| 25 | +export default function LabPage() { | |
| 26 | + const sys = useSystemStatus(); | |
| 27 | + const strategies = useQuery({ queryKey: ["strategies"], queryFn: () => api<{ items: StrategyMeta[] }>("/api/strategies") }); | |
| 28 | + const runs = useQuery({ queryKey: ["runs", 12], queryFn: () => api<{ items: RunRow[] }>("/api/backtests?limit=12"), refetchInterval: 5_000 }); | |
| 29 | + const [selected, setSelected] = useState<string | null>(null); | |
| 30 | + const strat = strategies.data?.items.find((s) => s.name === selected) ?? null; | |
| 31 | + return ( | |
| 32 | + <> | |
| 33 | + <PageHeader title="Strategy Lab" subtitle="Catalog of strategy plugins. Every run passes the same cost model and risk gateway used by paper/live engines." /> | |
| 34 | + <div className="grid gap-4 xl:grid-cols-[360px_1fr]"> | |
| 35 | + <div className="flex flex-col gap-3"> | |
| 36 | + {strategies.isLoading && <Spinner />} | |
| 37 | + {strategies.error && <ErrorBox error={strategies.error} />} | |
| 38 | + {strategies.data?.items.map((s) => ( | |
| 39 | + <button key={s.name} onClick={() => setSelected(s.name)} aria-pressed={selected === s.name} className={cx("rounded-lg border p-3 text-left transition", selected === s.name ? "border-accent bg-accent-soft/40" : "border-line bg-bg-elev hover:border-line-strong")}> | |
| 40 | + <div className="flex items-center justify-between gap-2"> | |
| 41 | + <span className="text-sm font-semibold">{s.label}</span> | |
| 42 | + <span className="flex gap-1"><Badge tone="muted">{s.family}</Badge>{s.experimental && <Badge tone="warn">experimental</Badge>}</span> | |
| 43 | + </div> | |
| 44 | + <p className="mt-1 line-clamp-3 text-xs text-fg-muted">{s.hypothesis}</p> | |
| 45 | + {s.failure_modes.length > 0 && <p className="mt-1.5 text-[11px] text-fg-dim">Failure modes: {s.failure_modes.join(" · ")}</p>} | |
| 46 | + </button> | |
| 47 | + ))} | |
| 48 | + </div> | |
| 49 | + <div className="flex flex-col gap-4"> | |
| 50 | + {strat ? ( | |
| 51 | + <ConfigForm key={strat.name} strat={strat} universe={sys.data?.universe ?? []} intervals={sys.data?.intervals ?? [60]} /> | |
| 52 | + ) : ( | |
| 53 | + <Card title="Configure a strategy"><Empty title="Select a strategy from the catalog">Parameters, cost model, holdout, walk-forward and sensitivity options appear here.</Empty></Card> | |
| 54 | + )} | |
| 55 | + <Card title="Recent runs" actions={<Link href="/backtests" className="text-xs text-accent hover:underline">Backtest Explorer →</Link>}> | |
| 56 | + {runs.data?.items.length ? ( | |
| 57 | + <Table> | |
| 58 | + <thead><tr><Th>Status</Th><Th>Strategy</Th><Th>Symbol</Th><Th align="right">Sharpe</Th><Th align="right">Return</Th><Th>Label</Th><Th>Created</Th></tr></thead> | |
| 59 | + <tbody>{runs.data.items.map((r) => ( | |
| 60 | + <tr key={r.id} className="hover:bg-bg-sub"> | |
| 61 | + <Td><Link href={`/backtests/${r.id}`}><StatusBadge status={r.status} /></Link></Td><Td>{r.strategy}</Td><Td>{r.symbol} {r.interval_min}m</Td> | |
| 62 | + <Td align="right" num>{fmtNum(r.sharpe)}</Td><Td align="right" num className={signClass(r.total_return)}>{fmtPct(r.total_return)}</Td><Td className="text-fg-muted">{r.label ?? ""}</Td><Td num className="text-fg-dim">{fmtIso(r.created_at)}</Td> | |
| 63 | + </tr> | |
| 64 | + ))}</tbody> | |
| 65 | + </Table> | |
| 66 | + ) : <Empty title="No runs yet" />} | |
| 67 | + </Card> | |
| 68 | + </div> | |
| 69 | + </div> | |
| 70 | + </> | |
| 71 | + ); | |
| 72 | +} | |
| 73 | + | |
| 74 | +function ConfigForm({ strat, universe, intervals }: { strat: StrategyMeta; universe: string[]; intervals: number[] }) { | |
| 75 | + const router = useRouter(); | |
| 76 | + const [params, setParams] = useState<Record<string, unknown>>(() => defaultParams(strat)); | |
| 77 | + const [symbol, setSymbol] = useState(universe[0] ?? ""); | |
| 78 | + const [interval, setInterval_] = useState(60); | |
| 79 | + const [cash, setCash] = useState(10000); | |
| 80 | + const [cost, setCost] = useState({ taker_fee: 0.004, maker_fee: 0.0025, spread_bps: 5, slippage_bps: 2, max_participation: 0.05 }); | |
| 81 | + const [holdout, setHoldout] = useState(0.2); | |
| 82 | + const [wfOn, setWfOn] = useState(false); | |
| 83 | + const [wfGrid, setWfGrid] = useState(() => JSON.stringify(DEFAULT_GRIDS[strat.name] ?? Object.fromEntries(strat.params.filter((p) => p.type !== "bool").slice(0, 2).map((p) => [p.name, [p.default]])), null, 1)); | |
| 84 | + const [wfFolds, setWfFolds] = useState(4); | |
| 85 | + const [wfAnchored, setWfAnchored] = useState(true); | |
| 86 | + const [sensOn, setSensOn] = useState(false); | |
| 87 | + const [sens, setSens] = useState(() => { | |
| 88 | + const nums = strat.params.filter((p) => p.type === "int" || p.type === "float"); | |
| 89 | + return { x: nums[0]?.name ?? "", xv: nums[0] ? String(nums[0].default) : "", y: nums[1]?.name ?? "", yv: nums[1] ? String(nums[1].default) : "" }; | |
| 90 | + }); | |
| 91 | + const [label, setLabel] = useState(""); | |
| 92 | + const [err, setErr] = useState<string | null>(null); | |
| 93 | + | |
| 94 | + | |
| 95 | + const validation = useMemo(() => { | |
| 96 | + const out: string[] = []; | |
| 97 | + for (const p of strat.params) { | |
| 98 | + const v = params[p.name]; | |
| 99 | + if ((p.type === "int" || p.type === "float") && typeof v === "number") { | |
| 100 | + if (p.min != null && v < p.min) out.push(`${p.name} must be ≥ ${p.min}`); | |
| 101 | + if (p.max != null && v > p.max) out.push(`${p.name} must be ≤ ${p.max}`); | |
| 102 | + if (p.type === "int" && !Number.isInteger(v)) out.push(`${p.name} must be an integer`); | |
| 103 | + } | |
| 104 | + } | |
| 105 | + return out; | |
| 106 | + }, [strat, params]); | |
| 107 | + | |
| 108 | + const launch = useMutation({ | |
| 109 | + mutationFn: async () => { | |
| 110 | + const body: Record<string, unknown> = { strategy: strat.name, symbol, interval_min: interval, params, cost_model: cost, initial_cash: cash, holdout_frac: holdout, label: label || null, stress: true }; | |
| 111 | + if (wfOn) { | |
| 112 | + let grid: unknown; | |
| 113 | + try { grid = JSON.parse(wfGrid); } catch { throw new Error("walk-forward grid is not valid JSON"); } | |
| 114 | + body.walk_forward = { grid, n_folds: wfFolds, anchored: wfAnchored, train_frac: 0.6 }; | |
| 115 | + body.n_trials = Object.values(grid as Record<string, unknown[]>).reduce((a, v) => a * (Array.isArray(v) ? v.length : 1), 1); | |
| 116 | + } | |
| 117 | + if (sensOn && sens.x && sens.y) { | |
| 118 | + const parse = (s: string) => s.split(",").map((x) => x.trim()).filter(Boolean).map((x) => (Number.isFinite(Number(x)) ? Number(x) : x)); | |
| 119 | + body.sensitivity = { x: sens.x, x_values: parse(sens.xv), y: sens.y, y_values: parse(sens.yv), metric: "sharpe" }; | |
| 120 | + } | |
| 121 | + return api<{ id: string }>("/api/backtests", { method: "POST", json: body }); | |
| 122 | + }, | |
| 123 | + onSuccess: (r) => router.push(`/backtests/${r.id}`), | |
| 124 | + onError: (e) => setErr(e instanceof ApiError ? e.message : String(e)), | |
| 125 | + }); | |
| 126 | + | |
| 127 | + return ( | |
| 128 | + <Card title={`Configure: ${strat.label}`} subtitle={<span>{strat.hypothesis} <span className="text-fg-dim">Needs ≥ {strat.min_bars} closed bars. {strat.allows_short ? "May short." : "Long-only (spot)."}</span></span>}> | |
| 129 | + <form onSubmit={(e) => { e.preventDefault(); setErr(null); launch.mutate(); }} className="flex flex-col gap-4"> | |
| 130 | + <div className="grid grid-cols-2 gap-3 md:grid-cols-4"> | |
| 131 | + <Field label="Symbol"><Select value={symbol} onChange={(e) => setSymbol(e.target.value)}>{universe.map((s) => <option key={s}>{s}</option>)}</Select></Field> | |
| 132 | + <Field label="Interval"><Select value={interval} onChange={(e) => setInterval_(Number(e.target.value))}>{intervals.map((i) => <option key={i} value={i}>{i} min</option>)}</Select></Field> | |
| 133 | + <Field label="Initial cash (USD)"><Input type="number" min={10} step={100} value={cash} onChange={(e) => setCash(Number(e.target.value))} /></Field> | |
| 134 | + <Field label="Holdout fraction" hint="Final slice never used for selection"><Input type="number" min={0} max={0.5} step={0.05} value={holdout} onChange={(e) => setHoldout(Number(e.target.value))} /></Field> | |
| 135 | + </div> | |
| 136 | + <fieldset className="rounded-md border border-line p-3"> | |
| 137 | + <legend className="px-1 text-xs font-semibold text-fg-muted">Strategy parameters</legend> | |
| 138 | + <div className="grid grid-cols-2 gap-3 md:grid-cols-3"> | |
| 139 | + {strat.params.map((p) => <ParamInput key={p.name} spec={p} value={params[p.name]} onChange={(v) => setParams((prev) => ({ ...prev, [p.name]: v }))} />)} | |
| 140 | + {strat.params.length === 0 && <p className="text-xs text-fg-dim">No parameters.</p>} | |
| 141 | + </div> | |
| 142 | + {validation.length > 0 && <ul className="mt-2 text-xs text-down">{validation.map((v) => <li key={v}>{v}</li>)}</ul>} | |
| 143 | + </fieldset> | |
| 144 | + <fieldset className="rounded-md border border-line p-3"> | |
| 145 | + <legend className="px-1 text-xs font-semibold text-fg-muted">Cost model</legend> | |
| 146 | + <div className="grid grid-cols-2 gap-3 md:grid-cols-5"> | |
| 147 | + <Field label="Taker fee" hint="fraction (0.004 = 0.40%)"><Input type="number" step={0.0001} min={0} value={cost.taker_fee} onChange={(e) => setCost({ ...cost, taker_fee: Number(e.target.value) })} /></Field> | |
| 148 | + <Field label="Maker fee"><Input type="number" step={0.0001} min={0} value={cost.maker_fee} onChange={(e) => setCost({ ...cost, maker_fee: Number(e.target.value) })} /></Field> | |
| 149 | + <Field label="Spread (bps)"><Input type="number" step={0.5} min={0} value={cost.spread_bps} onChange={(e) => setCost({ ...cost, spread_bps: Number(e.target.value) })} /></Field> | |
| 150 | + <Field label="Slippage (bps)"><Input type="number" step={0.5} min={0} value={cost.slippage_bps} onChange={(e) => setCost({ ...cost, slippage_bps: Number(e.target.value) })} /></Field> | |
| 151 | + <Field label="Max participation" hint="fraction of bar volume"><Input type="number" step={0.01} min={0.001} max={1} value={cost.max_participation} onChange={(e) => setCost({ ...cost, max_participation: Number(e.target.value) })} /></Field> | |
| 152 | + </div> | |
| 153 | + </fieldset> | |
| 154 | + <fieldset className="rounded-md border border-line p-3"> | |
| 155 | + <legend className="px-1 text-xs font-semibold text-fg-muted"><label className="flex items-center gap-2"><input type="checkbox" checked={wfOn} onChange={(e) => setWfOn(e.target.checked)} /> Walk-forward evaluation</label></legend> | |
| 156 | + {wfOn && ( | |
| 157 | + <div className="grid gap-3 md:grid-cols-[1fr_140px_140px]"> | |
| 158 | + <Field label="Parameter grid (JSON)" hint="Best in-sample Sharpe per fold is then evaluated out-of-sample; trial count feeds the deflated Sharpe."><Textarea rows={5} value={wfGrid} onChange={(e) => setWfGrid(e.target.value)} /></Field> | |
| 159 | + <Field label="Folds"><Input type="number" min={2} max={10} value={wfFolds} onChange={(e) => setWfFolds(Number(e.target.value))} /></Field> | |
| 160 | + <Field label="Train window"><Select value={wfAnchored ? "anchored" : "rolling"} onChange={(e) => setWfAnchored(e.target.value === "anchored")}><option value="anchored">Anchored</option><option value="rolling">Rolling</option></Select></Field> | |
| 161 | + </div> | |
| 162 | + )} | |
| 163 | + </fieldset> | |
| 164 | + <fieldset className="rounded-md border border-line p-3"> | |
| 165 | + <legend className="px-1 text-xs font-semibold text-fg-muted"><label className="flex items-center gap-2"><input type="checkbox" checked={sensOn} onChange={(e) => setSensOn(e.target.checked)} /> Sensitivity map (Sharpe)</label></legend> | |
| 166 | + {sensOn && ( | |
| 167 | + <div className="grid grid-cols-2 gap-3 md:grid-cols-4"> | |
| 168 | + <Field label="X parameter"><Select value={sens.x} onChange={(e) => setSens({ ...sens, x: e.target.value })}>{strat.params.map((p) => <option key={p.name}>{p.name}</option>)}</Select></Field> | |
| 169 | + <Field label="X values" hint="comma-separated"><Input value={sens.xv} onChange={(e) => setSens({ ...sens, xv: e.target.value })} /></Field> | |
| 170 | + <Field label="Y parameter"><Select value={sens.y} onChange={(e) => setSens({ ...sens, y: e.target.value })}>{strat.params.map((p) => <option key={p.name}>{p.name}</option>)}</Select></Field> | |
| 171 | + <Field label="Y values"><Input value={sens.yv} onChange={(e) => setSens({ ...sens, yv: e.target.value })} /></Field> | |
| 172 | + </div> | |
| 173 | + )} | |
| 174 | + </fieldset> | |
| 175 | + <div className="flex flex-wrap items-end gap-3"> | |
| 176 | + <Field label="Label (optional)"><Input value={label} onChange={(e) => setLabel(e.target.value)} placeholder="e.g. tsmom 1h baseline" /></Field> | |
| 177 | + <Button type="submit" variant="primary" loading={launch.isPending} disabled={validation.length > 0 || !symbol}>Run backtest</Button> | |
| 178 | + {err && <span role="alert" className="text-xs text-down">{err}</span>} | |
| 179 | + </div> | |
| 180 | + </form> | |
| 181 | + </Card> | |
| 182 | + ); | |
| 183 | +} | |
| 184 | + | |
| 185 | +function ParamInput({ spec, value, onChange }: { spec: ParamSpec; value: unknown; onChange: (v: unknown) => void }) { | |
| 186 | + const hint = [spec.description, spec.min != null || spec.max != null ? `range ${spec.min ?? "−∞"} … ${spec.max ?? "∞"}` : null].filter(Boolean).join(" · "); | |
| 187 | + if (spec.type === "bool") return <Field label={spec.name} hint={hint}><label className="flex h-9 items-center gap-2 text-sm"><input type="checkbox" checked={!!value} onChange={(e) => onChange(e.target.checked)} /> enabled</label></Field>; | |
| 188 | + if (spec.type === "choice") return <Field label={spec.name} hint={hint}><Select value={String(value)} onChange={(e) => onChange(e.target.value)}>{(spec.choices ?? []).map((c) => <option key={String(c)} value={String(c)}>{String(c)}</option>)}</Select></Field>; | |
| 189 | + return <Field label={spec.name} hint={hint}><Input type="number" step={spec.step ?? (spec.type === "int" ? 1 : 0.01)} min={spec.min ?? undefined} max={spec.max ?? undefined} value={value as number} onChange={(e) => onChange(e.target.value === "" ? "" : Number(e.target.value))} /></Field>; | |
| 190 | +} | |
added
apps/web/src/app/(app)/layout.tsx
+5 −0
@@ -0,0 +1,5 @@ | ||
| 1 | +import { Shell } from "@/components/shell"; | |
| 2 | + | |
| 3 | +export default function AppLayout({ children }: { children: React.ReactNode }) { | |
| 4 | + return <Shell>{children}</Shell>; | |
| 5 | +} | |
added
apps/web/src/app/(app)/market/page.tsx
+177 −0
@@ -0,0 +1,177 @@ | ||
| 1 | +"use client"; | |
| 2 | +import { useQuery } from "@tanstack/react-query"; | |
| 3 | +import { useRouter, useSearchParams } from "next/navigation"; | |
| 4 | +import { Suspense, useCallback, useMemo, useState } from "react"; | |
| 5 | +import { CandleChart, Sparkline } from "@/components/charts"; | |
| 6 | +import { useSystemStatus } from "@/components/shell"; | |
| 7 | +import { Badge, Card, Empty, ErrorBox, Input, PageHeader, Select, Spinner, Table, Td, Th, cx } from "@/components/ui"; | |
| 8 | +import { api, qs } from "@/lib/api"; | |
| 9 | +import { fmtAge, fmtBps, fmtDate, fmtNum, fmtPct, fmtPrice, fmtTs } from "@/lib/format"; | |
| 10 | +import { useMarketStream } from "@/lib/sse"; | |
| 11 | +import type { Book, Candle, Gap, Pair, Quality, Trade } from "@/lib/types"; | |
| 12 | + | |
| 13 | +const INTERVALS = [{ v: 1, l: "1 min" }, { v: 5, l: "5 min" }, { v: 60, l: "1 hour" }, { v: 1440, l: "1 day" }]; | |
| 14 | + | |
| 15 | +function MarketExplorer() { | |
| 16 | + const params = useSearchParams(); | |
| 17 | + const sys = useSystemStatus(); | |
| 18 | + const universe = useMemo(() => sys.data?.universe ?? [], [sys.data]); | |
| 19 | + const symbol = params.get("symbol") ?? universe[0] ?? "BTC/USD"; | |
| 20 | + const interval = Number(params.get("interval") ?? 60); | |
| 21 | + if (!sys.data) return <Spinner />; | |
| 22 | + return <MarketView key={`${symbol}|${interval}`} symbol={symbol} interval={interval} universe={universe} intervals={sys.data.intervals} />; | |
| 23 | +} | |
| 24 | + | |
| 25 | +function MarketView({ symbol, interval, universe, intervals }: { symbol: string; interval: number; universe: string[]; intervals: number[] }) { | |
| 26 | + const params = useSearchParams(); | |
| 27 | + const router = useRouter(); | |
| 28 | + const [search, setSearch] = useState(""); | |
| 29 | + const setParam = useCallback((k: string, v: string) => { | |
| 30 | + const p = new URLSearchParams(params.toString()); | |
| 31 | + p.set(k, v); | |
| 32 | + router.replace(`/market?${p.toString()}`); | |
| 33 | + }, [params, router]); | |
| 34 | + | |
| 35 | + const pairs = useQuery({ queryKey: ["pairs", search], queryFn: () => api<{ items: Pair[] }>(`/api/pairs?${qs({ q: search || undefined, all: !!search })}`), enabled: search.length >= 2 }); | |
| 36 | + const candles = useQuery({ queryKey: ["candles", symbol, interval], queryFn: () => api<{ items: Candle[] }>(`/api/market/candles?${qs({ symbol, interval, limit: 1500 })}`), refetchInterval: interval * 60_000 }); | |
| 37 | + const bookQ = useQuery({ queryKey: ["book", symbol], queryFn: () => api<Book>(`/api/market/book?symbol=${encodeURIComponent(symbol)}`), retry: false }); | |
| 38 | + const tradesQ = useQuery({ queryKey: ["trades", symbol], queryFn: () => api<{ items: Trade[] }>(`/api/market/trades?${qs({ symbol, limit: 60 })}`) }); | |
| 39 | + const micro = useQuery({ queryKey: ["micro", symbol], queryFn: () => api<{ items: { ts: number; spread_bps: number | null; imbalance: number | null; best_bid: number | null; best_ask: number | null }[] }>(`/api/market/microstructure?${qs({ symbol, seconds: 3600 })}`), refetchInterval: 30_000 }); | |
| 40 | + const quality = useQuery({ queryKey: ["quality"], queryFn: () => api<{ items: Quality[]; gaps: Gap[] }>("/api/market/quality"), refetchInterval: 60_000 }); | |
| 41 | + const pairMeta = useQuery({ queryKey: ["pair-meta", symbol], queryFn: async () => (await api<{ items: Pair[] }>(`/api/pairs?${qs({ q: symbol.split("/")[0], all: true })}`)).items.find((p) => p.wsname === symbol) ?? null }); | |
| 42 | + | |
| 43 | + const [ticker, setTicker] = useState<Record<string, unknown> | null>(null); | |
| 44 | + const [liveBook, setBook] = useState<Book | null>(null); | |
| 45 | + const [liveTape, setTape] = useState<Trade[]>([]); | |
| 46 | + const [liveBar, setLiveBar] = useState<Candle | null>(null); | |
| 47 | + // live stream state overrides the REST snapshot once it arrives (component is keyed by symbol+interval, so it resets) | |
| 48 | + const book = liveBook ?? bookQ.data ?? null; | |
| 49 | + const tape = liveTape.length ? liveTape : tradesQ.data?.items ?? []; | |
| 50 | + | |
| 51 | + const streamOk = useMarketStream([symbol], ["ticker", "book", "trade", "ohlc"], { | |
| 52 | + ticker: (ev) => { const d = ev.data[0]; if (d) setTicker(d); }, | |
| 53 | + book: (ev) => { const d = ev.data[0] as unknown as Book | undefined; if (d) setBook(d); }, | |
| 54 | + trade: (ev) => { | |
| 55 | + const rows = ev.data.map((d) => ({ trade_id: Number(d.trade_id), ts: Date.parse(String(d.timestamp)) / 1000, price: Number(d.price), volume: Number(d.qty), side: d.side as "buy" | "sell", order_type: String(d.ord_type ?? "") })); | |
| 56 | + setTape((prev) => [...rows.reverse(), ...prev].slice(0, 80)); | |
| 57 | + }, | |
| 58 | + ohlc: (ev) => { | |
| 59 | + const d = ev.data[0]; | |
| 60 | + if (!d) return; | |
| 61 | + const ts = Math.floor(Date.parse(String(d.interval_begin)) / 1000); | |
| 62 | + const bar: Candle = { ts, open: Number(d.open), high: Number(d.high), low: Number(d.low), close: Number(d.close), vwap: Number(d.vwap), volume: Number(d.volume), count: Number(d.trades), closed: false }; | |
| 63 | + if (interval === 1) setLiveBar(bar); | |
| 64 | + else { | |
| 65 | + const step = interval * 60; | |
| 66 | + const bucket = Math.floor(ts / step) * step; | |
| 67 | + setLiveBar((prev) => { | |
| 68 | + const base = candles.data?.items.find((c) => c.ts === bucket); | |
| 69 | + const cur = prev && prev.ts === bucket ? prev : base ?? { ts: bucket, open: bar.open, high: bar.high, low: bar.low, close: bar.close, vwap: bar.vwap, volume: 0, count: 0, closed: false }; | |
| 70 | + return { ...cur, high: Math.max(cur.high, bar.high), low: Math.min(cur.low, bar.low), close: bar.close, closed: false }; | |
| 71 | + }); | |
| 72 | + } | |
| 73 | + }, | |
| 74 | + }); | |
| 75 | + | |
| 76 | + const q = quality.data?.items.find((x) => x.symbol === symbol && x.interval_min === interval); | |
| 77 | + const gaps = quality.data?.gaps.filter((g) => g.symbol === symbol && g.interval_min === interval) ?? []; | |
| 78 | + const bestBid = book?.best_bid, bestAsk = book?.best_ask; | |
| 79 | + const maxDepth = book ? Math.max(...book.bids.slice(0, 15).map((b) => b[1]), ...book.asks.slice(0, 15).map((a) => a[1]), 1e-9) : 1; | |
| 80 | + | |
| 81 | + return ( | |
| 82 | + <> | |
| 83 | + <PageHeader title="Market Explorer" subtitle="Candles accumulated from Kraken REST OHLC (≤720 bars per poll), live updates from WebSocket v2." actions={<Badge tone={streamOk ? "up" : "warn"}>{streamOk ? "stream connected" : "stream reconnecting"}</Badge>} /> | |
| 84 | + <div className="mb-3 flex flex-wrap items-end gap-3"> | |
| 85 | + <div className="relative w-64"> | |
| 86 | + <label className="mb-1 block text-xs font-medium text-fg-muted" htmlFor="sym">Symbol</label> | |
| 87 | + <Input id="sym" placeholder="Search pairs (e.g. DOGE)…" value={search} onChange={(e) => setSearch(e.target.value)} aria-autocomplete="list" aria-controls="sym-list" /> | |
| 88 | + {search.length >= 2 && ( | |
| 89 | + <ul id="sym-list" role="listbox" className="absolute z-20 mt-1 max-h-64 w-full overflow-auto rounded-md border border-line-strong bg-bg-elev shadow-xl scroll-thin"> | |
| 90 | + {pairs.data?.items.length ? pairs.data.items.map((p) => ( | |
| 91 | + <li key={p.pair_id} role="option" aria-selected={p.wsname === symbol}> | |
| 92 | + <button className="flex w-full items-center justify-between px-3 py-1.5 text-left text-xs hover:bg-bg-sub" onClick={() => { setParam("symbol", p.wsname); setSearch(""); }}> | |
| 93 | + <span className="font-medium">{p.wsname}</span><span className="text-fg-dim">{p.status}{p.in_universe ? " · universe" : " · not streamed"}</span> | |
| 94 | + </button> | |
| 95 | + </li> | |
| 96 | + )) : <li className="px-3 py-2 text-xs text-fg-dim">{pairs.isLoading ? "Searching…" : "No match"}</li>} | |
| 97 | + </ul> | |
| 98 | + )} | |
| 99 | + </div> | |
| 100 | + <div className="flex gap-1" role="group" aria-label="Universe"> | |
| 101 | + {universe.map((s) => <button key={s} onClick={() => setParam("symbol", s)} className={cx("rounded-md border px-2.5 py-1.5 text-xs", s === symbol ? "border-accent bg-accent-soft text-fg" : "border-line-strong text-fg-muted hover:text-fg")}>{s}</button>)} | |
| 102 | + </div> | |
| 103 | + <label className="text-xs font-medium text-fg-muted">Interval | |
| 104 | + <Select className="mt-1 w-28" value={interval} onChange={(e) => setParam("interval", e.target.value)}>{INTERVALS.filter((i) => intervals.includes(i.v) || i.v === interval).map((i) => <option key={i.v} value={i.v}>{i.l}</option>)}</Select> | |
| 105 | + </label> | |
| 106 | + <div className="ml-auto flex items-center gap-2 text-xs"> | |
| 107 | + {q ? <Badge tone={q.stale ? "warn" : "up"}>{q.bars} bars · {q.coverage_days}d · {q.gaps} gaps{q.lag_bars > 0 ? ` · lag ${q.lag_bars}` : ""}</Badge> : <Badge tone="muted">no stored bars for {symbol} @ {interval}m</Badge>} | |
| 108 | + </div> | |
| 109 | + </div> | |
| 110 | + | |
| 111 | + <div className="grid gap-4 xl:grid-cols-[1fr_320px]"> | |
| 112 | + <div className="flex flex-col gap-4"> | |
| 113 | + <Card title={<span className="flex items-center gap-3">{symbol} <span className="num text-base">{ticker ? fmtPrice(ticker.last) : fmtPrice(candles.data?.items.at(-1)?.close)}</span>{ticker && <span className={cx("num text-xs", Number(ticker.change_pct) >= 0 ? "text-up" : "text-down")}>{fmtPct(ticker.change_pct, 2, true)} 24h</span>}</span>} subtitle={ticker ? `bid ${fmtPrice(ticker.bid)} · ask ${fmtPrice(ticker.ask)} · 24h vol ${fmtNum(ticker.volume, 0)} · vwap ${fmtPrice(ticker.vwap)}` : "waiting for ticker…"}> | |
| 114 | + {candles.isLoading && <Spinner />} | |
| 115 | + {candles.error && <ErrorBox error={candles.error} />} | |
| 116 | + {candles.data && (candles.data.items.length ? <CandleChart candles={candles.data.items} live={liveBar} /> : <Empty title="No candles stored yet">This symbol is not in the ingestion universe, or the worker has not polled this interval yet.</Empty>)} | |
| 117 | + </Card> | |
| 118 | + <div className="grid gap-4 md:grid-cols-2"> | |
| 119 | + <Card title="Microstructure (last hour)" subtitle="1-second samples of the checksum-verified local book."> | |
| 120 | + {micro.data?.items.length ? ( | |
| 121 | + <div className="grid grid-cols-2 gap-3 text-xs"> | |
| 122 | + <div><div className="text-fg-dim">Spread (bps)</div><Sparkline values={micro.data.items.map((i) => i.spread_bps)} color="#f59e0b" /><div className="num">{fmtBps(micro.data.items.at(-1)?.spread_bps, 2)} now</div></div> | |
| 123 | + <div><div className="text-fg-dim">Imbalance (bid−ask)/(bid+ask)</div><Sparkline values={micro.data.items.map((i) => i.imbalance)} color="#4f8cff" /><div className="num">{fmtNum(micro.data.items.at(-1)?.imbalance, 3)} now</div></div> | |
| 124 | + </div> | |
| 125 | + ) : <Empty title="No samples yet">Samples are written by the ingestion worker while the book stream is live.</Empty>} | |
| 126 | + </Card> | |
| 127 | + <Card title="Data quality" subtitle="Coverage grows from first deployment; Kraken REST only serves the last 720 bars."> | |
| 128 | + {q ? ( | |
| 129 | + <dl className="grid grid-cols-2 gap-y-1 text-xs"> | |
| 130 | + <dt className="text-fg-dim">First bar</dt><dd className="num">{fmtDate(q.first_ts)}</dd> | |
| 131 | + <dt className="text-fg-dim">Last closed bar</dt><dd className="num">{fmtTs(q.last_ts)}</dd> | |
| 132 | + <dt className="text-fg-dim">Closed bars</dt><dd className="num">{q.bars}</dd> | |
| 133 | + <dt className="text-fg-dim">Missing bars</dt><dd className="num">{q.missing_bars} in {q.gaps} gaps</dd> | |
| 134 | + <dt className="text-fg-dim">Lag</dt><dd className="num">{q.lag_bars} bars {q.stale && <Badge tone="warn">stale</Badge>}</dd> | |
| 135 | + </dl> | |
| 136 | + ) : <Empty title="No quality record" />} | |
| 137 | + {gaps.length > 0 && <ul className="mt-2 max-h-24 overflow-auto text-[11px] text-fg-dim scroll-thin">{gaps.slice(0, 10).map((g) => <li key={g.gap_start}>{fmtTs(g.gap_start)} → {fmtTs(g.gap_end)} ({g.missing_bars} bars)</li>)}</ul>} | |
| 138 | + </Card> | |
| 139 | + </div> | |
| 140 | + </div> | |
| 141 | + <div className="flex flex-col gap-4"> | |
| 142 | + <Card title="Order book" subtitle={book ? <span>spread {fmtBps(book.spread_bps, 2)} · imbalance <span className="num">{fmtNum(book.imbalance, 3)}</span> · {book.valid ? <Badge tone="up">checksum ok</Badge> : <Badge tone="down">checksum mismatch</Badge>}{book.age_s != null && ` · ${fmtAge(book.age_s)}`}</span> : "waiting for book…"}> | |
| 143 | + {book ? ( | |
| 144 | + <div className="grid grid-cols-2 gap-2 text-[11px]"> | |
| 145 | + <div> | |
| 146 | + <div className="mb-1 flex justify-between text-fg-dim"><span>Bid</span><span>Qty</span></div> | |
| 147 | + {book.bids.slice(0, 15).map(([p, qty]) => ( | |
| 148 | + <div key={p} className="relative flex justify-between py-px num"><span className="absolute inset-y-0 right-0 bg-up/15" style={{ width: `${(qty / maxDepth) * 100}%` }} /><span className="relative text-up">{fmtPrice(p)}</span><span className="relative">{fmtNum(qty, 4)}</span></div> | |
| 149 | + ))} | |
| 150 | + </div> | |
| 151 | + <div> | |
| 152 | + <div className="mb-1 flex justify-between text-fg-dim"><span>Ask</span><span>Qty</span></div> | |
| 153 | + {book.asks.slice(0, 15).map(([p, qty]) => ( | |
| 154 | + <div key={p} className="relative flex justify-between py-px num"><span className="absolute inset-y-0 left-0 bg-down/15" style={{ width: `${(qty / maxDepth) * 100}%` }} /><span className="relative text-down">{fmtPrice(p)}</span><span className="relative">{fmtNum(qty, 4)}</span></div> | |
| 155 | + ))} | |
| 156 | + </div> | |
| 157 | + <div className="col-span-2 mt-1 border-t border-line pt-1 text-fg-dim">mid <span className="num text-fg">{bestBid && bestAsk ? fmtPrice((bestBid + bestAsk) / 2) : "—"}</span>{pairMeta.data && <> · tick {pairMeta.data.tick_size} · min {pairMeta.data.ordermin} {pairMeta.data.base} / {pairMeta.data.costmin} {pairMeta.data.quote}</>}</div> | |
| 158 | + </div> | |
| 159 | + ) : bookQ.isLoading ? <Spinner /> : <Empty title="No book">Only universe symbols are streamed.</Empty>} | |
| 160 | + </Card> | |
| 161 | + <Card title="Trades" subtitle="Taker side; live via WebSocket."> | |
| 162 | + {tape.length ? ( | |
| 163 | + <Table className="max-h-80 overflow-y-auto"> | |
| 164 | + <thead><tr><Th>Time</Th><Th align="right">Price</Th><Th align="right">Qty</Th><Th>Side</Th></tr></thead> | |
| 165 | + <tbody>{tape.map((t) => <tr key={`${t.trade_id}-${t.ts}`}><Td num>{new Date(t.ts * 1000).toISOString().slice(11, 19)}</Td><Td align="right" num className={t.side === "buy" ? "text-up" : "text-down"}>{fmtPrice(t.price)}</Td><Td align="right" num>{fmtNum(t.volume, 4)}</Td><Td>{t.side}</Td></tr>)}</tbody> | |
| 166 | + </Table> | |
| 167 | + ) : <Empty title="No trades yet" />} | |
| 168 | + </Card> | |
| 169 | + </div> | |
| 170 | + </div> | |
| 171 | + </> | |
| 172 | + ); | |
| 173 | +} | |
| 174 | + | |
| 175 | +export default function MarketPage() { | |
| 176 | + return <Suspense fallback={<Spinner />}><MarketExplorer /></Suspense>; | |
| 177 | +} | |
added
apps/web/src/app/(app)/page.tsx
+114 −0
@@ -0,0 +1,114 @@ | ||
| 1 | +"use client"; | |
| 2 | +import { useQuery } from "@tanstack/react-query"; | |
| 3 | +import Link from "next/link"; | |
| 4 | +import { useRiskStatus, useSystemStatus } from "@/components/shell"; | |
| 5 | +import { Badge, Card, Dot, Empty, ErrorBox, KV, PageHeader, Spinner, Table, Td, Th, Tile } from "@/components/ui"; | |
| 6 | +import { api } from "@/lib/api"; | |
| 7 | +import { fmtAge, fmtBps, fmtNum, fmtPct, fmtPctPlain, fmtPrice, fmtUsd, signClass } from "@/lib/format"; | |
| 8 | +import type { OverviewSymbol, RunRow } from "@/lib/types"; | |
| 9 | + | |
| 10 | +export default function OverviewPage() { | |
| 11 | + const sys = useSystemStatus(); | |
| 12 | + const risk = useRiskStatus(); | |
| 13 | + const ov = useQuery({ queryKey: ["overview"], queryFn: () => api<{ symbols: OverviewSymbol[] }>("/api/market/overview"), refetchInterval: 5_000 }); | |
| 14 | + const runs = useQuery({ queryKey: ["runs", 5], queryFn: () => api<{ items: RunRow[] }>("/api/backtests?limit=5"), refetchInterval: 10_000 }); | |
| 15 | + const s = sys.data; | |
| 16 | + return ( | |
| 17 | + <> | |
| 18 | + <PageHeader title="Overview" subtitle="Portfolio, universe, connectivity and risk posture at a glance." /> | |
| 19 | + <div className="grid grid-cols-2 gap-3 md:grid-cols-3 xl:grid-cols-6"> | |
| 20 | + <Tile label="Total equity" value="—" sub="paper ledger not started (Phase 4)" tone="muted" /> | |
| 21 | + <Tile label="Cash" value="—" sub="paper ledger not started (Phase 4)" tone="muted" /> | |
| 22 | + <Tile label="Realized / unrealized P&L" value="—" sub="no fills yet" tone="muted" /> | |
| 23 | + <Tile label="Gross exposure" value={fmtUsd(0)} sub="no positions" tone="muted" /> | |
| 24 | + <Tile label="Trading mode" value={<span className="uppercase">{s?.mode ?? "…"}</span>} sub={s?.live_trading_enabled ? "live flag ON" : "live flag off"} tone={s?.mode === "live" ? "down" : "muted"} /> | |
| 25 | + <Tile label="Kill switch" value={risk.data ? (risk.data.kill_switch.active ? "ACTIVE" : "armed") : "…"} sub={risk.data?.kill_switch.reason ?? "blocks all new orders when active"} tone={risk.data?.kill_switch.active ? "down" : "up"} /> | |
| 26 | + </div> | |
| 27 | + | |
| 28 | + <div className="mt-4 grid gap-4 xl:grid-cols-3"> | |
| 29 | + <Card title="Universe" subtitle="Live ticker from the Kraken WebSocket; realized vol from stored hourly bars." className="xl:col-span-2"> | |
| 30 | + {ov.isLoading && <Spinner />} | |
| 31 | + {ov.error && <ErrorBox error={ov.error} />} | |
| 32 | + {ov.data && ( | |
| 33 | + <Table> | |
| 34 | + <thead> | |
| 35 | + <tr> | |
| 36 | + <Th>Symbol</Th><Th align="right">Last</Th><Th align="right">24h</Th><Th align="right">Bid / Ask</Th><Th align="right">Spread</Th><Th align="right">24h volume</Th><Th align="right">RV 7d (ann.)</Th><Th align="right">1h bars</Th><Th>Data</Th> | |
| 37 | + </tr> | |
| 38 | + </thead> | |
| 39 | + <tbody> | |
| 40 | + {ov.data.symbols.map((r) => ( | |
| 41 | + <tr key={r.symbol} className="hover:bg-bg-sub"> | |
| 42 | + <Td><Link href={`/market?symbol=${encodeURIComponent(r.symbol)}`} className="font-medium text-accent hover:underline">{r.symbol}</Link></Td> | |
| 43 | + <Td align="right" num>{fmtPrice(r.last)}</Td> | |
| 44 | + <Td align="right" num className={signClass(r.change_pct)}>{fmtPct(r.change_pct, 2, true)}</Td> | |
| 45 | + <Td align="right" num>{fmtPrice(r.bid)} / {fmtPrice(r.ask)}</Td> | |
| 46 | + <Td align="right" num>{fmtBps(r.spread_bps, 2)}</Td> | |
| 47 | + <Td align="right" num>{fmtNum(r.volume, 0)}</Td> | |
| 48 | + <Td align="right" num>{fmtPctPlain(r.rv_7d_annualized)}</Td> | |
| 49 | + <Td align="right" num>{r.hourly_bars}</Td> | |
| 50 | + <Td>{r.stale ? <Badge tone="warn">stale {fmtAge(r.age_s)}</Badge> : <Badge tone="up">live {fmtAge(r.age_s)}</Badge>}</Td> | |
| 51 | + </tr> | |
| 52 | + ))} | |
| 53 | + </tbody> | |
| 54 | + </Table> | |
| 55 | + )} | |
| 56 | + </Card> | |
| 57 | + <div className="flex flex-col gap-4"> | |
| 58 | + <Card title="Exchange connectivity"> | |
| 59 | + {sys.isLoading && <Spinner />} | |
| 60 | + {sys.error && <ErrorBox error={sys.error} />} | |
| 61 | + {s && ( | |
| 62 | + <KV items={[ | |
| 63 | + ["Kraken status", <span key="k" className="flex items-center gap-1.5"><Dot ok={s.kraken.status === "online"} /> {s.kraken.status}{s.kraken.error ? ` — ${s.kraken.error}` : ""}</span>], | |
| 64 | + ["REST round-trip", s.kraken.rtt_ms != null ? `${s.kraken.rtt_ms} ms` : "—"], | |
| 65 | + ["Clock drift", s.kraken.clock_drift_s != null ? `${s.kraken.clock_drift_s.toFixed(2)} s` : "—"], | |
| 66 | + ["WebSocket", s.stream ? <span key="w" className="flex items-center gap-1.5"><Dot ok={s.stream.connected} /> {s.stream.connected ? "connected" : "disconnected"} · {s.stream.messages.toLocaleString()} msgs · {s.stream.reconnects} reconnects · {s.stream.checksum_failures} checksum fails</span> : "worker not reporting"], | |
| 67 | + ["Last message", s.stream?.last_msg_age != null ? `${s.stream.last_msg_age}s ago` : "—"], | |
| 68 | + ["Data key", s.credentials.data_key ? "configured" : "not configured"], | |
| 69 | + ["Trade key", s.credentials.trade_key ? "configured" : "not configured"], | |
| 70 | + ]} /> | |
| 71 | + )} | |
| 72 | + </Card> | |
| 73 | + <Card title="Active strategies" subtitle="Paper/live strategy runners arrive in Phase 4."> | |
| 74 | + <Empty title="No strategies running">Backtests run on demand from the Strategy Lab; there is no scheduled execution yet.</Empty> | |
| 75 | + </Card> | |
| 76 | + <Card title="Risk limits"> | |
| 77 | + {risk.data && ( | |
| 78 | + <KV items={[ | |
| 79 | + ["Max order notional", fmtUsd(risk.data.limits.max_order_notional, 0)], | |
| 80 | + ["Max position notional", fmtUsd(risk.data.limits.max_position_notional, 0)], | |
| 81 | + ["Max gross exposure", `${risk.data.limits.max_gross_exposure}×`], | |
| 82 | + ["Daily loss limit", fmtPctPlain(risk.data.limits.daily_loss_limit)], | |
| 83 | + ["Max drawdown", fmtPctPlain(risk.data.limits.max_drawdown_limit)], | |
| 84 | + ["Stale data cutoff", `${risk.data.limits.stale_data_seconds}s`], | |
| 85 | + ]} /> | |
| 86 | + )} | |
| 87 | + <Link href="/risk" className="mt-3 inline-block text-xs text-accent hover:underline">Open Risk Center →</Link> | |
| 88 | + </Card> | |
| 89 | + </div> | |
| 90 | + </div> | |
| 91 | + <Card title="Recent backtests" className="mt-4" actions={<Link href="/backtests" className="text-xs text-accent hover:underline">All runs →</Link>}> | |
| 92 | + {runs.data?.items.length ? ( | |
| 93 | + <Table> | |
| 94 | + <thead><tr><Th>Status</Th><Th>Strategy</Th><Th>Symbol</Th><Th align="right">Interval</Th><Th align="right">Sharpe</Th><Th align="right">Return</Th><Th align="right">Max DD</Th></tr></thead> | |
| 95 | + <tbody> | |
| 96 | + {runs.data.items.map((r) => ( | |
| 97 | + <tr key={r.id} className="hover:bg-bg-sub"> | |
| 98 | + <Td><Link href={`/backtests/${r.id}`} className="text-accent hover:underline"><StatusBadge status={r.status} /></Link></Td> | |
| 99 | + <Td>{r.strategy}</Td><Td>{r.symbol}</Td><Td align="right" num>{r.interval_min}m</Td> | |
| 100 | + <Td align="right" num>{fmtNum(r.sharpe)}</Td><Td align="right" num className={signClass(r.total_return)}>{fmtPct(r.total_return)}</Td><Td align="right" num className="text-down">{fmtPct(r.max_drawdown)}</Td> | |
| 101 | + </tr> | |
| 102 | + ))} | |
| 103 | + </tbody> | |
| 104 | + </Table> | |
| 105 | + ) : <Empty title="No backtests yet">Launch one from the Strategy Lab.</Empty>} | |
| 106 | + </Card> | |
| 107 | + </> | |
| 108 | + ); | |
| 109 | +} | |
| 110 | + | |
| 111 | +export function StatusBadge({ status }: { status: string }) { | |
| 112 | + const tone = status === "done" ? "up" : status === "failed" ? "down" : status === "running" ? "accent" : "muted"; | |
| 113 | + return <Badge tone={tone}>{status}</Badge>; | |
| 114 | +} | |
added
apps/web/src/app/(app)/paper/page.tsx
+24 −0
@@ -0,0 +1,24 @@ | ||
| 1 | +import { Card, Empty, PageHeader, Tile } from "@/components/ui"; | |
| 2 | + | |
| 3 | +export default function PaperPage() { | |
| 4 | + return ( | |
| 5 | + <> | |
| 6 | + <PageHeader title="Paper Trading" subtitle="Phase 4: strategies run against live Kraken data with a simulated, reconciled ledger. The schema (paper_orders, paper_fills, paper_positions, paper_equity) and the risk gateway already exist." /> | |
| 7 | + <div className="grid grid-cols-2 gap-3 md:grid-cols-4"> | |
| 8 | + <Tile label="Virtual equity" value="—" sub="paper engine not started" tone="muted" /> | |
| 9 | + <Tile label="Open orders" value="—" sub="paper engine not started" tone="muted" /> | |
| 10 | + <Tile label="Simulated P&L" value="—" sub="fees: taker/maker from TradeVolume" tone="muted" /> | |
| 11 | + <Tile label="Strategy state" value="—" sub="no runner scheduled" tone="muted" /> | |
| 12 | + </div> | |
| 13 | + <Card title="What the paper engine will do" className="mt-4"> | |
| 14 | + <ul className="list-disc space-y-1 pl-5 text-xs text-fg-muted"> | |
| 15 | + <li>Run strategy plugins on closed bars as they arrive; targets execute at the next bar with the live spread and a queue-aware fill model.</li> | |
| 16 | + <li>Every order passes the independent risk gateway (same code as backtests); rejected intents are audited.</li> | |
| 17 | + <li>Ledger reconciliation against the simulated exchange state on every fill; mismatches trip the circuit breaker.</li> | |
| 18 | + <li>Real-time positions, orders, fills and equity curve on this page via the SSE relay.</li> | |
| 19 | + </ul> | |
| 20 | + </Card> | |
| 21 | + <Card title="Positions" className="mt-4"><Empty title="No paper positions">Nothing has traded; the paper runner is not implemented yet.</Empty></Card> | |
| 22 | + </> | |
| 23 | + ); | |
| 24 | +} | |
added
apps/web/src/app/(app)/portfolio/page.tsx
+23 −0
@@ -0,0 +1,23 @@ | ||
| 1 | +import { Card, Empty, PageHeader } from "@/components/ui"; | |
| 2 | + | |
| 3 | +export default function PortfolioPage() { | |
| 4 | + return ( | |
| 5 | + <> | |
| 6 | + <PageHeader title="Portfolio Optimizer" subtitle="Ships in Phase 3. The research engine and data layer it builds on are already in place." /> | |
| 7 | + <div className="grid gap-4 md:grid-cols-2"> | |
| 8 | + <Card title="Planned scope"> | |
| 9 | + <ul className="list-disc space-y-1 pl-5 text-xs text-fg-muted"> | |
| 10 | + <li>Covariance estimation on stored bars: sample, Ledoit–Wolf shrinkage, EWMA.</li> | |
| 11 | + <li>Allocators: volatility targeting, inverse-volatility, hierarchical risk parity, mean–variance with shrinkage, CVaR minimisation (cvxpy).</li> | |
| 12 | + <li>Constraints: long-only (spot), per-asset caps, minimum notional per leg, turnover penalty, cash buffer.</li> | |
| 13 | + <li>Rebalance simulation through the same event-driven backtester and cost model as single-strategy runs.</li> | |
| 14 | + <li>Stress scenarios: correlated drawdowns, liquidity collapse, spread widening.</li> | |
| 15 | + </ul> | |
| 16 | + </Card> | |
| 17 | + <Card title="Inputs it will take"> | |
| 18 | + <Empty title="No optimizer runs yet">Universe (symbols from the ingestion universe), interval, estimation window, allocator, constraints, rebalance frequency, cost model.</Empty> | |
| 19 | + </Card> | |
| 20 | + </div> | |
| 21 | + </> | |
| 22 | + ); | |
| 23 | +} | |
added
apps/web/src/app/(app)/reports/[id]/page.tsx
+53 −0
@@ -0,0 +1,53 @@ | ||
| 1 | +"use client"; | |
| 2 | +import { useQuery } from "@tanstack/react-query"; | |
| 3 | +import { Printer } from "lucide-react"; | |
| 4 | +import { useParams } from "next/navigation"; | |
| 5 | +import { BenchmarkTable, EquityVsBenchmarks, MetricsGrid, Provenance, StressTable, Underwater, WalkForward } from "@/components/run-views"; | |
| 6 | +import { Badge, Button, Card, ErrorBox, KV, PageHeader, Spinner } from "@/components/ui"; | |
| 7 | +import { api } from "@/lib/api"; | |
| 8 | +import { fmtTs } from "@/lib/format"; | |
| 9 | +import type { Run, StrategyMeta } from "@/lib/types"; | |
| 10 | + | |
| 11 | +export default function ReportPage() { | |
| 12 | + const { id } = useParams<{ id: string }>(); | |
| 13 | + const run = useQuery({ queryKey: ["run", id], queryFn: () => api<Run>(`/api/backtests/${id}`) }); | |
| 14 | + const strategies = useQuery({ queryKey: ["strategies"], queryFn: () => api<{ items: StrategyMeta[] }>("/api/strategies") }); | |
| 15 | + if (run.isLoading) return <Spinner />; | |
| 16 | + if (run.error) return <ErrorBox error={run.error} />; | |
| 17 | + const r = run.data!; | |
| 18 | + const meta = strategies.data?.items.find((s) => s.name === r.strategy); | |
| 19 | + const a = r.artifacts, m = r.metrics, p = r.provenance ?? {}; | |
| 20 | + if (r.status !== "done" || !a || !m) return <ErrorBox error={`Run is ${r.status}${r.error ? `: ${r.error}` : ""}`} />; | |
| 21 | + const w = p.window as [number, number] | undefined; | |
| 22 | + return ( | |
| 23 | + <article className="mx-auto max-w-5xl"> | |
| 24 | + <PageHeader title={`Research report — ${meta?.label ?? r.strategy}`} subtitle={<span>{r.symbol} · {r.interval_min}-minute bars · generated {new Date().toISOString().slice(0, 10)} · run {r.id}</span>} actions={<Button className="no-print" onClick={() => window.print()}><Printer className="h-3.5 w-3.5" /> Print / PDF</Button>} /> | |
| 25 | + <div className="mb-4 flex flex-wrap gap-2">{meta?.experimental && <Badge tone="warn">experimental — not validated for live trading</Badge>}<Badge tone="muted">{meta?.family}</Badge>{meta?.allows_short ? <Badge tone="muted">long/short</Badge> : <Badge tone="muted">long-only spot</Badge>}</div> | |
| 26 | + <div className="flex flex-col gap-4"> | |
| 27 | + <Card title="1. Economic hypothesis"><p className="text-sm text-fg-muted">{meta?.hypothesis ?? "—"}</p>{meta?.references?.length ? <p className="mt-2 text-xs text-fg-dim">References: {meta.references.join("; ")}</p> : null}</Card> | |
| 28 | + <Card title="2. Universe and data coverage"> | |
| 29 | + <KV items={[["Universe", `${r.symbol} (${meta?.universe_note ?? "Kraken spot"})`], ["Interval", `${r.interval_min} min`], ["Window", w ? `${fmtTs(w[0])} → ${fmtTs(w[1])}` : "—"], ["Closed bars", String(p.n_bars ?? "—")], ["Source", String(p.data_source ?? "—")], ["Dataset hash", String(p.dataset_hash ?? "—")], ["Holdout", `${Math.round(Number(p.holdout_frac ?? 0) * 100)}% reserved out-of-sample`]]} /> | |
| 30 | + </Card> | |
| 31 | + <Card title="3. Parameters and cost assumptions"><KV items={[["Parameters", JSON.stringify(p.params ?? r.params)], ["Cost model", JSON.stringify(p.cost_model ?? r.cost_model)], ["Initial cash", String(p.initial_cash ?? "—")], ["Execution", String(p.evaluation_note ?? "—")]]} /></Card> | |
| 32 | + <Card title="4. In-sample performance"><MetricsGrid m={m} /><div className="mt-4"><EquityVsBenchmarks a={a} label={r.strategy} /></div><div className="mt-4"><BenchmarkTable a={a} m={m} /></div><div className="mt-4"><Underwater a={a} /></div></Card> | |
| 33 | + <Card title="5. Out-of-sample evidence"> | |
| 34 | + {a.holdout ? <><p className="mb-2 text-xs text-fg-muted">Holdout from {fmtTs(a.holdout.start_ts)} — never used for parameter selection.</p><MetricsGrid m={a.holdout.metrics} compact /></> : <p className="text-xs text-fg-muted">No holdout window was reserved for this run.</p>} | |
| 35 | + <div className="mt-4"><WalkForward wf={a.walk_forward} /></div> | |
| 36 | + </Card> | |
| 37 | + <Card title="6. Uncertainty and multiple-testing control"> | |
| 38 | + <KV items={[["Sharpe 95% CI (block bootstrap)", m.bootstrap?.sharpe_ci ? `[${m.bootstrap.sharpe_ci[0]?.toFixed(2)}, ${m.bootstrap.sharpe_ci[1]?.toFixed(2)}] (${m.bootstrap.n_boot} resamples, block ${m.bootstrap.block})` : "—"], ["CAGR 95% CI", m.bootstrap?.cagr_ci ? `[${((m.bootstrap.cagr_ci[0] ?? 0) * 100).toFixed(1)}%, ${((m.bootstrap.cagr_ci[1] ?? 0) * 100).toFixed(1)}%]` : "—"], ["Deflated Sharpe", m.deflated_sharpe?.dsr != null ? `${(m.deflated_sharpe.dsr * 100).toFixed(1)}% probability over ${m.deflated_sharpe.n_trials} trials` : "—"], ["Shuffled trades", a.shuffled_trades?.n ? `median max DD ${a.shuffled_trades.max_dd_p50?.toFixed(2)}, worst ${a.shuffled_trades.max_dd_worst?.toFixed(2)} over ${a.shuffled_trades.n} permutations` : "n/a (< 5 round trips)"]]} /> | |
| 39 | + </Card> | |
| 40 | + <Card title="7. Implementation costs and stress"><StressTable stress={a.stress} base={m} /></Card> | |
| 41 | + <Card title="8. Failure modes and limitations"> | |
| 42 | + <ul className="list-disc space-y-1 pl-5 text-xs text-fg-muted"> | |
| 43 | + {(meta?.failure_modes ?? []).map((f) => <li key={f}>{f}</li>)} | |
| 44 | + <li>History is accumulated from Kraken REST (≤720 bars per poll); coverage is limited to what has been collected since deployment unless a licensed dataset is loaded.</li> | |
| 45 | + <li>Fills are simulated: no real queue priority, and partial fills are approximated by a participation cap.</li> | |
| 46 | + <li>Results are not a claim of future profitability; experimental strategies must pass out-of-sample and paper-trading validation before live use.</li> | |
| 47 | + </ul> | |
| 48 | + </Card> | |
| 49 | + <Card title="9. Provenance"><Provenance run={r} /></Card> | |
| 50 | + </div> | |
| 51 | + </article> | |
| 52 | + ); | |
| 53 | +} | |
added
apps/web/src/app/(app)/reports/page.tsx
+33 −0
@@ -0,0 +1,33 @@ | ||
| 1 | +"use client"; | |
| 2 | +import { useQuery } from "@tanstack/react-query"; | |
| 3 | +import Link from "next/link"; | |
| 4 | +import { Card, Empty, ErrorBox, PageHeader, Spinner, Table, Td, Th } from "@/components/ui"; | |
| 5 | +import { api } from "@/lib/api"; | |
| 6 | +import { fmtIso, fmtNum, fmtPct, signClass } from "@/lib/format"; | |
| 7 | +import type { RunRow } from "@/lib/types"; | |
| 8 | + | |
| 9 | +export default function ReportsPage() { | |
| 10 | + const runs = useQuery({ queryKey: ["runs", 200], queryFn: () => api<{ items: RunRow[] }>("/api/backtests?limit=200") }); | |
| 11 | + const done = runs.data?.items.filter((r) => r.status === "done") ?? []; | |
| 12 | + return ( | |
| 13 | + <> | |
| 14 | + <PageHeader title="Research Reports" subtitle="One report per backtest run: hypothesis, data coverage, parameters, in-sample and out-of-sample results, uncertainty, costs and failure modes. Machine-readable JSON and CSV artifacts alongside." /> | |
| 15 | + <Card title="Completed runs"> | |
| 16 | + {runs.isLoading && <Spinner />} | |
| 17 | + {runs.error && <ErrorBox error={runs.error} />} | |
| 18 | + {runs.data && (done.length ? ( | |
| 19 | + <Table> | |
| 20 | + <thead><tr><Th>Strategy</Th><Th>Symbol</Th><Th align="right">Sharpe</Th><Th align="right">Return</Th><Th>Label</Th><Th>Created</Th><Th>Artifacts</Th></tr></thead> | |
| 21 | + <tbody>{done.map((r) => ( | |
| 22 | + <tr key={r.id} className="hover:bg-bg-sub"> | |
| 23 | + <Td><Link href={`/reports/${r.id}`} className="font-medium text-accent hover:underline">{r.strategy}</Link></Td><Td>{r.symbol} {r.interval_min}m</Td> | |
| 24 | + <Td align="right" num>{fmtNum(r.sharpe)}</Td><Td align="right" num className={signClass(r.total_return)}>{fmtPct(r.total_return)}</Td><Td className="text-fg-muted">{r.label ?? ""}</Td><Td num className="text-fg-dim">{fmtIso(r.created_at)}</Td> | |
| 25 | + <Td><span className="flex gap-2 text-xs"><Link href={`/reports/${r.id}`} className="text-accent hover:underline">HTML</Link><a href={`/api/backtests/${r.id}`} download={`backtest-${r.id}.json`} className="text-accent hover:underline">JSON</a><a href={`/api/backtests/${r.id}/trades.csv`} download className="text-accent hover:underline">CSV</a></span></Td> | |
| 26 | + </tr> | |
| 27 | + ))}</tbody> | |
| 28 | + </Table> | |
| 29 | + ) : <Empty title="No completed runs">Reports are generated from finished backtests.</Empty>)} | |
| 30 | + </Card> | |
| 31 | + </> | |
| 32 | + ); | |
| 33 | +} | |
added
apps/web/src/app/(app)/risk/page.tsx
+121 −0
@@ -0,0 +1,121 @@ | ||
| 1 | +"use client"; | |
| 2 | +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; | |
| 3 | +import { useState } from "react"; | |
| 4 | +import { Badge, Button, Card, Dialog, Empty, ErrorBox, Field, Input, PageHeader, Select, Spinner, Table, Td, Th } from "@/components/ui"; | |
| 5 | +import { api, ApiError } from "@/lib/api"; | |
| 6 | +import { fmtIso } from "@/lib/format"; | |
| 7 | +import type { AuditItem, RiskLimits, RiskStatus } from "@/lib/types"; | |
| 8 | + | |
| 9 | +const LIMIT_FIELDS: { key: keyof RiskLimits; label: string; hint: string; step?: number }[] = [ | |
| 10 | + { key: "max_order_notional", label: "Max order notional (USD)", hint: "Quote notional per order", step: 100 }, | |
| 11 | + { key: "max_position_notional", label: "Max position notional (USD)", hint: "Per-symbol absolute notional", step: 100 }, | |
| 12 | + { key: "max_gross_exposure", label: "Max gross exposure (×equity)", hint: "Σ|position notional| / equity", step: 0.05 }, | |
| 13 | + { key: "max_net_exposure", label: "Max net exposure (×equity)", hint: "Net long minus short / equity", step: 0.05 }, | |
| 14 | + { key: "max_concentration", label: "Max concentration", hint: "Largest |position| / equity", step: 0.05 }, | |
| 15 | + { key: "daily_loss_limit", label: "Daily loss limit", hint: "Fraction of start-of-day equity", step: 0.005 }, | |
| 16 | + { key: "max_drawdown_limit", label: "Max drawdown limit", hint: "Fraction from rolling peak equity", step: 0.01 }, | |
| 17 | + { key: "min_cash", label: "Minimum cash (USD)", hint: "Must remain after a buy", step: 10 }, | |
| 18 | + { key: "max_open_orders", label: "Max open orders", hint: "Across all symbols", step: 1 }, | |
| 19 | + { key: "max_participation", label: "Max participation", hint: "Order qty / recent bar volume", step: 0.01 }, | |
| 20 | + { key: "max_price_deviation", label: "Max price deviation", hint: "|limit − last| / last", step: 0.005 }, | |
| 21 | + { key: "stale_data_seconds", label: "Stale data cutoff (s)", hint: "Orders are refused when market data is older", step: 5 }, | |
| 22 | + { key: "max_clock_drift_seconds", label: "Max clock drift (s)", hint: "Local vs exchange time", step: 0.5 }, | |
| 23 | + { key: "var_limit_95", label: "VaR 95% limit (optional)", hint: "1-bar VaR as fraction of equity; blank = off", step: 0.005 }, | |
| 24 | +]; | |
| 25 | + | |
| 26 | +export default function RiskPage() { | |
| 27 | + const qc = useQueryClient(); | |
| 28 | + const status = useQuery({ queryKey: ["risk-status"], queryFn: () => api<RiskStatus>("/api/risk/status"), refetchInterval: 10_000 }); | |
| 29 | + const audit = useQuery({ queryKey: ["audit"], queryFn: () => api<{ items: AuditItem[]; chain_valid: boolean; chain_length: number }>("/api/audit?limit=100"), refetchInterval: 15_000 }); | |
| 30 | + const [formState, setForm] = useState<RiskLimits | null>(null); | |
| 31 | + const form = formState ?? status.data?.limits ?? null; | |
| 32 | + const save = useMutation({ mutationFn: (b: RiskLimits) => api<RiskLimits>("/api/risk/limits", { method: "PUT", json: b }), onSuccess: () => { qc.invalidateQueries({ queryKey: ["risk-status"] }); qc.invalidateQueries({ queryKey: ["audit"] }); } }); | |
| 33 | + const [ksOpen, setKsOpen] = useState(false); | |
| 34 | + const [ksReason, setKsReason] = useState(""); | |
| 35 | + const kill = useMutation({ mutationFn: (active: boolean) => api("/api/risk/kill-switch", { method: "POST", json: { active, reason: ksReason || null } }), onSuccess: () => { setKsOpen(false); setKsReason(""); qc.invalidateQueries({ queryKey: ["risk-status"] }); qc.invalidateQueries({ queryKey: ["audit"] }); } }); | |
| 36 | + const ksActive = status.data?.kill_switch.active ?? false; | |
| 37 | + | |
| 38 | + // what-if evaluator | |
| 39 | + const [wi, setWi] = useState({ symbol: "BTC/USD", side: "buy", qty: 0.01, order_type: "market", limit_price: "", equity: 10000, cash: 10000, position: 0, last_price: 80000, age: 1, recent_vol: "", ordermin: 0.0001, costmin: 0.5 }); | |
| 40 | + const evalQ = useMutation({ | |
| 41 | + mutationFn: () => api<{ allowed: boolean; reasons: string[]; checks: Record<string, boolean> }>("/api/risk/evaluate", { method: "POST", json: { | |
| 42 | + intent: { symbol: wi.symbol, side: wi.side, qty: Number(wi.qty), order_type: wi.order_type, limit_price: wi.limit_price === "" ? null : Number(wi.limit_price) }, | |
| 43 | + portfolio: { equity: Number(wi.equity), cash: Number(wi.cash), positions: Number(wi.position) ? { [wi.symbol]: Number(wi.position) } : {}, marks: { [wi.symbol]: Number(wi.last_price) }, open_orders: 0, day_start_equity: Number(wi.equity), peak_equity: Number(wi.equity), reconciled: true }, | |
| 44 | + market: { last_price: Number(wi.last_price), data_age_seconds: Number(wi.age), recent_bar_volume: wi.recent_vol === "" ? null : Number(wi.recent_vol), ordermin: Number(wi.ordermin), costmin: Number(wi.costmin) }, | |
| 45 | + } }), | |
| 46 | + }); | |
| 47 | + | |
| 48 | + return ( | |
| 49 | + <> | |
| 50 | + <PageHeader title="Risk Center" subtitle="Independent gateway every order must pass (backtest, paper and live). Default-deny on stale data, reconciliation failure, kill switch or internal error." actions={status.data && (ksActive ? <Badge tone="down">KILL SWITCH ACTIVE</Badge> : <Badge tone="up">kill switch armed</Badge>)} /> | |
| 51 | + <div className="grid gap-4 xl:grid-cols-3"> | |
| 52 | + <Card title="Kill switch" subtitle="Blocks every new order immediately. Phase 4+ also cancels eligible open orders; during an exchange outage cancellations cannot be guaranteed." className="xl:col-span-1"> | |
| 53 | + {status.isLoading && <Spinner />} | |
| 54 | + {status.data && ( | |
| 55 | + <div className="flex flex-col gap-3 text-xs"> | |
| 56 | + <div>Status: {ksActive ? <span className="font-semibold text-down">ACTIVE</span> : <span className="text-up">armed (inactive)</span>}</div> | |
| 57 | + {status.data.kill_switch.reason && <div className="text-fg-muted">Reason: {status.data.kill_switch.reason}</div>} | |
| 58 | + {status.data.kill_switch.since && <div className="text-fg-dim">Since {fmtIso(new Date(status.data.kill_switch.since * 1000).toISOString())}</div>} | |
| 59 | + <Button variant={ksActive ? "outline" : "danger"} onClick={() => setKsOpen(true)}>{ksActive ? "Disarm kill switch" : "Activate kill switch"}</Button> | |
| 60 | + </div> | |
| 61 | + )} | |
| 62 | + <Dialog open={ksOpen} onClose={() => setKsOpen(false)} title={ksActive ? "Disarm kill switch?" : "Activate kill switch?"} footer={<><Button onClick={() => setKsOpen(false)}>Cancel</Button><Button variant={ksActive ? "primary" : "danger"} loading={kill.isPending} onClick={() => kill.mutate(!ksActive)}>Confirm</Button></>}> | |
| 63 | + <p className="text-xs text-fg-muted">{ksActive ? "New orders will be allowed again, subject to all other limits." : "All new orders from every engine will be rejected until disarmed. This is recorded in the immutable audit log."}</p> | |
| 64 | + <Field label="Reason (recorded in audit)"><Input value={ksReason} onChange={(e) => setKsReason(e.target.value)} placeholder="e.g. anomalous prices on BTC/USD" /></Field> | |
| 65 | + {kill.error && <ErrorBox error={kill.error} />} | |
| 66 | + </Dialog> | |
| 67 | + </Card> | |
| 68 | + <Card title="Risk limits" subtitle="Applied to paper and live orders; backtests may override per run." className="xl:col-span-2" actions={<Button variant="primary" size="sm" loading={save.isPending} disabled={!form} onClick={() => form && save.mutate(form)}>Save limits</Button>}> | |
| 69 | + {form ? ( | |
| 70 | + <form onSubmit={(e) => { e.preventDefault(); save.mutate(form); }} className="grid grid-cols-2 gap-3 md:grid-cols-3 xl:grid-cols-4"> | |
| 71 | + {LIMIT_FIELDS.map((f) => ( | |
| 72 | + <Field key={f.key} label={f.label} hint={f.hint}> | |
| 73 | + <Input type="number" step={f.step} value={form[f.key] ?? ""} onChange={(e) => setForm({ ...form, [f.key]: e.target.value === "" ? (f.key === "var_limit_95" ? null : 0) : Number(e.target.value) })} /> | |
| 74 | + </Field> | |
| 75 | + ))} | |
| 76 | + {save.error && <div className="col-span-full"><ErrorBox error={save.error} /></div>} | |
| 77 | + {save.isSuccess && <p className="col-span-full text-xs text-up">Limits saved and audited.</p>} | |
| 78 | + </form> | |
| 79 | + ) : <Spinner />} | |
| 80 | + </Card> | |
| 81 | + </div> | |
| 82 | + <div className="mt-4 grid gap-4 xl:grid-cols-2"> | |
| 83 | + <Card title="What-if evaluator" subtitle="Dry-run the gateway with a hypothetical order; nothing is placed."> | |
| 84 | + <form onSubmit={(e) => { e.preventDefault(); evalQ.mutate(); }} className="grid grid-cols-2 gap-3 md:grid-cols-4"> | |
| 85 | + <Field label="Symbol"><Input value={wi.symbol} onChange={(e) => setWi({ ...wi, symbol: e.target.value })} /></Field> | |
| 86 | + <Field label="Side"><Select value={wi.side} onChange={(e) => setWi({ ...wi, side: e.target.value })}><option>buy</option><option>sell</option></Select></Field> | |
| 87 | + <Field label="Qty (base)"><Input type="number" step="any" value={wi.qty} onChange={(e) => setWi({ ...wi, qty: Number(e.target.value) })} /></Field> | |
| 88 | + <Field label="Order type"><Select value={wi.order_type} onChange={(e) => setWi({ ...wi, order_type: e.target.value })}><option>market</option><option>limit</option></Select></Field> | |
| 89 | + <Field label="Limit price"><Input type="number" step="any" value={wi.limit_price} onChange={(e) => setWi({ ...wi, limit_price: e.target.value })} placeholder="market" /></Field> | |
| 90 | + <Field label="Last price"><Input type="number" step="any" value={wi.last_price} onChange={(e) => setWi({ ...wi, last_price: Number(e.target.value) })} /></Field> | |
| 91 | + <Field label="Equity"><Input type="number" step="any" value={wi.equity} onChange={(e) => setWi({ ...wi, equity: Number(e.target.value) })} /></Field> | |
| 92 | + <Field label="Cash"><Input type="number" step="any" value={wi.cash} onChange={(e) => setWi({ ...wi, cash: Number(e.target.value) })} /></Field> | |
| 93 | + <Field label="Existing position"><Input type="number" step="any" value={wi.position} onChange={(e) => setWi({ ...wi, position: Number(e.target.value) })} /></Field> | |
| 94 | + <Field label="Data age (s)"><Input type="number" step="any" value={wi.age} onChange={(e) => setWi({ ...wi, age: Number(e.target.value) })} /></Field> | |
| 95 | + <Field label="Recent bar volume"><Input type="number" step="any" value={wi.recent_vol} onChange={(e) => setWi({ ...wi, recent_vol: e.target.value })} placeholder="unknown" /></Field> | |
| 96 | + <Field label="ordermin / costmin"><div className="flex gap-1"><Input type="number" step="any" value={wi.ordermin} onChange={(e) => setWi({ ...wi, ordermin: Number(e.target.value) })} /><Input type="number" step="any" value={wi.costmin} onChange={(e) => setWi({ ...wi, costmin: Number(e.target.value) })} /></div></Field> | |
| 97 | + <div className="col-span-full"><Button type="submit" loading={evalQ.isPending}>Evaluate</Button></div> | |
| 98 | + </form> | |
| 99 | + {evalQ.error && <ErrorBox error={evalQ.error instanceof ApiError ? evalQ.error : String(evalQ.error)} />} | |
| 100 | + {evalQ.data && ( | |
| 101 | + <div className="mt-3 rounded-md border border-line bg-bg p-3 text-xs"> | |
| 102 | + <div className="mb-2">{evalQ.data.allowed ? <Badge tone="up">ALLOWED</Badge> : <Badge tone="down">REJECTED</Badge>}{evalQ.data.reasons.length > 0 && <span className="ml-2 text-down">{evalQ.data.reasons.join("; ")}</span>}</div> | |
| 103 | + <ul className="grid grid-cols-2 gap-x-4 gap-y-0.5 md:grid-cols-3">{Object.entries(evalQ.data.checks).map(([k, ok]) => <li key={k} className={ok ? "text-fg-muted" : "font-semibold text-down"}>{ok ? "✓" : "✗"} {k}</li>)}</ul> | |
| 104 | + </div> | |
| 105 | + )} | |
| 106 | + </Card> | |
| 107 | + <Card title="Recent risk events" subtitle="Limit changes, kill-switch flips and breaches from the audit trail."> | |
| 108 | + {status.data?.recent_events.length ? ( | |
| 109 | + <Table><thead><tr><Th>Time</Th><Th>Action</Th><Th>Details</Th></tr></thead><tbody>{status.data.recent_events.map((e, i) => <tr key={i}><Td num className="text-fg-dim">{fmtIso(e.ts)}</Td><Td>{e.action}</Td><Td className="max-w-md truncate font-mono text-[11px] text-fg-muted" title={JSON.stringify(e.details)}>{JSON.stringify(e.details)}</Td></tr>)}</tbody></Table> | |
| 110 | + ) : <Empty title="No risk events yet" />} | |
| 111 | + </Card> | |
| 112 | + </div> | |
| 113 | + <Card title="Audit history" subtitle="Append-only, hash-chained log of every sensitive action." className="mt-4" actions={audit.data && <Badge tone={audit.data.chain_valid ? "up" : "down"}>chain {audit.data.chain_valid ? "valid" : "BROKEN"} · {audit.data.chain_length} entries</Badge>}> | |
| 114 | + {audit.isLoading && <Spinner />} | |
| 115 | + {audit.data && (audit.data.items.length ? ( | |
| 116 | + <Table><thead><tr><Th>#</Th><Th>Time</Th><Th>Actor</Th><Th>Action</Th><Th>Entity</Th><Th>Details</Th><Th>Hash</Th></tr></thead><tbody>{audit.data.items.map((a) => <tr key={a.id}><Td num>{a.id}</Td><Td num className="text-fg-dim">{fmtIso(a.ts)}</Td><Td>{a.actor}</Td><Td>{a.action}</Td><Td className="font-mono text-[11px]">{a.entity ?? ""}</Td><Td className="max-w-md truncate font-mono text-[11px] text-fg-muted" title={JSON.stringify(a.details)}>{JSON.stringify(a.details)}</Td><Td className="font-mono text-[10px] text-fg-dim">{a.hash.slice(0, 12)}</Td></tr>)}</tbody></Table> | |
| 117 | + ) : <Empty title="Audit log empty" />)} | |
| 118 | + </Card> | |
| 119 | + </> | |
| 120 | + ); | |
| 121 | +} | |
added
apps/web/src/app/(app)/settings/page.tsx
+59 −0
@@ -0,0 +1,59 @@ | ||
| 1 | +"use client"; | |
| 2 | +import { useMutation, useQuery } from "@tanstack/react-query"; | |
| 3 | +import { useSystemStatus } from "@/components/shell"; | |
| 4 | +import { Badge, Button, Card, Empty, ErrorBox, KV, PageHeader, Spinner, Table, Td, Th } from "@/components/ui"; | |
| 5 | +import { api, ApiError } from "@/lib/api"; | |
| 6 | + | |
| 7 | +type Preflight = { checked_at: number; data_key: Record<string, { ok?: boolean; permission_denied?: boolean; error?: string; has_trade_permission?: boolean | null }> & { warning?: string } | null; trade_key: Record<string, unknown> | null }; | |
| 8 | + | |
| 9 | +export default function SettingsPage() { | |
| 10 | + const sys = useSystemStatus(); | |
| 11 | + const balance = useQuery({ queryKey: ["balance"], queryFn: () => api<{ balances: Record<string, string>; fetched_at: number }>("/api/account/balance"), retry: false, enabled: !!sys.data?.credentials.data_key }); | |
| 12 | + const preflight = useMutation({ mutationFn: () => api<Preflight>("/api/account/preflight", { method: "POST" }) }); | |
| 13 | + const s = sys.data; | |
| 14 | + const pf = (preflight.data ?? s?.preflight) as Preflight | null | undefined; | |
| 15 | + return ( | |
| 16 | + <> | |
| 17 | + <PageHeader title="Settings" subtitle="Credentials are injected server-side and never displayed. Only their presence and probed permissions are shown." /> | |
| 18 | + <div className="grid gap-4 md:grid-cols-2"> | |
| 19 | + <Card title="Exchange credentials" subtitle="Least privilege: data/account key must not have order permission; the trade key is only read when live mode is armed." actions={<Button size="sm" loading={preflight.isPending} onClick={() => preflight.mutate()} disabled={!s?.credentials.data_key && !s?.credentials.trade_key}>Run preflight</Button>}> | |
| 20 | + {sys.isLoading && <Spinner />} | |
| 21 | + {s && ( | |
| 22 | + <KV items={[ | |
| 23 | + ["Data / account key", s.credentials.data_key ? <Badge key="d" tone="up">configured</Badge> : <Badge key="d" tone="muted">not configured</Badge>], | |
| 24 | + ["Trade key", s.credentials.trade_key ? <Badge key="t" tone="warn">configured (live flag off)</Badge> : <Badge key="t" tone="muted">not configured</Badge>], | |
| 25 | + ["Withdrawal permission", <span key="w" className="text-fg-muted">{s.credentials.withdrawal}</span>], | |
| 26 | + ["Live trading flag", s.live_trading_enabled ? <Badge key="l" tone="down">ENABLED</Badge> : <Badge key="l" tone="up">disabled</Badge>], | |
| 27 | + ]} /> | |
| 28 | + )} | |
| 29 | + {preflight.error && <div className="mt-3"><ErrorBox error={preflight.error instanceof ApiError ? preflight.error : String(preflight.error)} /></div>} | |
| 30 | + {pf ? ( | |
| 31 | + <div className="mt-3 text-xs"> | |
| 32 | + <div className="mb-1 text-fg-dim">Preflight {pf.checked_at ? `at ${new Date(pf.checked_at * 1000).toISOString().slice(0, 19)}Z` : ""}</div> | |
| 33 | + {pf.data_key?.warning && <div role="alert" className="mb-2 rounded border border-warn/40 bg-warn/10 px-2 py-1 text-warn">{pf.data_key.warning}</div>} | |
| 34 | + {pf.data_key ? ( | |
| 35 | + <Table><thead><tr><Th>Probe (data key)</Th><Th>Result</Th></tr></thead><tbody>{Object.entries(pf.data_key).filter(([k]) => k !== "warning").map(([k, v]) => <tr key={k}><Td>{k}</Td><Td>{"has_trade_permission" in (v as object) ? ((v as { has_trade_permission?: boolean | null }).has_trade_permission ? <Badge tone="warn">has order permission</Badge> : <Badge tone="up">no order permission</Badge>) : (v as { ok?: boolean }).ok ? <Badge tone="up">ok</Badge> : <span className="text-down">{(v as { error?: string }).error}</span>}</Td></tr>)}</tbody></Table> | |
| 36 | + ) : <p className="text-fg-dim">No data key to probe.</p>} | |
| 37 | + {pf.trade_key && <pre className="mt-2 overflow-auto rounded bg-bg p-2 text-[11px]">{JSON.stringify(pf.trade_key, null, 1)}</pre>} | |
| 38 | + </div> | |
| 39 | + ) : <p className="mt-3 text-xs text-fg-dim">No preflight has been run. The trade-permission probe uses AddOrder with validate=true on an order that could never fill; it is recorded in the audit log.</p>} | |
| 40 | + </Card> | |
| 41 | + <Card title="Account balances" subtitle="Read-only Balance endpoint; non-zero assets only."> | |
| 42 | + {!s?.credentials.data_key ? <Empty title="No data key configured">Set KTRADE_KRAKEN_API_KEY / SECRET on the server to enable account reads.</Empty> : balance.isLoading ? <Spinner /> : balance.error ? <ErrorBox error={balance.error} /> : balance.data && Object.keys(balance.data.balances).length ? ( | |
| 43 | + <Table><thead><tr><Th>Asset</Th><Th align="right">Amount</Th></tr></thead><tbody>{Object.entries(balance.data.balances).map(([k, v]) => <tr key={k}><Td>{k}</Td><Td align="right" num>{v}</Td></tr>)}</tbody></Table> | |
| 44 | + ) : <Empty title="No non-zero balances" />} | |
| 45 | + </Card> | |
| 46 | + <Card title="Environment"> | |
| 47 | + {s && <KV items={[["Environment", s.env], ["Version", s.version], ["Trading mode", s.mode], ["Universe", s.universe.join(", ")], ["Candle intervals", s.intervals.map((i) => `${i}m`).join(", ")], ["TimescaleDB", s.timescale ? "enabled (hypertables)" : "not installed (plain tables with indexes)"], ["Kraken REST", `${s.kraken.status}${s.kraken.rtt_ms != null ? ` · ${s.kraken.rtt_ms} ms` : ""}`]]} />} | |
| 48 | + </Card> | |
| 49 | + <Card title="Alerts, access control, retention" subtitle="Planned for Phase 4."> | |
| 50 | + <ul className="list-disc space-y-1 pl-5 text-xs text-fg-muted"> | |
| 51 | + <li>Alert channels (e-mail / webhook) for circuit breakers, staleness and reconciliation mismatches.</li> | |
| 52 | + <li>Single-owner sessions today; role-based viewers/operators later.</li> | |
| 53 | + <li>Retention: trades 30 days, book samples 14 days (enforced by the worker); candles and audit log kept indefinitely; Postgres backups via the operator runbook.</li> | |
| 54 | + </ul> | |
| 55 | + </Card> | |
| 56 | + </div> | |
| 57 | + </> | |
| 58 | + ); | |
| 59 | +} | |
added
apps/web/src/app/favicon.ico
+0 −0
Binary file not shown.
added
apps/web/src/app/globals.css
+33 −0
@@ -0,0 +1,33 @@ | ||
| 1 | +@import "tailwindcss"; | |
| 2 | + | |
| 3 | +@theme { | |
| 4 | + --color-bg: #0b0e14; | |
| 5 | + --color-bg-elev: #111622; | |
| 6 | + --color-bg-sub: #161c2a; | |
| 7 | + --color-line: #232b3b; | |
| 8 | + --color-line-strong: #2f3a4f; | |
| 9 | + --color-fg: #e6e9ef; | |
| 10 | + --color-fg-muted: #94a0b8; | |
| 11 | + --color-fg-dim: #66718a; | |
| 12 | + --color-accent: #4f8cff; | |
| 13 | + --color-accent-soft: #1b2a4a; | |
| 14 | + --color-up: #22c55e; | |
| 15 | + --color-down: #ef4444; | |
| 16 | + --color-warn: #f59e0b; | |
| 17 | + --color-live: #dc2626; | |
| 18 | + --font-sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; | |
| 19 | + --font-mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; | |
| 20 | +} | |
| 21 | + | |
| 22 | +html, body { height: 100%; } | |
| 23 | +body { background: var(--color-bg); color: var(--color-fg); font-family: var(--font-sans); font-size: 14px; } | |
| 24 | +.num { font-family: var(--font-mono); font-variant-numeric: tabular-nums; } | |
| 25 | +.scroll-thin { scrollbar-width: thin; scrollbar-color: var(--color-line-strong) transparent; } | |
| 26 | +input[type="number"]::-webkit-inner-spin-button { opacity: 0.4; } | |
| 27 | +:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 2px; } | |
| 28 | + | |
| 29 | +@media print { | |
| 30 | + body { background: #fff; color: #111; font-size: 12px; } | |
| 31 | + .no-print { display: none !important; } | |
| 32 | + .print-card { border: 1px solid #ccc; break-inside: avoid; } | |
| 33 | +} | |
added
apps/web/src/app/layout.tsx
+18 −0
@@ -0,0 +1,18 @@ | ||
| 1 | +import type { Metadata } from "next"; | |
| 2 | +import "./globals.css"; | |
| 3 | +import { Providers } from "@/components/providers"; | |
| 4 | + | |
| 5 | +export const metadata: Metadata = { | |
| 6 | + title: "ktrade — Kraken quant terminal", | |
| 7 | + description: "Research, backtesting, paper trading and execution for Kraken spot.", | |
| 8 | +}; | |
| 9 | + | |
| 10 | +export default function RootLayout({ children }: { children: React.ReactNode }) { | |
| 11 | + return ( | |
| 12 | + <html lang="en" className="h-full antialiased"> | |
| 13 | + <body className="min-h-full flex flex-col"> | |
| 14 | + <Providers>{children}</Providers> | |
| 15 | + </body> | |
| 16 | + </html> | |
| 17 | + ); | |
| 18 | +} | |
added
apps/web/src/app/login/page.tsx
+55 −0
@@ -0,0 +1,55 @@ | ||
| 1 | +"use client"; | |
| 2 | +import { Gauge } from "lucide-react"; | |
| 3 | +import { useRouter, useSearchParams } from "next/navigation"; | |
| 4 | +import { Suspense, useState } from "react"; | |
| 5 | +import { api, ApiError } from "@/lib/api"; | |
| 6 | +import { Button, Field, Input } from "@/components/ui"; | |
| 7 | + | |
| 8 | +function LoginForm() { | |
| 9 | + const router = useRouter(); | |
| 10 | + const params = useSearchParams(); | |
| 11 | + const [pw, setPw] = useState(""); | |
| 12 | + const [err, setErr] = useState<string | null>(null); | |
| 13 | + const [busy, setBusy] = useState(false); | |
| 14 | + async function submit(e: React.FormEvent) { | |
| 15 | + e.preventDefault(); | |
| 16 | + setBusy(true); | |
| 17 | + setErr(null); | |
| 18 | + try { | |
| 19 | + await api("/api/auth/login", { method: "POST", json: { password: pw } }); | |
| 20 | + const next = params.get("next"); | |
| 21 | + router.replace(next && next.startsWith("/") ? next : "/"); | |
| 22 | + } catch (e) { | |
| 23 | + setErr(e instanceof ApiError ? e.message : "login failed"); | |
| 24 | + } finally { | |
| 25 | + setBusy(false); | |
| 26 | + } | |
| 27 | + } | |
| 28 | + return ( | |
| 29 | + <form onSubmit={submit} className="w-full max-w-sm rounded-lg border border-line bg-bg-elev p-6"> | |
| 30 | + <div className="mb-5 flex items-center gap-2"> | |
| 31 | + <Gauge className="h-5 w-5 text-accent" aria-hidden /> | |
| 32 | + <div> | |
| 33 | + <h1 className="text-base font-semibold">ktrade</h1> | |
| 34 | + <p className="text-xs text-fg-muted">Kraken quantitative trading terminal — owner access</p> | |
| 35 | + </div> | |
| 36 | + </div> | |
| 37 | + <Field label="Owner password" htmlFor="pw"> | |
| 38 | + <Input id="pw" type="password" autoComplete="current-password" value={pw} onChange={(e) => setPw(e.target.value)} required autoFocus /> | |
| 39 | + </Field> | |
| 40 | + {err && <p role="alert" className="mt-3 text-xs text-down">{err}</p>} | |
| 41 | + <Button type="submit" variant="primary" className="mt-4 w-full" loading={busy}>Sign in</Button> | |
| 42 | + <p className="mt-4 text-[11px] text-fg-dim">Sessions are HttpOnly cookies signed server-side. Live trading stays disabled regardless of login.</p> | |
| 43 | + </form> | |
| 44 | + ); | |
| 45 | +} | |
| 46 | + | |
| 47 | +export default function LoginPage() { | |
| 48 | + return ( | |
| 49 | + <div className="flex min-h-screen items-center justify-center p-4"> | |
| 50 | + <Suspense> | |
| 51 | + <LoginForm /> | |
| 52 | + </Suspense> | |
| 53 | + </div> | |
| 54 | + ); | |
| 55 | +} | |
added
apps/web/src/components/charts.tsx
+123 −0
@@ -0,0 +1,123 @@ | ||
| 1 | +"use client"; | |
| 2 | +import { AreaSeries, CandlestickSeries, ColorType, createChart, HistogramSeries, LineSeries, type IChartApi, type ISeriesApi, type LineData, type Time, type UTCTimestamp } from "lightweight-charts"; | |
| 3 | +import { useEffect, useRef } from "react"; | |
| 4 | +import type { Candle, EquityPoint } from "@/lib/types"; | |
| 5 | + | |
| 6 | +const baseOptions = (h: number) => ({ | |
| 7 | + height: h, | |
| 8 | + layout: { background: { type: ColorType.Solid, color: "transparent" }, textColor: "#94a0b8", fontSize: 11, attributionLogo: false }, | |
| 9 | + grid: { vertLines: { color: "#1a2030" }, horzLines: { color: "#1a2030" } }, | |
| 10 | + rightPriceScale: { borderColor: "#232b3b" }, | |
| 11 | + timeScale: { borderColor: "#232b3b", timeVisible: true, secondsVisible: false }, | |
| 12 | + crosshair: { mode: 0 }, | |
| 13 | +}); | |
| 14 | + | |
| 15 | +function useChart(height: number) { | |
| 16 | + const ref = useRef<HTMLDivElement>(null); | |
| 17 | + const chartRef = useRef<IChartApi | null>(null); | |
| 18 | + useEffect(() => { | |
| 19 | + const el = ref.current; | |
| 20 | + if (!el) return; | |
| 21 | + const chart = createChart(el, { ...baseOptions(height), width: el.clientWidth }); | |
| 22 | + chartRef.current = chart; | |
| 23 | + const ro = new ResizeObserver(() => chart.applyOptions({ width: el.clientWidth })); | |
| 24 | + ro.observe(el); | |
| 25 | + return () => { | |
| 26 | + ro.disconnect(); | |
| 27 | + chart.remove(); | |
| 28 | + chartRef.current = null; | |
| 29 | + }; | |
| 30 | + }, [height]); | |
| 31 | + return { ref, chartRef }; | |
| 32 | +} | |
| 33 | + | |
| 34 | +const t = (ts: number) => ts as UTCTimestamp; | |
| 35 | + | |
| 36 | +/** Candlestick + volume. `live` is a partial bar (1-min or ticker) merged in via series.update. */ | |
| 37 | +export function CandleChart({ candles, live, height = 420 }: { candles: Candle[]; live?: Candle | null; height?: number }) { | |
| 38 | + const { ref, chartRef } = useChart(height); | |
| 39 | + const cs = useRef<ISeriesApi<"Candlestick"> | null>(null); | |
| 40 | + const vs = useRef<ISeriesApi<"Histogram"> | null>(null); | |
| 41 | + useEffect(() => { | |
| 42 | + const chart = chartRef.current; | |
| 43 | + if (!chart) return; | |
| 44 | + const c = chart.addSeries(CandlestickSeries, { upColor: "#22c55e", downColor: "#ef4444", borderVisible: false, wickUpColor: "#22c55e", wickDownColor: "#ef4444" }); | |
| 45 | + const v = chart.addSeries(HistogramSeries, { priceFormat: { type: "volume" }, priceScaleId: "vol" }); | |
| 46 | + chart.priceScale("vol").applyOptions({ scaleMargins: { top: 0.82, bottom: 0 } }); | |
| 47 | + cs.current = c; | |
| 48 | + vs.current = v; | |
| 49 | + return () => { | |
| 50 | + cs.current = null; | |
| 51 | + vs.current = null; | |
| 52 | + }; | |
| 53 | + }, [chartRef]); | |
| 54 | + useEffect(() => { | |
| 55 | + if (!cs.current || !vs.current) return; | |
| 56 | + const sorted = [...candles].sort((a, b) => a.ts - b.ts); | |
| 57 | + cs.current.setData(sorted.map((c) => ({ time: t(c.ts), open: c.open, high: c.high, low: c.low, close: c.close }))); | |
| 58 | + vs.current.setData(sorted.map((c) => ({ time: t(c.ts), value: c.volume, color: c.close >= c.open ? "rgba(34,197,94,0.45)" : "rgba(239,68,68,0.45)" }))); | |
| 59 | + chartRef.current?.timeScale().fitContent(); | |
| 60 | + }, [candles, chartRef]); | |
| 61 | + useEffect(() => { | |
| 62 | + if (!live || !cs.current || !vs.current) return; | |
| 63 | + try { | |
| 64 | + cs.current.update({ time: t(live.ts), open: live.open, high: live.high, low: live.low, close: live.close }); | |
| 65 | + vs.current.update({ time: t(live.ts), value: live.volume, color: live.close >= live.open ? "rgba(34,197,94,0.45)" : "rgba(239,68,68,0.45)" }); | |
| 66 | + } catch { | |
| 67 | + /* out-of-order update (older than last bar) is ignored */ | |
| 68 | + } | |
| 69 | + }, [live]); | |
| 70 | + return <div ref={ref} className="w-full" />; | |
| 71 | +} | |
| 72 | + | |
| 73 | +export type LineSpec = { id: string; label: string; color: string; points: { ts: number; value: number }[]; width?: number }; | |
| 74 | + | |
| 75 | +/** Multi-line chart (equity curves, rolling Sharpe…). */ | |
| 76 | +export function LinesChart({ series, height = 300, percentScale = false, priceFormat }: { series: LineSpec[]; height?: number; percentScale?: boolean; priceFormat?: "percent" | "price" }) { | |
| 77 | + const { ref, chartRef } = useChart(height); | |
| 78 | + const key = series.map((s) => `${s.id}:${s.points.length}`).join("|"); | |
| 79 | + useEffect(() => { | |
| 80 | + const chart = chartRef.current; | |
| 81 | + if (!chart) return; | |
| 82 | + const created: ISeriesApi<"Line">[] = []; | |
| 83 | + for (const s of series) { | |
| 84 | + const line = chart.addSeries(LineSeries, { color: s.color, lineWidth: (s.width ?? 2) as 1 | 2 | 3 | 4, title: s.label, priceFormat: priceFormat === "percent" ? { type: "percent" } : { type: "price", precision: 2, minMove: 0.01 }, priceLineVisible: false, lastValueVisible: true }); | |
| 85 | + const data: LineData<Time>[] = [...s.points].sort((a, b) => a.ts - b.ts).filter((p) => Number.isFinite(p.value)).map((p) => ({ time: t(p.ts), value: p.value })); | |
| 86 | + line.setData(data); | |
| 87 | + created.push(line); | |
| 88 | + } | |
| 89 | + chart.applyOptions({ rightPriceScale: { mode: percentScale ? 2 : 0, borderColor: "#232b3b" } }); | |
| 90 | + chart.timeScale().fitContent(); | |
| 91 | + return () => created.forEach((l) => chart.removeSeries(l)); | |
| 92 | + // eslint-disable-next-line react-hooks/exhaustive-deps | |
| 93 | + }, [key, chartRef, percentScale, priceFormat]); | |
| 94 | + return <div ref={ref} className="w-full" />; | |
| 95 | +} | |
| 96 | + | |
| 97 | +/** Underwater (drawdown) area chart. */ | |
| 98 | +export function DrawdownChart({ points, height = 180 }: { points: EquityPoint[]; height?: number }) { | |
| 99 | + const { ref, chartRef } = useChart(height); | |
| 100 | + useEffect(() => { | |
| 101 | + const chart = chartRef.current; | |
| 102 | + if (!chart) return; | |
| 103 | + const s = chart.addSeries(AreaSeries, { lineColor: "#ef4444", topColor: "rgba(239,68,68,0.05)", bottomColor: "rgba(239,68,68,0.45)", lineWidth: 1, priceFormat: { type: "percent" }, priceLineVisible: false, invertFilledArea: true }); | |
| 104 | + s.setData([...points].sort((a, b) => a.ts - b.ts).map((p) => ({ time: t(p.ts), value: p.drawdown * 100 }))); | |
| 105 | + chart.timeScale().fitContent(); | |
| 106 | + return () => chart.removeSeries(s); | |
| 107 | + }, [points, chartRef]); | |
| 108 | + return <div ref={ref} className="w-full" />; | |
| 109 | +} | |
| 110 | + | |
| 111 | +/** Tiny inline sparkline (SVG) for microstructure panels. */ | |
| 112 | +export function Sparkline({ values, color = "#4f8cff", height = 36, width = 160 }: { values: (number | null)[]; color?: string; height?: number; width?: number }) { | |
| 113 | + const v = values.filter((x): x is number => x !== null && Number.isFinite(x)); | |
| 114 | + if (v.length < 2) return <svg width={width} height={height} aria-hidden />; | |
| 115 | + const min = Math.min(...v), max = Math.max(...v); | |
| 116 | + const span = max - min || 1; | |
| 117 | + const pts = v.map((y, i) => `${(i / (v.length - 1)) * width},${height - ((y - min) / span) * (height - 2) - 1}`).join(" "); | |
| 118 | + return ( | |
| 119 | + <svg width={width} height={height} viewBox={`0 0 ${width} ${height}`} className="overflow-visible" aria-hidden> | |
| 120 | + <polyline points={pts} fill="none" stroke={color} strokeWidth={1.25} /> | |
| 121 | + </svg> | |
| 122 | + ); | |
| 123 | +} | |
added
apps/web/src/components/providers.tsx
+13 −0
@@ -0,0 +1,13 @@ | ||
| 1 | +"use client"; | |
| 2 | +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; | |
| 3 | +import { useState } from "react"; | |
| 4 | + | |
| 5 | +export function Providers({ children }: { children: React.ReactNode }) { | |
| 6 | + const [client] = useState( | |
| 7 | + () => | |
| 8 | + new QueryClient({ | |
| 9 | + defaultOptions: { queries: { retry: 1, staleTime: 5_000, refetchOnWindowFocus: false } }, | |
| 10 | + }), | |
| 11 | + ); | |
| 12 | + return <QueryClientProvider client={client}>{children}</QueryClientProvider>; | |
| 13 | +} | |
added
apps/web/src/components/run-views.tsx
+179 −0
@@ -0,0 +1,179 @@ | ||
| 1 | +"use client"; | |
| 2 | +import { DrawdownChart, LinesChart, type LineSpec } from "@/components/charts"; | |
| 3 | +import { Badge, Card, Empty, KV, Table, Td, Th, cx } from "@/components/ui"; | |
| 4 | +import { fmtBps, fmtDate, fmtNum, fmtPct, fmtPctPlain, fmtRatio, fmtTs, fmtUsd, signClass } from "@/lib/format"; | |
| 5 | +import type { Artifacts, Metrics, Run } from "@/lib/types"; | |
| 6 | + | |
| 7 | +const BENCH_COLORS: Record<string, string> = { buy_and_hold: "#f59e0b", cash: "#66718a", simple_momentum: "#a855f7", equal_risk: "#14b8a6" }; | |
| 8 | + | |
| 9 | +export function MetricsGrid({ m, compact = false }: { m: Metrics; compact?: boolean }) { | |
| 10 | + const ci = m.bootstrap?.sharpe_ci; | |
| 11 | + const items: [string, string, string?][] = [ | |
| 12 | + ["CAGR", fmtPct(m.cagr), signClass(m.cagr)], | |
| 13 | + ["Total return", fmtPct(m.total_return), signClass(m.total_return)], | |
| 14 | + ["Volatility (ann.)", fmtPctPlain(m.volatility)], | |
| 15 | + ["Sharpe", `${fmtRatio(m.sharpe)}${ci && ci[0] != null ? ` [${fmtRatio(ci[0])}, ${fmtRatio(ci[1])}]` : ""}`], | |
| 16 | + ["Deflated Sharpe (prob.)", m.deflated_sharpe?.dsr != null ? `${fmtPctPlain(m.deflated_sharpe.dsr)} (${m.deflated_sharpe.n_trials ?? 1} trials)` : "—"], | |
| 17 | + ["Sortino", fmtRatio(m.sortino)], | |
| 18 | + ["Max drawdown", fmtPct(m.max_drawdown), "text-down"], | |
| 19 | + ["Calmar", fmtRatio(m.calmar)], | |
| 20 | + ["Turnover (ann.)", `${fmtRatio(m.turnover_annual, 1)}×`], | |
| 21 | + ["Exposure", fmtPctPlain(m.exposure)], | |
| 22 | + ["Trades / round trips", `${m.n_trades ?? "—"} / ${m.n_round_trips ?? "—"}`], | |
| 23 | + ["Hit rate", fmtPctPlain(m.hit_rate)], | |
| 24 | + ["Profit factor", fmtRatio(m.profit_factor)], | |
| 25 | + ["Avg round-trip P&L", fmtUsd(m.avg_trade_pnl)], | |
| 26 | + ["Fee drag", fmtPctPlain(m.fee_drag, 2)], | |
| 27 | + ["Slippage drag", fmtPctPlain(m.slippage_drag, 2)], | |
| 28 | + ["VaR 95% (1 bar)", fmtPct(m.var_95_bar), "text-down"], | |
| 29 | + ["CVaR 95% (1 bar)", fmtPct(m.cvar_95_bar), "text-down"], | |
| 30 | + ["Underwater time", fmtPctPlain(m.underwater_bars_pct)], | |
| 31 | + ["Skew / kurtosis", `${fmtRatio(m.skew)} / ${fmtRatio(m.kurtosis)}`], | |
| 32 | + ["Bars / years", `${m.bars ?? "—"} / ${fmtRatio(m.years)}`], | |
| 33 | + ["Final equity", fmtUsd(m.final_equity)], | |
| 34 | + ]; | |
| 35 | + return ( | |
| 36 | + <div className={cx("grid gap-2", compact ? "grid-cols-2 md:grid-cols-4" : "grid-cols-2 md:grid-cols-4 xl:grid-cols-6")}> | |
| 37 | + {items.map(([k, v, cls]) => ( | |
| 38 | + <div key={k} className="rounded-md border border-line bg-bg px-3 py-2"> | |
| 39 | + <div className="text-[10px] uppercase tracking-wide text-fg-dim">{k}</div> | |
| 40 | + <div className={cx("num mt-0.5 text-sm font-semibold", cls)}>{v}</div> | |
| 41 | + </div> | |
| 42 | + ))} | |
| 43 | + </div> | |
| 44 | + ); | |
| 45 | +} | |
| 46 | + | |
| 47 | +export function EquityVsBenchmarks({ a, label }: { a: Artifacts; label: string }) { | |
| 48 | + const series: LineSpec[] = [{ id: "strategy", label, color: "#4f8cff", width: 2, points: a.equity.map((p) => ({ ts: p.ts, value: p.equity })) }]; | |
| 49 | + for (const [name, b] of Object.entries(a.benchmarks ?? {})) series.push({ id: name, label: name.replace(/_/g, " "), color: BENCH_COLORS[name] ?? "#888", width: 1, points: b.equity.map((p) => ({ ts: p.ts, value: p.equity })) }); | |
| 50 | + return ( | |
| 51 | + <> | |
| 52 | + <LinesChart series={series} height={320} /> | |
| 53 | + <div className="mt-2 flex flex-wrap gap-3 text-[11px]">{series.map((s) => <span key={s.id} className="flex items-center gap-1.5"><span className="inline-block h-2 w-2 rounded-full" style={{ background: s.color }} />{s.label}</span>)}</div> | |
| 54 | + </> | |
| 55 | + ); | |
| 56 | +} | |
| 57 | + | |
| 58 | +export function BenchmarkTable({ a, m }: { a: Artifacts; m: Metrics }) { | |
| 59 | + const rows = [["strategy", m] as [string, Metrics], ...Object.entries(a.benchmarks ?? {}).map(([k, v]) => [k, v.metrics] as [string, Metrics])]; | |
| 60 | + return ( | |
| 61 | + <Table> | |
| 62 | + <thead><tr><Th>Series</Th><Th align="right">CAGR</Th><Th align="right">Vol</Th><Th align="right">Sharpe</Th><Th align="right">Max DD</Th><Th align="right">Calmar</Th><Th align="right">Turnover</Th><Th align="right">Fee drag</Th></tr></thead> | |
| 63 | + <tbody>{rows.map(([k, v]) => ( | |
| 64 | + <tr key={k} className={k === "strategy" ? "font-semibold" : ""}><Td>{k.replace(/_/g, " ")}</Td><Td align="right" num className={signClass(v.cagr)}>{fmtPct(v.cagr)}</Td><Td align="right" num>{fmtPctPlain(v.volatility)}</Td><Td align="right" num>{fmtRatio(v.sharpe)}</Td><Td align="right" num className="text-down">{fmtPct(v.max_drawdown)}</Td><Td align="right" num>{fmtRatio(v.calmar)}</Td><Td align="right" num>{fmtRatio(v.turnover_annual, 1)}×</Td><Td align="right" num>{fmtPctPlain(v.fee_drag, 2)}</Td></tr> | |
| 65 | + ))}</tbody> | |
| 66 | + </Table> | |
| 67 | + ); | |
| 68 | +} | |
| 69 | + | |
| 70 | +export function Underwater({ a }: { a: Artifacts }) { | |
| 71 | + return <DrawdownChart points={a.equity} />; | |
| 72 | +} | |
| 73 | + | |
| 74 | +export function RollingSharpe({ a }: { a: Artifacts }) { | |
| 75 | + if (!a.rolling_sharpe?.length) return <Empty title="Not enough bars for a rolling window" />; | |
| 76 | + return <LinesChart series={[{ id: "rs", label: "rolling Sharpe", color: "#a855f7", points: a.rolling_sharpe.map((p) => ({ ts: p.ts, value: p.sharpe })) }]} height={180} />; | |
| 77 | +} | |
| 78 | + | |
| 79 | +export function WalkForward({ wf }: { wf: Artifacts["walk_forward"] }) { | |
| 80 | + if (!wf) return <Empty title="Walk-forward not requested">Enable it in the Strategy Lab to see out-of-sample folds.</Empty>; | |
| 81 | + if (wf.error) return <Empty title="Walk-forward skipped">{wf.error}</Empty>; | |
| 82 | + return ( | |
| 83 | + <> | |
| 84 | + <div className="mb-2 flex flex-wrap gap-2 text-xs"> | |
| 85 | + <Badge tone="muted">{wf.anchored ? "anchored" : "rolling"} train</Badge><Badge tone="muted">{wf.n_trials} grid points</Badge><Badge tone="muted">embargo {wf.embargo_bars ?? 0} bars</Badge> | |
| 86 | + {wf.stitched_oos && <Badge tone={(wf.stitched_oos.sharpe ?? 0) > 0 ? "up" : "down"}>stitched OOS Sharpe {fmtRatio(wf.stitched_oos.sharpe)} · return {fmtPct(wf.stitched_oos.total_return)} · {wf.stitched_oos.bars} bars</Badge>} | |
| 87 | + </div> | |
| 88 | + <Table> | |
| 89 | + <thead><tr><Th>Fold</Th><Th>Train</Th><Th>Test (OOS)</Th><Th>Best params (IS)</Th><Th align="right">IS Sharpe</Th><Th align="right">OOS Sharpe</Th><Th align="right">OOS return</Th><Th align="right">OOS max DD</Th></tr></thead> | |
| 90 | + <tbody>{wf.folds.map((f) => ( | |
| 91 | + <tr key={f.fold}><Td num>{f.fold + 1}</Td><Td num className="text-fg-muted">{fmtDate(f.train[0])} → {fmtDate(f.train[1])}</Td><Td num>{fmtDate(f.test[0])} → {fmtDate(f.test[1])}</Td><Td className="font-mono text-[11px]">{JSON.stringify(f.best_params)}</Td><Td align="right" num>{fmtRatio(f.is_sharpe)}</Td><Td align="right" num className={signClass(f.oos_sharpe)}>{fmtRatio(f.oos_sharpe)}</Td><Td align="right" num className={signClass(f.oos_return)}>{fmtPct(f.oos_return)}</Td><Td align="right" num className="text-down">{fmtPct(f.oos_max_dd)}</Td></tr> | |
| 92 | + ))}</tbody> | |
| 93 | + </Table> | |
| 94 | + </> | |
| 95 | + ); | |
| 96 | +} | |
| 97 | + | |
| 98 | +export function SensitivityHeatmap({ s }: { s: Artifacts["sensitivity"] }) { | |
| 99 | + if (!s) return <Empty title="Sensitivity map not requested">Enable it in the Strategy Lab (two parameters × value lists).</Empty>; | |
| 100 | + const flat = s.z.flat().filter((v): v is number => v != null); | |
| 101 | + const min = Math.min(...flat), max = Math.max(...flat); | |
| 102 | + const color = (v: number | null) => { | |
| 103 | + if (v == null) return "transparent"; | |
| 104 | + const t = max === min ? 0.5 : (v - min) / (max - min); | |
| 105 | + return v >= 0 ? `rgba(34,197,94,${0.15 + 0.7 * t})` : `rgba(239,68,68,${0.15 + 0.7 * (1 - t)})`; | |
| 106 | + }; | |
| 107 | + return ( | |
| 108 | + <div className="overflow-x-auto"> | |
| 109 | + <table className="text-xs"> | |
| 110 | + <caption className="mb-1 text-left text-[11px] text-fg-dim">{s.metric} by {s.x.name} (columns) × {s.y.name} (rows)</caption> | |
| 111 | + <thead><tr><Th>{s.y.name} \ {s.x.name}</Th>{s.x.values.map((x) => <Th key={String(x)} align="center">{String(x)}</Th>)}</tr></thead> | |
| 112 | + <tbody>{s.y.values.map((y, j) => ( | |
| 113 | + <tr key={String(y)}><Th scope="row">{String(y)}</Th>{s.z[j].map((v, i) => <td key={i} className="num border border-line px-3 py-2 text-center" style={{ background: color(v) }}>{v == null ? "—" : v.toFixed(2)}</td>)}</tr> | |
| 114 | + ))}</tbody> | |
| 115 | + </table> | |
| 116 | + </div> | |
| 117 | + ); | |
| 118 | +} | |
| 119 | + | |
| 120 | +export function StressTable({ stress, base }: { stress: Artifacts["stress"]; base: Metrics }) { | |
| 121 | + const rows = Object.entries(stress ?? {}); | |
| 122 | + if (!rows.length) return <Empty title="No stress scenarios" />; | |
| 123 | + return ( | |
| 124 | + <Table> | |
| 125 | + <thead><tr><Th>Scenario</Th><Th align="right">Sharpe</Th><Th align="right">Δ Sharpe</Th><Th align="right">Return</Th><Th align="right">Max DD</Th><Th align="right">Fee drag</Th></tr></thead> | |
| 126 | + <tbody> | |
| 127 | + <tr className="font-semibold"><Td>base</Td><Td align="right" num>{fmtRatio(base.sharpe)}</Td><Td align="right" num>—</Td><Td align="right" num className={signClass(base.total_return)}>{fmtPct(base.total_return)}</Td><Td align="right" num className="text-down">{fmtPct(base.max_drawdown)}</Td><Td align="right" num>{fmtPctPlain(base.fee_drag, 2)}</Td></tr> | |
| 128 | + {rows.map(([k, v]) => <tr key={k}><Td>{k.replace(/_/g, " ")}</Td><Td align="right" num>{fmtRatio(v.sharpe)}</Td><Td align="right" num className={signClass((v.sharpe ?? 0) - (base.sharpe ?? 0))}>{v.sharpe != null && base.sharpe != null ? fmtRatio(v.sharpe - base.sharpe) : "—"}</Td><Td align="right" num className={signClass(v.total_return)}>{fmtPct(v.total_return)}</Td><Td align="right" num className="text-down">{fmtPct(v.max_drawdown)}</Td><Td align="right" num>{v.fee_drag != null ? fmtPctPlain(v.fee_drag, 2) : "—"}</Td></tr>)} | |
| 129 | + </tbody> | |
| 130 | + </Table> | |
| 131 | + ); | |
| 132 | +} | |
| 133 | + | |
| 134 | +export function ShuffledTrades({ s }: { s: Artifacts["shuffled_trades"] }) { | |
| 135 | + if (!s || !s.n) return <Empty title="Too few round trips for a Monte-Carlo reshuffle (needs ≥ 5)" />; | |
| 136 | + return <KV items={[["Permutations", s.n], ["Total P&L (fixed)", fmtUsd(s.total_pnl)], ["Max drawdown — median path", fmtUsd(s.max_dd_p50)], ["Max drawdown — 95th pct", fmtUsd(s.max_dd_p95)], ["Max drawdown — worst path", fmtUsd(s.max_dd_worst)]]} />; | |
| 137 | +} | |
| 138 | + | |
| 139 | +export function TradeLedger({ trades, page, setPage, pageSize = 50 }: { trades: Artifacts["trades"]; page: number; setPage: (p: number) => void; pageSize?: number }) { | |
| 140 | + if (!trades?.length) return <Empty title="No fills">The strategy never changed exposure enough to trade, or all orders were rejected.</Empty>; | |
| 141 | + const pages = Math.ceil(trades.length / pageSize); | |
| 142 | + const slice = trades.slice(page * pageSize, (page + 1) * pageSize); | |
| 143 | + return ( | |
| 144 | + <> | |
| 145 | + <Table> | |
| 146 | + <thead><tr><Th>Time</Th><Th>Side</Th><Th align="right">Qty</Th><Th align="right">Fill</Th><Th align="right">Ref (open)</Th><Th align="right">Slip</Th><Th align="right">Notional</Th><Th align="right">Fee</Th><Th align="right">Target</Th><Th>Note</Th></tr></thead> | |
| 147 | + <tbody>{slice.map((t, i) => ( | |
| 148 | + <tr key={`${t.ts}-${i}`}><Td num>{fmtTs(t.ts)}</Td><Td className={t.side === "buy" ? "text-up" : "text-down"}>{t.side}</Td><Td align="right" num>{fmtNum(t.qty, 6)}</Td><Td align="right" num>{fmtNum(t.price, 2)}</Td><Td align="right" num>{fmtNum(t.ref_price, 2)}</Td><Td align="right" num>{fmtBps(((t.price - t.ref_price) / t.ref_price) * 1e4 * (t.side === "buy" ? 1 : -1), 1)}</Td><Td align="right" num>{fmtUsd(t.notional)}</Td><Td align="right" num>{fmtUsd(t.fee)}</Td><Td align="right" num>{fmtPctPlain(t.strategy_target, 0)}</Td><Td className="text-fg-dim">{t.partial ? "partial" : ""}{t.reason !== "rebalance" ? ` ${t.reason}` : ""}</Td></tr> | |
| 149 | + ))}</tbody> | |
| 150 | + </Table> | |
| 151 | + {pages > 1 && <div className="mt-2 flex items-center justify-end gap-2 text-xs"><button className="rounded border border-line-strong px-2 py-1 disabled:opacity-40" disabled={page === 0} onClick={() => setPage(page - 1)}>Prev</button><span>page {page + 1} / {pages}</span><button className="rounded border border-line-strong px-2 py-1 disabled:opacity-40" disabled={page >= pages - 1} onClick={() => setPage(page + 1)}>Next</button></div>} | |
| 152 | + </> | |
| 153 | + ); | |
| 154 | +} | |
| 155 | + | |
| 156 | +export function Provenance({ run }: { run: Run }) { | |
| 157 | + const p = run.provenance ?? {}; | |
| 158 | + const w = p.window as [number, number] | undefined; | |
| 159 | + return ( | |
| 160 | + <KV items={[ | |
| 161 | + ["Run id", run.id], ["Code version", String(p.code_version ?? "—")], ["Git commit", String(p.git_commit ?? "not a git checkout")], ["Dataset hash", String(p.dataset_hash ?? "—")], | |
| 162 | + ["Bars", String(p.n_bars ?? "—")], ["Window", w ? `${fmtTs(w[0])} → ${fmtTs(w[1])}` : "—"], ["Holdout fraction", String(p.holdout_frac ?? 0)], | |
| 163 | + ["Params", JSON.stringify(p.params ?? run.params)], ["Cost model", JSON.stringify(p.cost_model ?? run.cost_model)], ["Risk limits", p.risk_limits ? JSON.stringify(p.risk_limits) : "engine defaults (size-unbounded, no shorting)"], | |
| 164 | + ["Seed", String(p.seed ?? "—")], ["Initial cash", fmtUsd(p.initial_cash)], ["Runtime", `${p.runtime_s ?? "—"} s`], ["Data source", String(p.data_source ?? "—")], ["Evaluation", String(p.evaluation_note ?? "—")], | |
| 165 | + ]} /> | |
| 166 | + ); | |
| 167 | +} | |
| 168 | + | |
| 169 | +export function RejectedOrders({ rejected }: { rejected: Artifacts["rejected"] }) { | |
| 170 | + if (!rejected?.length) return <p className="text-xs text-fg-dim">No orders were rejected by the risk gateway.</p>; | |
| 171 | + return ( | |
| 172 | + <Table> | |
| 173 | + <thead><tr><Th>Time</Th><Th>Side</Th><Th align="right">Qty</Th><Th>Reasons</Th></tr></thead> | |
| 174 | + <tbody>{rejected.map((r, i) => <tr key={i}><Td num>{fmtTs(r.ts)}</Td><Td>{r.side}</Td><Td align="right" num>{fmtNum(r.qty, 6)}</Td><Td className="text-down">{r.reasons.join("; ")}</Td></tr>)}</tbody> | |
| 175 | + </Table> | |
| 176 | + ); | |
| 177 | +} | |
| 178 | + | |
| 179 | +export { Card }; | |
added
apps/web/src/components/shell.tsx
+110 −0
@@ -0,0 +1,110 @@ | ||
| 1 | +"use client"; | |
| 2 | +import { useQuery, useQueryClient } from "@tanstack/react-query"; | |
| 3 | +import { Activity, BarChart3, BookOpen, Briefcase, FlaskConical, Gauge, LayoutDashboard, LogOut, Menu, Radio, Settings, ShieldAlert, TerminalSquare, X } from "lucide-react"; | |
| 4 | +import Link from "next/link"; | |
| 5 | +import { usePathname } from "next/navigation"; | |
| 6 | +import { useState } from "react"; | |
| 7 | +import { api } from "@/lib/api"; | |
| 8 | +import type { RiskStatus, SystemStatus } from "@/lib/types"; | |
| 9 | +import { Badge, Button, Dot, cx } from "./ui"; | |
| 10 | + | |
| 11 | +const NAV = [ | |
| 12 | + { href: "/", label: "Overview", icon: LayoutDashboard }, | |
| 13 | + { href: "/market", label: "Market Explorer", icon: Activity }, | |
| 14 | + { href: "/lab", label: "Strategy Lab", icon: FlaskConical }, | |
| 15 | + { href: "/backtests", label: "Backtest Explorer", icon: BarChart3 }, | |
| 16 | + { href: "/portfolio", label: "Portfolio Optimizer", icon: Briefcase }, | |
| 17 | + { href: "/paper", label: "Paper Trading", icon: Radio }, | |
| 18 | + { href: "/execution", label: "Execution Console", icon: TerminalSquare }, | |
| 19 | + { href: "/risk", label: "Risk Center", icon: ShieldAlert }, | |
| 20 | + { href: "/reports", label: "Research Reports", icon: BookOpen }, | |
| 21 | + { href: "/settings", label: "Settings", icon: Settings }, | |
| 22 | +]; | |
| 23 | + | |
| 24 | +export function useSystemStatus() { | |
| 25 | + return useQuery({ queryKey: ["system-status"], queryFn: () => api<SystemStatus>("/api/system/status"), refetchInterval: 15_000 }); | |
| 26 | +} | |
| 27 | +export function useRiskStatus() { | |
| 28 | + return useQuery({ queryKey: ["risk-status"], queryFn: () => api<RiskStatus>("/api/risk/status"), refetchInterval: 15_000 }); | |
| 29 | +} | |
| 30 | + | |
| 31 | +export function Shell({ children }: { children: React.ReactNode }) { | |
| 32 | + const [open, setOpen] = useState(false); | |
| 33 | + const pathname = usePathname(); | |
| 34 | + const sys = useSystemStatus(); | |
| 35 | + const risk = useRiskStatus(); | |
| 36 | + const qc = useQueryClient(); | |
| 37 | + const mode = sys.data?.mode ?? "offline"; | |
| 38 | + const ks = risk.data?.kill_switch?.active; | |
| 39 | + | |
| 40 | + async function logout() { | |
| 41 | + await api("/api/auth/logout", { method: "POST", headers: { Origin: window.location.origin } }); | |
| 42 | + qc.clear(); | |
| 43 | + // eslint-disable-next-line @next/next/no-location-assign-relative-destination -- full reload clears client state | |
| 44 | + window.location.href = "/login"; | |
| 45 | + } | |
| 46 | + | |
| 47 | + const nav = ( | |
| 48 | + <nav aria-label="Main" className="flex flex-col gap-0.5 p-2"> | |
| 49 | + {NAV.map(({ href, label, icon: Icon }) => { | |
| 50 | + const active = href === "/" ? pathname === "/" : pathname.startsWith(href); | |
| 51 | + return ( | |
| 52 | + <Link key={href} href={href} onClick={() => setOpen(false)} aria-current={active ? "page" : undefined} className={cx("flex items-center gap-2.5 rounded-md px-2.5 py-2 text-sm transition", active ? "bg-accent-soft text-fg" : "text-fg-muted hover:bg-bg-sub hover:text-fg")}> | |
| 53 | + <Icon className="h-4 w-4 shrink-0" aria-hidden /> | |
| 54 | + <span>{label}</span> | |
| 55 | + </Link> | |
| 56 | + ); | |
| 57 | + })} | |
| 58 | + </nav> | |
| 59 | + ); | |
| 60 | + | |
| 61 | + return ( | |
| 62 | + <div className="flex min-h-screen"> | |
| 63 | + <aside className="no-print hidden w-56 shrink-0 border-r border-line bg-bg-elev lg:block"> | |
| 64 | + <div className="flex h-12 items-center gap-2 border-b border-line px-4"> | |
| 65 | + <Gauge className="h-4 w-4 text-accent" aria-hidden /> | |
| 66 | + <span className="font-semibold tracking-tight">ktrade</span> | |
| 67 | + <span className="text-[10px] text-fg-dim">Kraken spot</span> | |
| 68 | + </div> | |
| 69 | + {nav} | |
| 70 | + </aside> | |
| 71 | + {open && ( | |
| 72 | + <div className="fixed inset-0 z-40 lg:hidden" onClick={() => setOpen(false)}> | |
| 73 | + <div className="absolute inset-0 bg-black/60" /> | |
| 74 | + <aside className="absolute left-0 top-0 h-full w-64 border-r border-line bg-bg-elev" onClick={(e) => e.stopPropagation()}> | |
| 75 | + <div className="flex h-12 items-center justify-between border-b border-line px-4"> | |
| 76 | + <span className="font-semibold">ktrade</span> | |
| 77 | + <button aria-label="Close menu" onClick={() => setOpen(false)}><X className="h-4 w-4" /></button> | |
| 78 | + </div> | |
| 79 | + {nav} | |
| 80 | + </aside> | |
| 81 | + </div> | |
| 82 | + )} | |
| 83 | + <div className="flex min-w-0 flex-1 flex-col"> | |
| 84 | + {mode === "live" && ( | |
| 85 | + <div role="alert" className="bg-live px-4 py-1.5 text-center text-xs font-bold uppercase tracking-widest text-white">LIVE TRADING ACTIVE — real orders can reach Kraken</div> | |
| 86 | + )} | |
| 87 | + <header className="no-print flex h-12 items-center gap-3 border-b border-line bg-bg-elev px-3 sm:px-4"> | |
| 88 | + <button className="rounded p-1 text-fg-muted hover:bg-bg-sub lg:hidden" aria-label="Open menu" onClick={() => setOpen(true)}><Menu className="h-5 w-5" /></button> | |
| 89 | + <ModeBadge mode={mode} /> | |
| 90 | + <div className="ml-1 hidden items-center gap-3 text-xs text-fg-muted sm:flex"> | |
| 91 | + <span className="flex items-center gap-1.5" title={sys.data?.kraken?.error ?? sys.data?.kraken?.status}><Dot ok={sys.data ? sys.data.kraken?.status === "online" : null} /> Kraken {sys.data?.kraken?.status ?? "…"}</span> | |
| 92 | + <span className="flex items-center gap-1.5"><Dot ok={sys.data ? !!sys.data.stream?.connected : null} /> stream {sys.data?.stream ? (sys.data.stream.connected ? "live" : "down") : "n/a"}</span> | |
| 93 | + {ks !== undefined && <span className="flex items-center gap-1.5"><Dot ok={!ks} /> kill switch {ks ? <span className="font-semibold text-down">ACTIVE</span> : "armed"}</span>} | |
| 94 | + </div> | |
| 95 | + <div className="ml-auto flex items-center gap-2"> | |
| 96 | + {ks && <Badge tone="down" className="sm:hidden">kill</Badge>} | |
| 97 | + <Button size="sm" variant="ghost" onClick={logout} aria-label="Log out"><LogOut className="h-3.5 w-3.5" /> <span className="hidden sm:inline">Logout</span></Button> | |
| 98 | + </div> | |
| 99 | + </header> | |
| 100 | + <main className="min-w-0 flex-1 p-3 sm:p-4 lg:p-6">{children}</main> | |
| 101 | + </div> | |
| 102 | + </div> | |
| 103 | + ); | |
| 104 | +} | |
| 105 | + | |
| 106 | +export function ModeBadge({ mode }: { mode: string }) { | |
| 107 | + if (mode === "live") return <Badge tone="live">LIVE</Badge>; | |
| 108 | + if (mode === "paper") return <Badge tone="accent">PAPER</Badge>; | |
| 109 | + return <Badge tone="muted">OFFLINE</Badge>; | |
| 110 | +} | |
added
apps/web/src/components/ui.tsx
+170 −0
@@ -0,0 +1,170 @@ | ||
| 1 | +"use client"; | |
| 2 | +import { Loader2, X } from "lucide-react"; | |
| 3 | +import { useEffect, useId, type ReactNode } from "react"; | |
| 4 | + | |
| 5 | +export const cx = (...a: (string | false | null | undefined)[]) => a.filter(Boolean).join(" "); | |
| 6 | + | |
| 7 | +export function Card({ title, subtitle, actions, children, className, id }: { title?: ReactNode; subtitle?: ReactNode; actions?: ReactNode; children: ReactNode; className?: string; id?: string }) { | |
| 8 | + return ( | |
| 9 | + <section id={id} className={cx("rounded-lg border border-line bg-bg-elev print-card", className)}> | |
| 10 | + {(title || actions) && ( | |
| 11 | + <header className="flex items-start justify-between gap-3 border-b border-line px-4 py-3"> | |
| 12 | + <div> | |
| 13 | + {title && <h2 className="text-sm font-semibold text-fg">{title}</h2>} | |
| 14 | + {subtitle && <p className="mt-0.5 text-xs text-fg-muted">{subtitle}</p>} | |
| 15 | + </div> | |
| 16 | + {actions && <div className="flex shrink-0 items-center gap-2">{actions}</div>} | |
| 17 | + </header> | |
| 18 | + )} | |
| 19 | + <div className="p-4">{children}</div> | |
| 20 | + </section> | |
| 21 | + ); | |
| 22 | +} | |
| 23 | + | |
| 24 | +export function Tile({ label, value, sub, tone }: { label: string; value: ReactNode; sub?: ReactNode; tone?: "up" | "down" | "warn" | "muted" }) { | |
| 25 | + const t = tone === "up" ? "text-up" : tone === "down" ? "text-down" : tone === "warn" ? "text-warn" : tone === "muted" ? "text-fg-muted" : "text-fg"; | |
| 26 | + return ( | |
| 27 | + <div className="rounded-lg border border-line bg-bg-elev px-4 py-3"> | |
| 28 | + <div className="text-xs uppercase tracking-wide text-fg-dim">{label}</div> | |
| 29 | + <div className={cx("mt-1 text-xl font-semibold num", t)}>{value}</div> | |
| 30 | + {sub && <div className="mt-0.5 text-xs text-fg-muted">{sub}</div>} | |
| 31 | + </div> | |
| 32 | + ); | |
| 33 | +} | |
| 34 | + | |
| 35 | +type BtnProps = React.ButtonHTMLAttributes<HTMLButtonElement> & { variant?: "primary" | "ghost" | "danger" | "outline"; size?: "sm" | "md"; loading?: boolean }; | |
| 36 | +export function Button({ variant = "outline", size = "md", loading, className, children, disabled, ...rest }: BtnProps) { | |
| 37 | + const v = | |
| 38 | + variant === "primary" ? "bg-accent text-white hover:bg-accent/90 border-transparent" : | |
| 39 | + variant === "danger" ? "bg-down text-white hover:bg-down/90 border-transparent" : | |
| 40 | + variant === "ghost" ? "border-transparent hover:bg-bg-sub text-fg-muted hover:text-fg" : | |
| 41 | + "border-line-strong bg-bg-sub hover:bg-line text-fg"; | |
| 42 | + const s = size === "sm" ? "h-7 px-2.5 text-xs" : "h-9 px-3.5 text-sm"; | |
| 43 | + return ( | |
| 44 | + <button {...rest} disabled={disabled || loading} className={cx("inline-flex items-center justify-center gap-1.5 rounded-md border font-medium transition disabled:cursor-not-allowed disabled:opacity-50", v, s, className)}> | |
| 45 | + {loading && <Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden />} | |
| 46 | + {children} | |
| 47 | + </button> | |
| 48 | + ); | |
| 49 | +} | |
| 50 | + | |
| 51 | +export function Field({ label, hint, children, htmlFor }: { label: ReactNode; hint?: ReactNode; children: ReactNode; htmlFor?: string }) { | |
| 52 | + return ( | |
| 53 | + <label htmlFor={htmlFor} className="block text-xs"> | |
| 54 | + <span className="mb-1 block font-medium text-fg-muted">{label}</span> | |
| 55 | + {children} | |
| 56 | + {hint && <span className="mt-1 block text-[11px] text-fg-dim">{hint}</span>} | |
| 57 | + </label> | |
| 58 | + ); | |
| 59 | +} | |
| 60 | + | |
| 61 | +const inputCls = "h-9 w-full rounded-md border border-line-strong bg-bg px-2.5 text-sm text-fg placeholder:text-fg-dim focus:border-accent"; | |
| 62 | +export function Input(props: React.InputHTMLAttributes<HTMLInputElement>) { | |
| 63 | + return <input {...props} className={cx(inputCls, props.type === "number" && "num", props.className)} />; | |
| 64 | +} | |
| 65 | +export function Select(props: React.SelectHTMLAttributes<HTMLSelectElement>) { | |
| 66 | + return <select {...props} className={cx(inputCls, props.className)} />; | |
| 67 | +} | |
| 68 | +export function Textarea(props: React.TextareaHTMLAttributes<HTMLTextAreaElement>) { | |
| 69 | + return <textarea {...props} className={cx("w-full rounded-md border border-line-strong bg-bg px-2.5 py-2 font-mono text-xs text-fg focus:border-accent", props.className)} />; | |
| 70 | +} | |
| 71 | + | |
| 72 | +export function Badge({ children, tone = "muted", className }: { children: ReactNode; tone?: "up" | "down" | "warn" | "muted" | "accent" | "live"; className?: string }) { | |
| 73 | + const t = { | |
| 74 | + up: "bg-up/15 text-up border-up/30", | |
| 75 | + down: "bg-down/15 text-down border-down/30", | |
| 76 | + warn: "bg-warn/15 text-warn border-warn/30", | |
| 77 | + muted: "bg-bg-sub text-fg-muted border-line-strong", | |
| 78 | + accent: "bg-accent-soft text-accent border-accent/40", | |
| 79 | + live: "bg-live text-white border-live", | |
| 80 | + }[tone]; | |
| 81 | + return <span className={cx("inline-flex items-center gap-1 rounded border px-1.5 py-0.5 text-[11px] font-medium uppercase tracking-wide", t, className)}>{children}</span>; | |
| 82 | +} | |
| 83 | + | |
| 84 | +export function Dot({ ok, className }: { ok: boolean | null | undefined; className?: string }) { | |
| 85 | + return <span aria-hidden className={cx("inline-block h-2 w-2 rounded-full", ok === true ? "bg-up" : ok === false ? "bg-down" : "bg-fg-dim", className)} />; | |
| 86 | +} | |
| 87 | + | |
| 88 | +export function Spinner({ label = "Loading…" }: { label?: string }) { | |
| 89 | + return ( | |
| 90 | + <div className="flex items-center gap-2 py-6 text-sm text-fg-muted" role="status"> | |
| 91 | + <Loader2 className="h-4 w-4 animate-spin" aria-hidden /> {label} | |
| 92 | + </div> | |
| 93 | + ); | |
| 94 | +} | |
| 95 | + | |
| 96 | +export function Empty({ title, children }: { title: string; children?: ReactNode }) { | |
| 97 | + return ( | |
| 98 | + <div className="rounded-md border border-dashed border-line-strong px-4 py-6 text-center"> | |
| 99 | + <div className="text-sm font-medium text-fg-muted">{title}</div> | |
| 100 | + {children && <div className="mt-1 text-xs text-fg-dim">{children}</div>} | |
| 101 | + </div> | |
| 102 | + ); | |
| 103 | +} | |
| 104 | + | |
| 105 | +export function ErrorBox({ error }: { error: unknown }) { | |
| 106 | + const msg = error instanceof Error ? error.message : String(error); | |
| 107 | + return <div role="alert" className="rounded-md border border-down/40 bg-down/10 px-3 py-2 text-xs text-down">{msg}</div>; | |
| 108 | +} | |
| 109 | + | |
| 110 | +export function Table({ children, className }: { children: ReactNode; className?: string }) { | |
| 111 | + return ( | |
| 112 | + <div className={cx("overflow-x-auto scroll-thin", className)}> | |
| 113 | + <table className="w-full border-collapse text-xs">{children}</table> | |
| 114 | + </div> | |
| 115 | + ); | |
| 116 | +} | |
| 117 | +export function Th({ children, className, align = "left", scope = "col" }: { children?: ReactNode; className?: string; align?: "left" | "right" | "center"; scope?: "col" | "row" }) { | |
| 118 | + return <th scope={scope} className={cx("border-b border-line px-2 py-1.5 text-[11px] font-semibold uppercase tracking-wide text-fg-dim", align === "right" ? "text-right" : align === "center" ? "text-center" : "text-left", className)}>{children}</th>; | |
| 119 | +} | |
| 120 | +export function Td({ children, className, align = "left", num, title }: { children?: ReactNode; className?: string; align?: "left" | "right" | "center"; num?: boolean; title?: string }) { | |
| 121 | + return <td title={title} className={cx("border-b border-line/60 px-2 py-1.5 align-middle", align === "right" ? "text-right" : align === "center" ? "text-center" : "text-left", num && "num", className)}>{children}</td>; | |
| 122 | +} | |
| 123 | + | |
| 124 | +export function Dialog({ open, onClose, title, children, footer }: { open: boolean; onClose: () => void; title: string; children: ReactNode; footer?: ReactNode }) { | |
| 125 | + const id = useId(); | |
| 126 | + useEffect(() => { | |
| 127 | + if (!open) return; | |
| 128 | + const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose(); | |
| 129 | + window.addEventListener("keydown", onKey); | |
| 130 | + return () => window.removeEventListener("keydown", onKey); | |
| 131 | + }, [open, onClose]); | |
| 132 | + if (!open) return null; | |
| 133 | + return ( | |
| 134 | + <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4" onClick={onClose}> | |
| 135 | + <div role="dialog" aria-modal="true" aria-labelledby={id} className="w-full max-w-md rounded-lg border border-line-strong bg-bg-elev shadow-xl" onClick={(e) => e.stopPropagation()}> | |
| 136 | + <header className="flex items-center justify-between border-b border-line px-4 py-3"> | |
| 137 | + <h2 id={id} className="text-sm font-semibold">{title}</h2> | |
| 138 | + <button onClick={onClose} aria-label="Close" className="rounded p-1 text-fg-muted hover:bg-bg-sub hover:text-fg"><X className="h-4 w-4" /></button> | |
| 139 | + </header> | |
| 140 | + <div className="px-4 py-3 text-sm">{children}</div> | |
| 141 | + {footer && <footer className="flex justify-end gap-2 border-t border-line px-4 py-3">{footer}</footer>} | |
| 142 | + </div> | |
| 143 | + </div> | |
| 144 | + ); | |
| 145 | +} | |
| 146 | + | |
| 147 | +export function PageHeader({ title, subtitle, actions }: { title: string; subtitle?: ReactNode; actions?: ReactNode }) { | |
| 148 | + return ( | |
| 149 | + <div className="mb-4 flex flex-wrap items-end justify-between gap-3"> | |
| 150 | + <div> | |
| 151 | + <h1 className="text-lg font-semibold text-fg">{title}</h1> | |
| 152 | + {subtitle && <p className="mt-0.5 text-xs text-fg-muted">{subtitle}</p>} | |
| 153 | + </div> | |
| 154 | + {actions && <div className="flex items-center gap-2">{actions}</div>} | |
| 155 | + </div> | |
| 156 | + ); | |
| 157 | +} | |
| 158 | + | |
| 159 | +export function KV({ items }: { items: [ReactNode, ReactNode][] }) { | |
| 160 | + return ( | |
| 161 | + <dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1 text-xs"> | |
| 162 | + {items.map(([k, v], i) => ( | |
| 163 | + <div key={i} className="contents"> | |
| 164 | + <dt className="text-fg-dim">{k}</dt> | |
| 165 | + <dd className="num break-all text-fg">{v}</dd> | |
| 166 | + </div> | |
| 167 | + ))} | |
| 168 | + </dl> | |
| 169 | + ); | |
| 170 | +} | |
added
apps/web/src/lib/api.ts
+49 −0
@@ -0,0 +1,49 @@ | ||
| 1 | +export class ApiError extends Error { | |
| 2 | + status: number; | |
| 3 | + detail: unknown; | |
| 4 | + constructor(status: number, detail: unknown) { | |
| 5 | + super(typeof detail === "string" ? detail : `HTTP ${status}`); | |
| 6 | + this.status = status; | |
| 7 | + this.detail = detail; | |
| 8 | + } | |
| 9 | +} | |
| 10 | + | |
| 11 | +function redirectToLogin() { | |
| 12 | + if (typeof window !== "undefined" && !window.location.pathname.startsWith("/login")) { | |
| 13 | + // Full navigation on purpose: clears all client state/query cache after the session expired. | |
| 14 | + // eslint-disable-next-line @next/next/no-location-assign-relative-destination | |
| 15 | + window.location.href = `/login?next=${encodeURIComponent(window.location.pathname)}`; | |
| 16 | + } | |
| 17 | +} | |
| 18 | + | |
| 19 | +export async function api<T>(path: string, init?: RequestInit & { json?: unknown }): Promise<T> { | |
| 20 | + const headers: Record<string, string> = { Accept: "application/json", ...(init?.headers as Record<string, string>) }; | |
| 21 | + let body = init?.body; | |
| 22 | + if (init?.json !== undefined) { | |
| 23 | + headers["Content-Type"] = "application/json"; | |
| 24 | + body = JSON.stringify(init.json); | |
| 25 | + } | |
| 26 | + const res = await fetch(path, { ...init, body, headers, credentials: "include" }); | |
| 27 | + if (res.status === 401) { | |
| 28 | + redirectToLogin(); | |
| 29 | + throw new ApiError(401, "authentication required"); | |
| 30 | + } | |
| 31 | + const text = await res.text(); | |
| 32 | + let data: unknown = text; | |
| 33 | + try { | |
| 34 | + data = text ? JSON.parse(text) : null; | |
| 35 | + } catch { | |
| 36 | + /* non-JSON */ | |
| 37 | + } | |
| 38 | + if (!res.ok) { | |
| 39 | + const detail = (data as { detail?: unknown })?.detail ?? data; | |
| 40 | + throw new ApiError(res.status, typeof detail === "string" ? detail : JSON.stringify(detail)); | |
| 41 | + } | |
| 42 | + return data as T; | |
| 43 | +} | |
| 44 | + | |
| 45 | +export const qs = (params: Record<string, string | number | boolean | undefined | null>) => | |
| 46 | + Object.entries(params) | |
| 47 | + .filter(([, v]) => v !== undefined && v !== null && v !== "") | |
| 48 | + .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`) | |
| 49 | + .join("&"); | |
added
apps/web/src/lib/format.ts
+66 −0
@@ -0,0 +1,66 @@ | ||
| 1 | +const isNum = (v: unknown): v is number => typeof v === "number" && Number.isFinite(v); | |
| 2 | +const toNum = (v: unknown): number | null => { | |
| 3 | + if (isNum(v)) return v; | |
| 4 | + if (typeof v === "string" && v.trim() !== "" && Number.isFinite(Number(v))) return Number(v); | |
| 5 | + return null; | |
| 6 | +}; | |
| 7 | + | |
| 8 | +export const fmtNum = (v: unknown, digits = 2): string => { | |
| 9 | + const n = toNum(v); | |
| 10 | + return n === null ? "—" : n.toLocaleString("en-US", { minimumFractionDigits: digits, maximumFractionDigits: digits }); | |
| 11 | +}; | |
| 12 | +export const fmtPrice = (v: unknown): string => { | |
| 13 | + const n = toNum(v); | |
| 14 | + if (n === null) return "—"; | |
| 15 | + const d = n >= 1000 ? 1 : n >= 10 ? 2 : n >= 1 ? 3 : 5; | |
| 16 | + return n.toLocaleString("en-US", { minimumFractionDigits: d, maximumFractionDigits: d }); | |
| 17 | +}; | |
| 18 | +export const fmtPct = (v: unknown, digits = 2, alreadyPct = false): string => { | |
| 19 | + const n = toNum(v); | |
| 20 | + if (n === null) return "—"; | |
| 21 | + const p = alreadyPct ? n : n * 100; | |
| 22 | + return `${p >= 0 ? "+" : ""}${p.toFixed(digits)}%`; | |
| 23 | +}; | |
| 24 | +export const fmtPctPlain = (v: unknown, digits = 1): string => { | |
| 25 | + const n = toNum(v); | |
| 26 | + return n === null ? "—" : `${(n * 100).toFixed(digits)}%`; | |
| 27 | +}; | |
| 28 | +export const fmtBps = (v: unknown, digits = 1): string => { | |
| 29 | + const n = toNum(v); | |
| 30 | + return n === null ? "—" : `${n.toFixed(digits)} bps`; | |
| 31 | +}; | |
| 32 | +export const fmtUsd = (v: unknown, digits = 2): string => { | |
| 33 | + const n = toNum(v); | |
| 34 | + return n === null ? "—" : n.toLocaleString("en-US", { style: "currency", currency: "USD", minimumFractionDigits: digits, maximumFractionDigits: digits }); | |
| 35 | +}; | |
| 36 | +export const fmtRatio = (v: unknown, digits = 2): string => { | |
| 37 | + const n = toNum(v); | |
| 38 | + if (n === null) return "—"; | |
| 39 | + if (!Number.isFinite(n)) return n > 0 ? "∞" : "—"; | |
| 40 | + return n.toFixed(digits); | |
| 41 | +}; | |
| 42 | +export const fmtTs = (ts: unknown): string => { | |
| 43 | + const n = toNum(ts); | |
| 44 | + if (n === null) return "—"; | |
| 45 | + const ms = n > 1e12 ? n : n * 1000; | |
| 46 | + return new Date(ms).toISOString().replace("T", " ").slice(0, 19) + "Z"; | |
| 47 | +}; | |
| 48 | +export const fmtDate = (ts: unknown): string => { | |
| 49 | + const n = toNum(ts); | |
| 50 | + if (n === null) return "—"; | |
| 51 | + return new Date((n > 1e12 ? n : n * 1000)).toISOString().slice(0, 10); | |
| 52 | +}; | |
| 53 | +export const fmtIso = (s: unknown): string => (typeof s === "string" ? s.replace("T", " ").slice(0, 19) : "—"); | |
| 54 | +export const fmtAge = (s: unknown): string => { | |
| 55 | + const n = toNum(s); | |
| 56 | + if (n === null) return "—"; | |
| 57 | + if (n < 60) return `${n.toFixed(0)}s`; | |
| 58 | + if (n < 3600) return `${(n / 60).toFixed(0)}m`; | |
| 59 | + return `${(n / 3600).toFixed(1)}h`; | |
| 60 | +}; | |
| 61 | +export const signClass = (v: unknown): string => { | |
| 62 | + const n = toNum(v); | |
| 63 | + if (n === null || n === 0) return "text-fg-muted"; | |
| 64 | + return n > 0 ? "text-up" : "text-down"; | |
| 65 | +}; | |
| 66 | +export { toNum }; | |
added
apps/web/src/lib/sse.ts
+41 −0
@@ -0,0 +1,41 @@ | ||
| 1 | +"use client"; | |
| 2 | +import { useEffect, useRef, useState } from "react"; | |
| 3 | + | |
| 4 | +export type SseEvent<T = unknown> = { type: string; data: T[]; received_at: number }; | |
| 5 | + | |
| 6 | +/** Subscribe to the market-data SSE relay. Handlers are kept in a ref so re-renders don't reconnect. */ | |
| 7 | +export function useMarketStream(symbols: string[], channels: string[], handlers: Partial<Record<string, (ev: SseEvent<Record<string, unknown>>) => void>>) { | |
| 8 | + const [connected, setConnected] = useState(false); | |
| 9 | + const ref = useRef(handlers); | |
| 10 | + useEffect(() => { | |
| 11 | + ref.current = handlers; | |
| 12 | + }); | |
| 13 | + const key = `${symbols.join(",")}|${channels.join(",")}`; | |
| 14 | + useEffect(() => { | |
| 15 | + if (!symbols.length) return; | |
| 16 | + const url = `/api/market/stream?symbols=${encodeURIComponent(symbols.join(","))}&channels=${encodeURIComponent(channels.join(","))}`; | |
| 17 | + const es = new EventSource(url, { withCredentials: true }); | |
| 18 | + es.onopen = () => setConnected(true); | |
| 19 | + es.onerror = () => setConnected(false); | |
| 20 | + const listeners: [string, (e: MessageEvent) => void][] = []; | |
| 21 | + for (const ch of channels) { | |
| 22 | + const fn = (e: MessageEvent) => { | |
| 23 | + try { | |
| 24 | + const parsed = JSON.parse(e.data) as SseEvent<Record<string, unknown>>; | |
| 25 | + ref.current[ch]?.(parsed); | |
| 26 | + } catch { | |
| 27 | + /* ignore malformed */ | |
| 28 | + } | |
| 29 | + }; | |
| 30 | + es.addEventListener(ch, fn); | |
| 31 | + listeners.push([ch, fn]); | |
| 32 | + } | |
| 33 | + return () => { | |
| 34 | + for (const [ch, fn] of listeners) es.removeEventListener(ch, fn); | |
| 35 | + es.close(); | |
| 36 | + setConnected(false); | |
| 37 | + }; | |
| 38 | + // eslint-disable-next-line react-hooks/exhaustive-deps | |
| 39 | + }, [key]); | |
| 40 | + return connected; | |
| 41 | +} | |
added
apps/web/src/lib/types.ts
+106 −0
@@ -0,0 +1,106 @@ | ||
| 1 | +export type ParamSpec = { | |
| 2 | + name: string; | |
| 3 | + type: "int" | "float" | "bool" | "choice"; | |
| 4 | + default: unknown; | |
| 5 | + min?: number | null; | |
| 6 | + max?: number | null; | |
| 7 | + step?: number | null; | |
| 8 | + choices?: unknown[] | null; | |
| 9 | + description?: string; | |
| 10 | +}; | |
| 11 | +export type StrategyMeta = { | |
| 12 | + name: string; | |
| 13 | + label: string; | |
| 14 | + family: string; | |
| 15 | + hypothesis: string; | |
| 16 | + universe_note: string; | |
| 17 | + experimental: boolean; | |
| 18 | + allows_short: boolean; | |
| 19 | + min_bars: number; | |
| 20 | + params: ParamSpec[]; | |
| 21 | + failure_modes: string[]; | |
| 22 | + references: string[]; | |
| 23 | +}; | |
| 24 | +export type OverviewSymbol = { | |
| 25 | + symbol: string; | |
| 26 | + last: number | null; | |
| 27 | + change_pct: number | null; | |
| 28 | + bid: number | null; | |
| 29 | + ask: number | null; | |
| 30 | + volume: number | null; | |
| 31 | + spread_bps: number | null; | |
| 32 | + age_s: number | null; | |
| 33 | + stale: boolean; | |
| 34 | + rv_7d_annualized: number | null; | |
| 35 | + hourly_bars: number; | |
| 36 | +}; | |
| 37 | +export type SystemStatus = { | |
| 38 | + mode: "offline" | "paper" | "live"; | |
| 39 | + live_trading_enabled: boolean; | |
| 40 | + env: string; | |
| 41 | + version: string; | |
| 42 | + universe: string[]; | |
| 43 | + intervals: number[]; | |
| 44 | + timescale: boolean; | |
| 45 | + credentials: { data_key: boolean; trade_key: boolean; withdrawal: string }; | |
| 46 | + preflight: Record<string, unknown> | null; | |
| 47 | + kraken: { status: string; timestamp?: string; rtt_ms?: number; clock_drift_s?: number; error?: string }; | |
| 48 | + stream: { connected: boolean; last_msg_age: number | null; reconnects: number; checksum_failures: number; messages: number; counts: Record<string, number>; ts: number } | null; | |
| 49 | +}; | |
| 50 | +export type Candle = { ts: number; open: number; high: number; low: number; close: number; vwap: number; volume: number; count: number; closed: boolean }; | |
| 51 | +export type Book = { | |
| 52 | + symbol: string; | |
| 53 | + bids: [number, number][]; | |
| 54 | + asks: [number, number][]; | |
| 55 | + best_bid: number | null; | |
| 56 | + best_ask: number | null; | |
| 57 | + spread: number | null; | |
| 58 | + spread_bps: number | null; | |
| 59 | + imbalance: number; | |
| 60 | + timestamp: string | null; | |
| 61 | + valid: boolean; | |
| 62 | + received_at?: number; | |
| 63 | + age_s?: number; | |
| 64 | +}; | |
| 65 | +export type Trade = { trade_id: number; ts: number; price: number; volume: number; side: "buy" | "sell"; order_type: string }; | |
| 66 | +export type Pair = { pair_id: string; altname: string; wsname: string; base: string; quote: string; status: string; ordermin: number; costmin: number; tick_size: number; pair_decimals: number; lot_decimals: number; in_universe: boolean }; | |
| 67 | +export type Quality = { symbol: string; interval_min: number; bars: number; first_ts: number; last_ts: number; gaps: number; missing_bars: number; lag_bars: number; stale: boolean; coverage_days: number }; | |
| 68 | +export type Gap = { symbol: string; interval_min: number; gap_start: number; gap_end: number; missing_bars: number; detected_at: string }; | |
| 69 | +export type RiskLimits = { | |
| 70 | + max_order_notional: number; max_position_notional: number; max_gross_exposure: number; max_net_exposure: number; max_concentration: number; | |
| 71 | + daily_loss_limit: number; max_drawdown_limit: number; min_cash: number; max_open_orders: number; max_participation: number; max_price_deviation: number; | |
| 72 | + stale_data_seconds: number; max_clock_drift_seconds: number; var_limit_95: number | null; | |
| 73 | +}; | |
| 74 | +export type KillSwitch = { active: boolean; reason?: string | null; since?: number | null }; | |
| 75 | +export type RiskStatus = { limits: RiskLimits; kill_switch: KillSwitch; mode: string; recent_events: { ts: string; action: string; details: Record<string, unknown> }[] }; | |
| 76 | +export type AuditItem = { id: number; ts: string; actor: string; action: string; entity: string | null; details: Record<string, unknown>; cid: string | null; hash: string }; | |
| 77 | +export type RunRow = { | |
| 78 | + id: string; created_at: string; finished_at: string | null; status: string; strategy: string; symbol: string; interval_min: number; | |
| 79 | + params: Record<string, unknown>; label: string | null; error: string | null; start_ts: number | null; end_ts: number | null; | |
| 80 | + sharpe: string | null; total_return: string | null; max_drawdown: string | null; n_trades: string | null; | |
| 81 | +}; | |
| 82 | +export type EquityPoint = { ts: number; equity: number; drawdown: number; exposure: number; price: number }; | |
| 83 | +export type Metrics = Record<string, number | null | undefined> & { | |
| 84 | + bootstrap?: { sharpe_ci: [number | null, number | null]; cagr_ci: [number | null, number | null]; n_boot: number; block?: number }; | |
| 85 | + deflated_sharpe?: { dsr: number | null; sr0_annual?: number | null; n_trials?: number }; | |
| 86 | +}; | |
| 87 | +export type TradeRec = { ts: number; side: string; qty: number; price: number; ref_price: number; fee: number; slippage_cost: number; notional: number; reason: string; strategy_target: number; partial: boolean }; | |
| 88 | +export type Artifacts = { | |
| 89 | + equity: EquityPoint[]; | |
| 90 | + rolling_sharpe: { ts: number; sharpe: number }[]; | |
| 91 | + trades: TradeRec[]; | |
| 92 | + rejected: { ts: number; side: string; qty: number; reasons: string[] }[]; | |
| 93 | + benchmarks: Record<string, { metrics: Metrics; equity: EquityPoint[] }>; | |
| 94 | + stress: Record<string, Record<string, number | null>>; | |
| 95 | + shuffled_trades: { n: number; max_dd_p50?: number; max_dd_p95?: number; max_dd_worst?: number; total_pnl?: number }; | |
| 96 | + walk_forward: { folds: { fold: number; train: [number, number]; test: [number, number]; best_params: Record<string, unknown>; is_sharpe: number | null; oos_sharpe: number | null; oos_return: number | null; oos_max_dd: number | null }[]; anchored?: boolean; n_trials?: number; grid?: Record<string, unknown[]>; embargo_bars?: number; stitched_oos?: { sharpe: number | null; total_return: number | null; bars: number }; error?: string } | null; | |
| 97 | + sensitivity: { x: { name: string; values: unknown[] }; y: { name: string; values: unknown[] }; metric: string; z: (number | null)[][] } | null; | |
| 98 | + holdout: { start_ts: number; metrics: Metrics; equity: EquityPoint[] } | null; | |
| 99 | + features_tail: Record<string, number>[]; | |
| 100 | +}; | |
| 101 | +export type Run = RunRow & { | |
| 102 | + cost_model: Record<string, unknown>; | |
| 103 | + metrics: Metrics | null; | |
| 104 | + provenance: Record<string, unknown> | null; | |
| 105 | + artifacts: Artifacts | null; | |
| 106 | +}; | |
added
apps/web/src/proxy.ts
+18 −0
@@ -0,0 +1,18 @@ | ||
| 1 | +import { NextResponse, type NextRequest } from "next/server"; | |
| 2 | + | |
| 3 | +// Presence-only check: the API validates the signed cookie on every call. | |
| 4 | +export function proxy(req: NextRequest) { | |
| 5 | + const { pathname } = req.nextUrl; | |
| 6 | + if (pathname.startsWith("/login") || pathname.startsWith("/api") || pathname.startsWith("/_next") || pathname === "/favicon.ico") { | |
| 7 | + return NextResponse.next(); | |
| 8 | + } | |
| 9 | + if (!req.cookies.get("ktrade_session")) { | |
| 10 | + const url = req.nextUrl.clone(); | |
| 11 | + url.pathname = "/login"; | |
| 12 | + url.searchParams.set("next", pathname); | |
| 13 | + return NextResponse.redirect(url); | |
| 14 | + } | |
| 15 | + return NextResponse.next(); | |
| 16 | +} | |
| 17 | + | |
| 18 | +export const config = { matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"] }; | |
added
apps/web/tsconfig.json
+34 −0
@@ -0,0 +1,34 @@ | ||
| 1 | +{ | |
| 2 | + "compilerOptions": { | |
| 3 | + "target": "ES2017", | |
| 4 | + "lib": ["dom", "dom.iterable", "esnext"], | |
| 5 | + "allowJs": true, | |
| 6 | + "skipLibCheck": true, | |
| 7 | + "strict": true, | |
| 8 | + "noEmit": true, | |
| 9 | + "esModuleInterop": true, | |
| 10 | + "module": "esnext", | |
| 11 | + "moduleResolution": "bundler", | |
| 12 | + "resolveJsonModule": true, | |
| 13 | + "isolatedModules": true, | |
| 14 | + "jsx": "react-jsx", | |
| 15 | + "incremental": true, | |
| 16 | + "plugins": [ | |
| 17 | + { | |
| 18 | + "name": "next" | |
| 19 | + } | |
| 20 | + ], | |
| 21 | + "paths": { | |
| 22 | + "@/*": ["./src/*"] | |
| 23 | + } | |
| 24 | + }, | |
| 25 | + "include": [ | |
| 26 | + "next-env.d.ts", | |
| 27 | + "**/*.ts", | |
| 28 | + "**/*.tsx", | |
| 29 | + ".next/types/**/*.ts", | |
| 30 | + ".next/dev/types/**/*.ts", | |
| 31 | + "**/*.mts" | |
| 32 | + ], | |
| 33 | + "exclude": ["node_modules"] | |
| 34 | +} | |
added
deploy/grafana/dashboards/dash.yml
+6 −0
@@ -0,0 +1,6 @@ | ||
| 1 | +apiVersion: 1 | |
| 2 | +providers: | |
| 3 | + - name: ktrade | |
| 4 | + folder: ktrade | |
| 5 | + type: file | |
| 6 | + options: { path: /etc/grafana/provisioning/dashboards } | |
added
deploy/grafana/dashboards/ktrade-api.json
+5 −0
@@ -0,0 +1,5 @@ | ||
| 1 | +{"title":"ktrade API","panels":[ | |
| 2 | + {"type":"timeseries","title":"HTTP req/s by path","gridPos":{"x":0,"y":0,"w":12,"h":8},"targets":[{"expr":"sum by (path) (rate(ktrade_http_requests_total[1m]))"}]}, | |
| 3 | + {"type":"timeseries","title":"p95 latency","gridPos":{"x":12,"y":0,"w":12,"h":8},"targets":[{"expr":"histogram_quantile(0.95, sum by (le, path) (rate(ktrade_http_request_seconds_bucket[5m])))"}]}, | |
| 4 | + {"type":"timeseries","title":"5xx rate","gridPos":{"x":0,"y":8,"w":12,"h":8},"targets":[{"expr":"sum(rate(ktrade_http_requests_total{status=~\"5..\"}[5m]))"}]} | |
| 5 | +],"schemaVersion":39} | |
added
deploy/grafana/datasources/prom.yml
+6 −0
@@ -0,0 +1,6 @@ | ||
| 1 | +apiVersion: 1 | |
| 2 | +datasources: | |
| 3 | + - name: Prometheus | |
| 4 | + type: prometheus | |
| 5 | + url: http://prometheus:9090 | |
| 6 | + isDefault: true | |
added
deploy/prometheus.yml
+5 −0
@@ -0,0 +1,5 @@ | ||
| 1 | +global: { scrape_interval: 15s } | |
| 2 | +scrape_configs: | |
| 3 | + - job_name: ktrade-api | |
| 4 | + metrics_path: /api/metrics | |
| 5 | + static_configs: [{ targets: ["host.docker.internal:8470"] }] | |
added
docker-compose.yml
+54 −0
@@ -0,0 +1,54 @@ | ||
| 1 | +# Local-first stack for Apple Silicon (arm64 images). `docker compose up -d db redis` then run api/worker/web natively, | |
| 2 | +# or `docker compose --profile app up` to run everything in containers. | |
| 3 | +services: | |
| 4 | + db: | |
| 5 | + image: timescale/timescaledb:latest-pg17 | |
| 6 | + environment: | |
| 7 | + POSTGRES_USER: ktrade | |
| 8 | + POSTGRES_PASSWORD: ktrade | |
| 9 | + POSTGRES_DB: ktrade | |
| 10 | + ports: ["5432:5432"] | |
| 11 | + volumes: ["pgdata:/var/lib/postgresql/data"] | |
| 12 | + healthcheck: { test: ["CMD-SHELL", "pg_isready -U ktrade"], interval: 5s, timeout: 3s, retries: 20 } | |
| 13 | + redis: | |
| 14 | + image: redis:7-alpine | |
| 15 | + ports: ["6379:6379"] | |
| 16 | + command: ["redis-server", "--save", "60", "1", "--appendonly", "no"] | |
| 17 | + api: | |
| 18 | + profiles: ["app"] | |
| 19 | + build: { context: ./apps/api } | |
| 20 | + env_file: .env | |
| 21 | + environment: | |
| 22 | + KTRADE_DATABASE_URL: postgresql://ktrade:ktrade@db:5432/ktrade | |
| 23 | + KTRADE_REDIS_URL: redis://redis:6379/3 | |
| 24 | + KTRADE_API_HOST: 0.0.0.0 | |
| 25 | + command: ["sh", "-c", "ktrade migrate && ktrade api"] | |
| 26 | + ports: ["8470:8470"] | |
| 27 | + depends_on: { db: { condition: service_healthy }, redis: { condition: service_started } } | |
| 28 | + worker: | |
| 29 | + profiles: ["app"] | |
| 30 | + build: { context: ./apps/api } | |
| 31 | + env_file: .env | |
| 32 | + environment: | |
| 33 | + KTRADE_DATABASE_URL: postgresql://ktrade:ktrade@db:5432/ktrade | |
| 34 | + KTRADE_REDIS_URL: redis://redis:6379/3 | |
| 35 | + command: ["ktrade", "worker"] | |
| 36 | + depends_on: [api] | |
| 37 | + web: | |
| 38 | + profiles: ["app"] | |
| 39 | + build: { context: ./apps/web } | |
| 40 | + environment: { API_UPSTREAM: http://api:8470, PORT: "8471" } | |
| 41 | + ports: ["8471:8471"] | |
| 42 | + depends_on: [api] | |
| 43 | + prometheus: | |
| 44 | + profiles: ["monitoring"] | |
| 45 | + image: prom/prometheus:latest | |
| 46 | + volumes: ["./deploy/prometheus.yml:/etc/prometheus/prometheus.yml:ro"] | |
| 47 | + ports: ["9090:9090"] | |
| 48 | + grafana: | |
| 49 | + profiles: ["monitoring"] | |
| 50 | + image: grafana/grafana:latest | |
| 51 | + ports: ["3001:3000"] | |
| 52 | + volumes: ["./deploy/grafana:/etc/grafana/provisioning:ro"] | |
| 53 | +volumes: | |
| 54 | + pgdata: {} | |
added
docs/ARCHITECTURE.md
+32 −0
@@ -0,0 +1,32 @@ | ||
| 1 | +# Architecture | |
| 2 | + | |
| 3 | +``` | |
| 4 | + ┌──────────────┐ SSE /api/market/stream ┌──────────────┐ | |
| 5 | + Browser ──────▶│ Next.js web │──────── rewrite /api ──────▶│ FastAPI api │──▶ Postgres (candles, trades, book, | |
| 6 | + (owner) │ :8471 │ │ :8470 │ runs, audit, risk, paper ledger) | |
| 7 | + └──────────────┘ └──────┬───────┘ | |
| 8 | + │ Redis pub/sub `ktrade:md`, health keys | |
| 9 | + ┌──────────────┐ REST /0/public/* (pairs, OHLC) │ | |
| 10 | + Kraken ───────▶│ ingest worker│────────────────────────────────────┘ | |
| 11 | + REST + WS v2 │ (ktrade worker) WS v2 ticker/book/trade/ohlc → Redis + Postgres | |
| 12 | + └──────────────┘ | |
| 13 | +``` | |
| 14 | + | |
| 15 | +**Event-time semantics.** Candles are stored by open time with a `closed` flag computed from exchange time; | |
| 16 | +strategies only ever see closed bars; the backtester executes at the next bar's open. WS messages carry the | |
| 17 | +exchange timestamp and the local `received_at`; book validity is tracked per update (CRC32 checksum). | |
| 18 | + | |
| 19 | +**Shared interfaces.** `Strategy.run(df) → target exposure` and `RiskGateway.evaluate(intent, portfolio, market)` | |
| 20 | +are the same objects used by the backtester, the paper simulator (Phase 4) and the live adapter (Phase 5). | |
| 21 | +`CostModel` (fees/spread/slippage/impact/participation/latency) is shared between backtest and paper fills. | |
| 22 | + | |
| 23 | +**Modules** (`apps/api/ktrade`): `kraken/` (rest, ws, checksum, models) · `ingestion/` (pairs, backfill, stream) · | |
| 24 | +`market/` (queries, features) · `strategy/` (SDK, plugins, registry) · `backtest/` (costs, engine, metrics, | |
| 25 | +validation, runner) · `risk/` (gateway) · `execution/` (preflight; paper/live to come) · `api/` (auth, routes) · | |
| 26 | +`workers/` (ingest) · `db/` (pool, migrations, audit) · `cli.py`. | |
| 27 | + | |
| 28 | +**Processes in production** (PM2 on the MacLustr node): `crypto-trade-bot-api` (uvicorn), `crypto-trade-bot-worker`, | |
| 29 | +`crypto-trade-bot-web` (next start). Public route: MacLustr Tunnel (Caddy on BHS64) → web :8471 → `/api` rewrite → :8470. | |
| 30 | + | |
| 31 | +**Scaling path.** Ingestion is per-universe; a second worker can own a disjoint universe (Redis channel is shared). | |
| 32 | +Backtests run in-process via `asyncio.create_task`; a queue (Redis/RQ) is the next step when runs get long. | |
added
docs/KRAKEN-API-NOTES.md
+23 −0
@@ -0,0 +1,23 @@ | ||
| 1 | +# Kraken Spot API — facts this code relies on (verified 2026-10-21 on docs.kraken.com) | |
| 2 | + | |
| 3 | +- Base `https://api.kraken.com/0`; public GET `/public/{Time,SystemStatus,Assets,AssetPairs,Ticker,OHLC,Depth,Trades}`; | |
| 4 | + private POST `/private/{Balance,TradeBalance,OpenOrders,ClosedOrders,QueryOrders,TradesHistory,TradeVolume, | |
| 5 | + GetWebSocketsToken,AddOrder,CancelOrder,CancelAll}`. | |
| 6 | +- Signature: `API-Sign = b64(HMAC-SHA512(path + SHA256(nonce + postdata), b64decode(secret)))`, headers `API-Key`, | |
| 7 | + `API-Sign`; nonce strictly increasing uint64 (ms). Test vector reproduced in tests. | |
| 8 | +- Rate limits: private counter 15/20/20 (starter/intermediate/pro) decaying 0.33/0.5/1 per s; ledger/trade-history | |
| 9 | + cost 2. Trading engine limiter is separate (add +1, cancel +8…+1 by order age; thresholds 60/125/180). | |
| 10 | +- `AssetPairs`: `ordermin`, `costmin`, `tick_size`, `pair_decimals`, `lot_decimals`, `cost_decimals`, `status` | |
| 11 | + (`online|cancel_only|post_only|limit_only|reduce_only`), `wsname` (v1 names, e.g. `XBT/USD`), leverage lists. | |
| 12 | + **`fees`/`fees_maker` are deprecated since 2026-09-08 (always `[]`) → use `TradeVolume`.** | |
| 13 | +- `OHLC`: `interval` ∈ {1,5,15,30,60,240,1440,10080,21600}; rows `[time, open, high, low, close, vwap, volume, count]`; | |
| 14 | + `last` = cursor for `since`; **≤ 720 most recent entries, older data not retrievable**; last row is the forming bar. | |
| 15 | +- `Depth`: `count` 1–500. `Trades`: `count` ≤ 1000, `last` is a ns cursor. | |
| 16 | +- `AddOrder`: `ordertype` ∈ market/limit/iceberg/stop-loss/take-profit/…; `timeinforce` GTC/IOC/GTD/FOK; `oflags` | |
| 17 | + `post` for post-only; `cl_ord_id` (UUID or ≤18 ascii) XOR `userref`; **`validate=true` validates without trading**. | |
| 18 | +- WebSocket v2 `wss://ws.kraken.com/v2`: `{"method":"subscribe","params":{"channel":"ticker|book|trade|ohlc", | |
| 19 | + "symbol":["BTC/USD"],…}}`; book `depth` ∈ {10,25,100,500,1000}, `checksum` = CRC32 over top-10 asks (asc) then | |
| 20 | + bids (desc) with `price`/`qty` strings stripped of `.` and leading zeros; heartbeat ~1/s when idle; **one ohlc | |
| 21 | + interval per symbol per connection** (observed `Already subscribed to one ohlc interval on this symbol`). | |
| 22 | +- No Spot sandbox exists. Private WS (`wss://ws-auth.kraken.com/v2`, token from `GetWebSocketsToken`, ~15 min to | |
| 23 | + first use) is not used yet (Phase 4/5). | |
added
docs/RESEARCH-REPORTS.md
+19 −0
@@ -0,0 +1,19 @@ | ||
| 1 | +# Research reports | |
| 2 | + | |
| 3 | +Every backtest run stores a machine-readable report in `backtest_runs` (`metrics`, `provenance`, `artifacts`) and is | |
| 4 | +rendered at `/reports/<id>` (printable) with CSV (`/api/backtests/<id>/trades.csv`) and JSON (`/api/backtests/<id>`). | |
| 5 | + | |
| 6 | +A report answers, for one strategy × symbol × interval: | |
| 7 | +1. **Economic hypothesis** and failure modes (from `StrategyMeta`). | |
| 8 | +2. **Universe & data coverage**: window, number of closed bars, dataset hash, gaps. | |
| 9 | +3. **Parameters** (validated against the `ParamSpec`s) and the cost model actually used. | |
| 10 | +4. **In-sample performance** with uncertainty: block-bootstrap CI on Sharpe/CAGR, deflated Sharpe given the number | |
| 11 | + of configurations tried (`n_trials`). | |
| 12 | +5. **Out-of-sample**: holdout segment (never used for selection) and walk-forward folds (train → test, embargo). | |
| 13 | +6. **Implementation costs**: fee drag, slippage drag, turnover, partial fills, rejected orders. | |
| 14 | +7. **Stress**: fees ×2, spread ×3, +20 bps slippage, +1 bar latency, liquidity collapse, exchange outage gap, | |
| 15 | + shuffled-trade drawdown distribution. | |
| 16 | +8. **Benchmarks** on the same window and costs: buy & hold, cash, simple momentum, constant-vol long. | |
| 17 | + | |
| 18 | +Strategies stay `experimental: true` until a documented OOS evaluation on ≥ 1 year of data supports them. | |
| 19 | +Current data coverage (accumulating since first deployment) is far too short for any claim — the report says so. | |
added
docs/RUNBOOK.md
+35 −0
@@ -0,0 +1,35 @@ | ||
| 1 | +# Operator runbook | |
| 2 | + | |
| 3 | +## Daily checks | |
| 4 | +- `GET /api/health` → `db: ok`, `redis: ok`, `stream.connected: true`, `stream.last_msg_age < 10`. | |
| 5 | +- `/settings` → Kraken status `online`, clock drift < 2 s; `/market` → data-quality badges green (lag_bars 0–1). | |
| 6 | +- `pm2 ls` on the node: `crypto-trade-bot-api|web|worker` online, restarts not climbing. | |
| 7 | + | |
| 8 | +## Common operations | |
| 9 | +| Task | Command | | |
| 10 | +|---|---| | |
| 11 | +| Apply migrations | `ktrade migrate` | | |
| 12 | +| One-shot OHLC pull | `ktrade backfill` | | |
| 13 | +| Probe key permissions (never trades) | `ktrade preflight` or Settings → Preflight | | |
| 14 | +| Change owner password | `ktrade set-owner-password` → put hash in env → restart api | | |
| 15 | +| Verify audit chain | `ktrade audit-verify` | | |
| 16 | +| Kill switch | Risk Center → toggle (audited) — blocks all new orders; cancels eligible open orders in Phase 4/5 | | |
| 17 | +| Add a pair to the universe | edit `KTRADE_UNIVERSE`, restart worker (pairs refresh immediately) | | |
| 18 | + | |
| 19 | +## Failure modes | |
| 20 | +- **WS disconnected / checksum mismatches climbing**: worker reconnects with backoff; if persistent, restart the | |
| 21 | + worker; check Kraken status page. Backtests are unaffected (REST data). | |
| 22 | +- **Stale candles (lag_bars > 2)**: REST backfill failing → see worker logs `ingest.backfill.error`; rate-limit waits | |
| 23 | + are logged as `kraken.ratelimit.wait`. | |
| 24 | +- **Postgres down**: API health reports `db: error`; gateway defaults to deny; restart `postgresql@17`. | |
| 25 | +- **Exchange outage**: kill switch still blocks new orders locally; *cancellation of resting exchange orders cannot be | |
| 26 | + guaranteed while the exchange is unreachable* (Phase 5 note). | |
| 27 | + | |
| 28 | +## Backups & retention | |
| 29 | +- Postgres: nightly `pg_dump ktrade` (add to PM2 cron on the node); candles are re-accumulable only forward in time — | |
| 30 | + **back up `candles` to keep history**. | |
| 31 | +- Retention: trades 30 d, book samples 14 d (worker `retention_loop`). | |
| 32 | + | |
| 33 | +## Deployment (MacLustr) | |
| 34 | +Manifest `M1M32:~/dispatch/apps/crypto-trade-bot.json`; `mld deploy crypto-trade-bot` (post_sync builds venv + web); | |
| 35 | +route `https://crypto-trade-bot.app → <node>:8471` on BHS64. Remote-first: edit on the node, `git push` to spbgit. | |
added
docs/SPEC.md
+81 −0
@@ -0,0 +1,81 @@ | ||
| 1 | +# Product specification (verbatim, 2026-10-21) | |
| 2 | + | |
| 3 | +## Mission | |
| 4 | +Build a production-quality, self-hosted quantitative algorithmic trading research, backtesting, paper-trading, and execution platform for Kraken. The central product is **systematic algorithmic trading**, not a generic AI chatbot or bot-monitoring dashboard. Deliver a working full-stack web application, reproducible research pipelines, and tested trading services. Prioritize statistical validity, execution realism, capital preservation, security, and observability over speculative claims of profitability. | |
| 5 | + | |
| 6 | +## Non-negotiable operating rules | |
| 7 | +- Begin in offline/backtest and paper-trading modes. Never place live orders unless the human owner separately configures credentials with trading permission, passes preflight checks, and explicitly activates live mode in the UI with a second confirmation. | |
| 8 | +- Do not request or display secret API keys in chat, logs, source control, or browser local storage. Store credentials server-side encrypted using environment-injected secrets or a secret manager. Use separate least-privilege Kraken API keys for market data, account read, and trading. Never enable withdrawal permission. | |
| 9 | +- Implement an independent risk gateway that every order must pass; no model, strategy, or agent can bypass it. Default-deny on stale data, connection loss, reconciliation failure, invalid order, or risk service outage. | |
| 10 | +- Do not claim any strategy is profitable without genuine out-of-sample evidence, realistic fees/slippage and reproducible reports. Do not manufacture results, prices, or historical data. | |
| 11 | +- Check current official Kraken Spot REST/WebSocket documentation for endpoint names, signing, rate limits, minimum sizes, precision, supported pairs, order types, and account jurisdiction. Make exchange capabilities discoverable, not hard-coded assumptions. No leveraged derivatives unless explicitly supported for this account and separately authorized. | |
| 12 | + | |
| 13 | +## Deliverables | |
| 14 | +1. A deployable monorepo with web frontend, Python API, market-data ingestion, research engine, event-driven backtester, paper execution simulator, live execution adapter (disabled by default), risk engine, worker scheduler, PostgreSQL/TimescaleDB storage, Redis messaging, and monitoring. | |
| 15 | +2. A polished responsive web terminal: strategy laboratory, strategy comparison, market explorer, interactive candlesticks and order book, portfolio and risk dashboards, backtest explorer, paper-trading console, execution quality, live status, alerts, audit history, and configuration. | |
| 16 | +3. Working example strategies, unit/integration/property tests, Docker Compose, sample `.env.example`, architecture diagrams, API docs, operator runbook, and reproducible research notebooks. | |
| 17 | +4. A research report for each strategy showing economic hypothesis, universe, data coverage, parameters, in-sample and out-of-sample performance, uncertainty, implementation costs, and failure modes. | |
| 18 | + | |
| 19 | +## Suggested technology | |
| 20 | +- Frontend: Next.js, TypeScript, Tailwind, shadcn/ui, TradingView Lightweight Charts or equivalent, TanStack Query, accessible data tables. | |
| 21 | +- Backend: Python 3.12+, FastAPI, Pydantic, asyncio, SQLAlchemy, PostgreSQL/TimescaleDB, Redis, background workers; typed APIs and WebSockets for streaming. | |
| 22 | +- Quant: NumPy, pandas/Polars, SciPy, statsmodels, scikit-learn, arch, cvxpy, optional PyTorch, LightGBM and XGBoost. Use libraries only where supported and tested. | |
| 23 | +- Observability: structured logs, Prometheus/OpenTelemetry, health checks, Grafana dashboards, alerting, correlation IDs, immutable order audit trail. | |
| 24 | +- Local-first Docker Compose deployment for Apple Silicon Macs; isolate services, benchmark performance, and support future horizontal scaling. Avoid unnecessary Kubernetes complexity. | |
| 25 | + | |
| 26 | +## Quantitative strategy research — implement as plugins | |
| 27 | +Build a strategy SDK with standardized lifecycle: load data, calculate features, generate target positions/orders, estimate uncertainty, assess risk, execute in simulation, and report. Include: | |
| 28 | +- Trend following: time-series momentum, cross-sectional momentum, moving-average ensembles, volatility targeting, breakout signals. | |
| 29 | +- Mean reversion: z-score, Ornstein–Uhlenbeck, half-life estimation, regime filters, transaction-cost-aware entry/exit. | |
| 30 | +- Statistical arbitrage: Engle–Granger/Johansen cointegration, error-correction models, dynamic hedge ratios via Kalman filter, robust spread diagnostics, capacity and borrow/short constraints. If shorting is unavailable, demonstrate spot-feasible alternatives rather than assuming leverage. | |
| 31 | +- Market making: Avellaneda–Stoikov reservation price/spread model, inventory penalties, adverse selection, queue-aware fill models, latency and cancellation controls; explicitly distinguish simulation from real order-book priority. | |
| 32 | +- Volatility and regime: GARCH-family forecasting, realized volatility, change-point detection, hidden Markov models, regime-dependent risk budgets. | |
| 33 | +- ML forecasting: lagged tabular features with LightGBM/XGBoost, probabilistic forecasts, calibrated uncertainty, drift monitoring; optionally temporal deep learning with strong naive baselines. | |
| 34 | +- Portfolio construction: volatility targeting, inverse-volatility, hierarchical risk parity, mean–variance with shrinkage, CVaR optimization, robust constraints, turnover penalties. | |
| 35 | +- Execution: market/limit decision rules, TWAP, VWAP where volume profiles exist, participation caps, adaptive execution using spread/depth, post-trade implementation shortfall analysis. | |
| 36 | +- Optional reinforcement learning sandbox only after baseline strategies are implemented, with explicit non-stationarity and simulation-to-reality warnings. RL never directly controls live execution without independent risk gating. | |
| 37 | + | |
| 38 | +## Data engineering and market microstructure | |
| 39 | +- Ingest official Kraken historical candles, trades, and streaming market data as available; document retention/coverage limitations. For deeper history use explicitly licensed external datasets with provenance. | |
| 40 | +- Persist raw immutable events and normalized bars/order books. Handle reconnects, sequence gaps, deduplication, timestamp normalization, corporate-like symbol changes, delistings, pair metadata, and exchange maintenance. | |
| 41 | +- Record best bid/ask, trades, spread, depth, imbalance, realized volatility, fees, and funding only when actually applicable. Track data quality metrics and missing intervals. | |
| 42 | +- Separate research snapshots from live streams; use event-time semantics, not wall-clock shortcuts. Prevent look-ahead bias from bar closing times and feature joins. | |
| 43 | + | |
| 44 | +## Backtesting and validation standards | |
| 45 | +- Build an event-driven backtester with the same strategy and risk interfaces used by paper/live engines. Explicitly model exchange maker/taker fees, bid/ask spread, slippage, order sizing/rounding, minimum notional, partial fills, rejected orders, latency, and available depth. | |
| 46 | +- No same-bar execution on unavailable information. Respect data availability timestamps and order arrival times. | |
| 47 | +- Implement anchored/rolling walk-forward evaluation, purged/embargoed cross-validation when labels overlap, holdout periods, bootstrap confidence intervals, parameter sensitivity maps, and multiple-testing controls (e.g., deflated Sharpe/probability of backtest overfitting where appropriate). | |
| 48 | +- Benchmark against buy-and-hold, cash, simple momentum, and equal-risk strategies. Report CAGR/annualized return when valid, volatility, Sharpe, Sortino, max drawdown, Calmar, turnover, hit rate, profit factor, tail risk, exposure, capacity, fee drag, and trade-level distributions. | |
| 49 | +- Produce full experiment provenance: code commit, dataset version/hash, parameters, random seeds, evaluation window, cost assumptions, and reproducible artifacts. Never optimize on the final test set. | |
| 50 | +- Add Monte Carlo stress tests, block bootstrap, shuffled trades, spread widening, fee increases, exchange outage, sudden gaps, liquidity collapse, and correlated drawdowns. | |
| 51 | + | |
| 52 | +## Risk and portfolio controls | |
| 53 | +- Enforce per-order and per-symbol size limits, max gross/net exposure, concentration, daily loss, rolling drawdown, portfolio VaR/CVaR guardrails, liquidity participation, max open orders, and minimum available cash. | |
| 54 | +- Circuit breakers for volatility spikes, data staleness, anomalous prices, excessive cancellations, clock drift, API errors, unexpected fills, and reconciliation mismatches. | |
| 55 | +- A global kill switch cancels eligible open orders and blocks new ones; describe what cannot be guaranteed during exchange outages. | |
| 56 | +- Reconcile exchange balances, orders and fills against the internal ledger; ensure idempotent client order identifiers and retry policies that cannot duplicate orders. | |
| 57 | +- Live activation requires owner confirmation, trading-key permission verification, risk-budget selection, small-size canary phase, and visible active-state banner. No unattended auto-enabling. | |
| 58 | + | |
| 59 | +## Web app information architecture | |
| 60 | +- **Overview:** total equity, cash, realized/unrealized P&L, current exposures, risk status, active strategies, exchange connectivity. | |
| 61 | +- **Market Explorer:** symbol search, candles, depth, spread, volume, volatility, data-quality status. | |
| 62 | +- **Strategy Lab:** strategy catalog, hypothesis, editable validated parameters, feature visualization, launch backtests, compare runs. | |
| 63 | +- **Backtest Explorer:** equity/drawdown curves, rolling Sharpe, underwater chart, trade ledger, fees, slippage, sensitivity heatmaps, walk-forward folds, confidence intervals. | |
| 64 | +- **Portfolio Optimizer:** covariance estimation, target weights, constraints, rebalance simulation, turnover and stress scenarios. | |
| 65 | +- **Paper Trading:** real-time virtual positions, orders/fills, strategy state, simulated P&L and realistic fee model. | |
| 66 | +- **Execution Console:** order lifecycle, routing status, Kraken error codes, latency, reconciliation, cancel controls; live tab locked by default. | |
| 67 | +- **Risk Center:** limits, breaches, stress tests, circuit breakers, kill switch and immutable audit history. | |
| 68 | +- **Research Reports:** downloadable HTML/PDF summaries and machine-readable JSON/CSV artifacts. | |
| 69 | +- **Settings:** exchange credentials status (never expose secrets), API permissions, environments, alerts, access control, retention and backup. | |
| 70 | + | |
| 71 | +## Development plan and acceptance criteria | |
| 72 | +Phase 1: Inspect repository, read current official Kraken docs, define architecture and threat model, create project skeleton and Docker Compose. Implement read-only exchange connection, pair metadata, candles, market streams, database and a functioning market dashboard. Include tests. | |
| 73 | +Phase 2: Implement event-driven backtester, realistic fee/fill engine, strategy SDK, momentum and mean-reversion baselines, research dashboard and reproducibility metadata. Prove no-lookahead behavior with tests. | |
| 74 | +Phase 3: Implement advanced strategies (cointegration/Kalman, GARCH/regimes, portfolio optimization, market-making simulation), walk-forward testing, stress scenarios and comparison reports. Mark strategies experimental until validated. | |
| 75 | +Phase 4: Implement paper-trading execution, reconciled simulated ledger, independent risk gateway, alerts, full observability, robust reconnects and failure injection tests. | |
| 76 | +Phase 5: Implement live Kraken adapter behind feature flags and owner-controlled activation. Test against mocks/sandbox capabilities if officially supported, then perform a documented small-size canary only after explicit user authorization. Never place live orders merely because the implementation is complete. | |
| 77 | + | |
| 78 | +For each phase, produce runnable code, migrations, tests, documentation and screenshots if available. Execute tests, linting and type checking; fix failures. Do not stop at an architecture essay or static mockup. Record any unimplemented features and limitations honestly. Prioritize a robust end-to-end vertical slice before adding more models. | |
| 79 | + | |
| 80 | +## Security and compliance | |
| 81 | +Use secure sessions, CSRF protections, rate limits, role-based permissions, encrypted secret storage, audit logs, dependency scanning, and HTTPS at deployment. Restrict API-key IPs where supported. Treat jurisdictional restrictions and Kraken account entitlements as runtime checks, not assumptions. Avoid custody/withdrawal workflows entirely. | |
added
docs/THREAT-MODEL.md
+15 −0
@@ -0,0 +1,15 @@ | ||
| 1 | +# Threat model & security controls | |
| 2 | + | |
| 3 | +| Asset | Threat | Control | | |
| 4 | +|---|---|---| | |
| 5 | +| Kraken API keys | leak via chat/logs/git/browser | env-injected only (PM2 env from the gateway manifest, 0600); never returned by any endpoint; structlog redaction of key-like fields; `.env` git-ignored; separate data/trade keys; withdrawal permission never requested; preflight warns if the data key can trade | | |
| 6 | +| Owner session | brute force, CSRF, XSS | bcrypt (cost 12) owner password; signed HttpOnly SameSite=Lax cookie (Secure in prod); login rate limit 8/min/IP; mutations require same-origin `Origin`/`Referer`; `X-Content-Type-Options`, `Referrer-Policy`; no secrets in localStorage | | |
| 7 | +| Order flow | strategy/agent bypassing limits | every order (backtest/paper/live) goes through `RiskGateway` (pure, default-deny); kill switch in DB + Redis; live adapter behind feature flag + UI double confirmation (Phase 5) | | |
| 8 | +| Data integrity | corrupted book / stale feed | CRC32 checksum on every book update, resubscribe on mismatch; staleness and clock-drift checks in the gateway; gaps recorded, never silently filled | | |
| 9 | +| Audit | tampering with history | append-only `audit_log` with SHA-256 hash chain; `/api/audit` and `ktrade audit-verify` verify the chain | | |
| 10 | +| Network | exposure of internal ports | API binds 127.0.0.1; only the web port is routed through the MacLustr Tunnel (TLS by Caddy); `/api/metrics` restricted to localhost | | |
| 11 | +| Supply chain | malicious deps | pinned minimum versions, `uv`/`pnpm` lockfiles, `ruff`/`mypy`/tests in CI (to add: `pip-audit`, `pnpm audit` in the runbook) | | |
| 12 | +| Jurisdiction / entitlements | assuming capabilities | pair `status`, leverage lists, minimums read live from AssetPairs; `SystemStatus` checked; preflight probes permissions at runtime | | |
| 13 | + | |
| 14 | +Out of scope by design: custody/withdrawal workflows, margin/derivatives, multi-user RBAC (single owner role today — | |
| 15 | +the session carries a `role` claim so additional roles can be added without changing the cookie format). | |
| 16 | ||