SPB Git forge
1commits 1branches 0releases
189.0 KBsize
maindefault branch
2 h agolast push
Python 54.7% TypeScript 42.5% SQL 1.9%

adopt: état déployé sur M3U96b adopté comme source de vérité (remote-first, 2026-10-21)

mld committed 2 h ago (Oct 22, 2026)

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 &gt; 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