Compare commits

..

1 Commits

Author SHA1 Message Date
dependabot[bot] 4987ad0094 chore(deps-dev): Bump typescript from 5.9.3 to 7.0.2
Bumps [typescript](https://github.com/microsoft/TypeScript) from 5.9.3 to 7.0.2.
- [Release notes](https://github.com/microsoft/TypeScript/releases)
- [Commits](https://github.com/microsoft/TypeScript/commits)

---
updated-dependencies:
- dependency-name: typescript
  dependency-version: 7.0.2
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-16 19:02:00 +00:00
45 changed files with 675 additions and 1858 deletions
-95
View File
@@ -1,95 +0,0 @@
# Production deploy: build the image in CI, publish to GHCR, then SSH into the
# Hetzner VPS to pull + restart. The VPS never builds — it only pulls the image
# that passed CI. Rollback = re-run this workflow from an older commit, or
# `docker compose pull` a previous sha-tag on the VPS.
#
# Secrets: VPS_SSH_KEY (dedicated deploy key), VPS_HOST, VPS_USER.
name: VPS Deploy
on:
push:
branches: [main]
workflow_dispatch:
concurrency:
group: vps-deploy
cancel-in-progress: false
env:
IMAGE: ghcr.io/${{ github.repository }}
jobs:
build-push:
name: Build & push to GHCR
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/metadata-action@v5
id: meta
with:
images: ${{ env.IMAGE }}
tags: |
type=raw,value=latest
type=sha,format=long
- uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
deploy:
name: Deploy to VPS
needs: build-push
runs-on: ubuntu-latest
steps:
- name: Install SSH key
uses: webfactory/ssh-agent@v0.9.1
with:
ssh-private-key: ${{ secrets.VPS_SSH_KEY }}
- name: Trust VPS host key
run: |
mkdir -p ~/.ssh
ssh-keyscan -H "${{ secrets.VPS_HOST }}" >> ~/.ssh/known_hosts
- name: Pull image and restart app
run: |
ssh "${{ secrets.VPS_USER }}@${{ secrets.VPS_HOST }}" bash -s <<'EOF'
set -euo pipefail
cd /root/projects/arbpulse
git fetch origin main
git checkout main
git reset --hard origin/main
cd deploy
docker compose pull app
docker compose up -d app
echo "waiting for health..."
for i in $(seq 1 12); do
if curl -fsS http://127.0.0.1:8080/api/health >/dev/null 2>&1; then
echo "app healthy"
docker image prune -f >/dev/null
exit 0
fi
sleep 5
done
echo "app did not become healthy in time" >&2
docker compose logs --tail 50 app >&2
exit 1
EOF
+12 -11
View File
@@ -41,7 +41,7 @@ Capturas completas (scroll): [`dashboard-live.png`](docs/screenshots/dashboard-l
| Feeds | WebSocket nativo (`ws`) — APIs públicas |
| Frontend | React 18, Vite, Tailwind CSS v3 |
| Estado | In-memory (sin base de datos) |
| Deploy | VPS Hetzner — Docker Compose + GHCR (CI/CD en `vps-deploy.yml`); Fly.io como alternativa manual |
| Deploy | [Fly.io](https://fly.io) — Docker multi-stage (`Dockerfile` + `fly.toml`) |
No se requieren API keys: los feeds de mercado son públicos.
@@ -164,7 +164,6 @@ Consecuencia esperada en feed **real**: la mayoría de divergencias brutas salen
### Robustez en el hot path
- **One trade per tick** — si varios pares confirman en el mismo tick, solo se ejecuta el de mayor `netProfit` (desempate por `netProfitPct` y par lexicográfico).
- **Integridad del libro local** — los feeds delta con ventana top-N (Kraken v2) se truncan al depth suscrito tras cada update (Kraken no manda deletes para niveles expulsados de la ventana); un libro internamente cruzado (bid ≥ ask) nunca se emite al engine: se descarta, se loguea y se fuerza re-sync vía reconexión.
- **Staleness** — quotes más viejos que `STALE_MS` no disparan ejecución.
- **Anti-flicker** — la divergencia debe persistir `FLICKER_CONFIRM_MS` antes de actuar (filtra artefactos de latencia).
- **Partial fills** — volumen limitado por profundidad del libro e inventario de wallet.
@@ -308,19 +307,21 @@ Contrato generado desde Zod (`src/interfaces/http/schemas/`) con `@asteasolution
---
## Deploy (producción: VPS Hetzner + GHCR)
## Deploy en Fly.io
Producción corre en una **VPS de Hetzner** (`https://arbpulse.wayool.com`) con Docker Compose detrás de nginx. El deploy es **automático**:
El repo incluye `Dockerfile` (multi-stage: build Vite + runtime Node), `fly.toml` y el workflow `.github/workflows/fly-deploy.yml`.
1. Push/merge a `main` dispara `.github/workflows/vps-deploy.yml`.
2. GitHub Actions buildea la imagen (`Dockerfile` multi-stage) y la publica en **GHCR** (`ghcr.io/mauricioabh/arbpulse`, tags `latest` + `sha-<commit>`).
3. El workflow entra por SSH a la VPS, hace `docker compose pull` + `up -d` y espera el health check (`GET /api/health`).
1. Instala [flyctl](https://fly.io/docs/flyctl/install/) e inicia sesión: `fly auth login`.
2. Desde la raíz del repo: `fly launch` (primera vez) o `fly deploy`.
3. **Región primaria:** `sin` (Singapore) — menor latencia a matching engines de Binance, Bybit y OKX.
4. Fly expone el servicio en el puerto interno **8080** con healthcheck `GET /api/health`.
5. Variables de entorno: opcionales; `fly.toml` incluye defaults razonables (`STALE_MS`, inventario inicial, etc.).
6. **CI/CD:** cada push a `main` dispara deploy automático vía GitHub Actions (`fly-deploy.yml`; requiere secret `FLY_API_TOKEN`). Los PRs pasan `CI / quality` (typecheck, test, build) sin desplegar.
7. Abre la URL pública: badge **LIVE**, cuatro venues en la price matrix, y —en mercado real— sobre todo oportunidades rechazadas por fees (esperado).
Detalles, bootstrap manual y rollback: [`deploy/README.md`](deploy/README.md).
> Producción usa **Fly.io**. `railway.json` es un artefacto legacy del deploy anterior; no lo uses.
> Fly.io queda como opción alternativa manual (`fly-deploy.yml` vía workflow_dispatch, requiere `FLY_API_TOKEN`; región recomendada `sin`). `railway.json` es un artefacto legacy; no lo uses.
Para una demo con actividad visible en pocos segundos, define `DEMO_MODE=true` en `deploy/.env` de la VPS o actívalo desde Controls.
Para una demo con actividad visible en pocos segundos, define `DEMO_MODE=true` en Fly (`fly secrets set` / `[env]` en `fly.toml`) o actívalo desde Controls.
---
-34
View File
@@ -1,34 +0,0 @@
# Copy to ./.env on the VPS and fill in. Never commit the real .env.
# Domain for Caddy automatic HTTPS. Its Cloudflare DNS A record must point to this
# VPS IP as "DNS only" (grey cloud) so Caddy can issue the Let's Encrypt cert and
# SSE (/api/stream) is not buffered by Cloudflare's proxy.
DOMAIN=arbpulse.wayool.com
NODE_ENV=production
PORT=8080
# Engine tuning (mirrors render.yaml production defaults)
STALE_MS=3000
FLICKER_CONFIRM_MS=150
MIN_NET_PROFIT_PCT=0.0005
MAX_TRADE_BTC=0.25
LATENCY_MS=120
LATENCY_SLIPPAGE_BPS=2
CIRCUIT_BREAKER_LOSSES=5
CIRCUIT_BREAKER_COOLDOWN_MS=15000
INITIAL_USDT=50000
INITIAL_BTC=0.5
DEMO_MODE=false
RECORD_FEED=false
# Observability. Error monitoring is on when SENTRY_DSN is set.
# Leave SENTRY_TRACING=false for 24/7 (keeps spans off the free-tier quota).
SENTRY_DSN=
SENTRY_TRACING=false
LOG_LEVEL=info
# Upstash (optional): rate limiting on /api/* + short-TTL snapshot cache.
# Leave empty to disable both features.
UPSTASH_REDIS_REST_URL=
UPSTASH_REDIS_REST_TOKEN=
-9
View File
@@ -1,9 +0,0 @@
# Automatic HTTPS via Let's Encrypt. DOMAIN comes from ./.env (docker compose
# substitutes ${DOMAIN}). Point the domain's DNS A record to this VPS first.
{$DOMAIN} {
# flush_interval -1 disables response buffering so SSE (/api/stream) streams
# in real time. Do NOT gzip event-stream responses (would buffer them).
reverse_proxy app:8080 {
flush_interval -1
}
}
-130
View File
@@ -1,130 +0,0 @@
# Deploy en VPS (Hetzner) — Arb Pulse
Despliegue con Docker Compose para correr 24/7 desde la rama `main`. El contenedor
`app` (Node + tsx) queda en `127.0.0.1:8080` y un **reverse proxy del host** expone
HTTPS.
## Deploy automático (CI/CD con GHCR) — flujo normal
Cada push/merge a `main` dispara `.github/workflows/vps-deploy.yml`:
1. **Build en GitHub Actions** → imagen publicada en `ghcr.io/mauricioabh/arbpulse`
con tags `latest` + `sha-<commit>` (la VPS nunca buildea).
2. **Deploy por SSH** → en la VPS: `git reset --hard origin/main` (en
`/root/projects/arbpulse`), `docker compose pull app`, `docker compose up -d app`
y espera del health check.
Secrets del repo: `VPS_SSH_KEY` (deploy key dedicada, solo para esto),
`VPS_HOST`, `VPS_USER`. Rollback: re-ejecutar el workflow desde un commit
anterior (`workflow_dispatch`) o en la VPS hacer `docker compose pull` de un
tag `sha-<commit>` previo.
> No editar archivos del repo directamente en la VPS: el deploy hace
> `git reset --hard` y los pisará. La config local vive solo en `deploy/.env`
> (no trackeado).
Lo que sigue abajo es el **camino manual/bootstrap** (primera instalación o
fallback). Dos modos:
- **Opción A — detrás de nginx existente (recomendado en este VPS).** El servidor ya
corre nginx en 80/443 con certbot para otras apps (p. ej. `consumet.wayool.com`,
`openclaw.wayool.com`). Se agrega un vhost para `arbpulse.wayool.com` y punto.
- **Opción B — Caddy bundleado.** Solo para un server **fresco** sin nada en 80/443.
## Prerrequisitos
1. VPS Ubuntu/Debian (recomendado 2 vCPU / 2 GB RAM — p. ej. Hetzner CX22).
2. Subdominio `arbpulse.wayool.com` con registro **A → IP del VPS** (ya creado en
Cloudflare, ver abajo).
3. Docker + plugin compose (el `deploy.sh` los instala si faltan).
## DNS en Cloudflare (zona wayool.com)
- Registro **A**, nombre `arbpulse`, contenido = IP del VPS, **"DNS only" (nube gris)**.
- Motivo: certbot/Caddy emiten el cert vía HTTP-01 (reto directo al origen) y el
SSE de `/api/stream` fluye sin buffering del proxy de Cloudflare.
- Verificar: `dig +short arbpulse.wayool.com` debe devolver la IP del VPS.
---
## Opción A — detrás del nginx existente (recomendado)
No toca tus otras apps ni Caddy. Solo levanta el contenedor `app` en loopback y le
pone un vhost de nginx con certbot (mismo patrón que consumet/openclaw).
```bash
# 1) Traer y correr el script (1ra vez: crea .env y se detiene). App-only por default.
curl -fsSL https://raw.githubusercontent.com/mauricioabh/arbpulse/main/deploy/deploy.sh -o /tmp/arbpulse-deploy.sh
bash /tmp/arbpulse-deploy.sh
# 2) Editar el .env (DOMAIN + secretos opcionales)
nano /root/projects/arbpulse/deploy/.env # DOMAIN=arbpulse.wayool.com ; SENTRY_TRACING=false ; ...
# 3) Volver a correr: build + up del contenedor app (127.0.0.1:8080)
bash /tmp/arbpulse-deploy.sh
# 4) Instalar el vhost de nginx y emitir el cert
sudo cp /root/projects/arbpulse/deploy/nginx/arbpulse.wayool.com.conf /etc/nginx/sites-available/arbpulse.wayool.com
sudo ln -s /etc/nginx/sites-available/arbpulse.wayool.com /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx
sudo certbot --nginx -d arbpulse.wayool.com
```
El vhost (`deploy/nginx/arbpulse.wayool.com.conf`) trae `proxy_buffering off` y
timeouts largos para que el SSE funcione. certbot copia el bloque `location` al
server TLS que genera, así que los ajustes SSE se mantienen en HTTPS.
## Opción B — Caddy bundleado (server fresco, sin nginx)
Solo si nada más usa 80/443:
```bash
cd /root/projects/arbpulse/deploy
cp .env.vps.example .env && nano .env # DOMAIN=arbpulse.wayool.com ...
WITH_CADDY=1 bash /tmp/arbpulse-deploy.sh # o: docker compose --profile caddy up -d --build
```
---
## Verificar
```bash
docker compose -f /root/projects/arbpulse/deploy/docker-compose.yml ps # app healthy/running
curl -s http://127.0.0.1:8080/api/health # local
curl -s https://arbpulse.wayool.com/api/health # público (HTTPS)
```
Dashboard: `https://arbpulse.wayool.com` → badge **LIVE** y los 4 exchanges.
## Variables de entorno
Ver `.env.vps.example`. Claves:
- `DOMAIN``arbpulse.wayool.com` (usado por Caddy en Opción B; informativo en A).
- `SENTRY_DSN` — opcional; activa error monitoring.
- `SENTRY_TRACING` — dejar en `false` (default) para no consumir la cuota free-tier
de spans en operación 24/7.
- `UPSTASH_REDIS_REST_URL` / `UPSTASH_REDIS_REST_TOKEN` — opcionales (rate limit +
cache). Vacío = desactivado.
> Nunca comitees el `.env` real. Solo `.env.vps.example` vive en el repo.
## Operación
```bash
cd /root/projects/arbpulse/deploy
docker compose logs -f app # logs de la app
docker compose restart app # reiniciar
docker compose down # detener (app; agrega --profile caddy si aplica)
bash /tmp/arbpulse-deploy.sh # actualizar manualmente (git pull main + pull GHCR)
BUILD=1 bash /tmp/arbpulse-deploy.sh # fallback: build local en la VPS
```
## Notas
- El puerto 8080 se publica solo en `127.0.0.1` (no expuesto a Internet); el tráfico
público entra por el reverse proxy del host (nginx o Caddy).
- La app ya envía `X-Accel-Buffering: no` en el SSE, que nginx respeta para no
bufferear ese response.
- Región: Hetzner no tiene Asia; para menor latencia a Binance/Bybit/OKX evalúa un
VPS en Singapur. Para Kraken (EU) Falkenstein/Helsinki va bien.
-96
View File
@@ -1,96 +0,0 @@
#!/usr/bin/env bash
# Idempotent VPS deploy for Arb Pulse (Ubuntu/Debian).
# Safe to re-run: installs Docker if missing, clones or fast-forwards the repo,
# pulls the CI-built image from GHCR (or builds locally with BUILD=1) and
# restarts the containers. On first run it creates .env from the example and
# stops so you can fill in DOMAIN + secrets.
#
# NOTE: normal production deploys are automatic — push/merge to `main` triggers
# .github/workflows/vps-deploy.yml (build in CI -> GHCR -> SSH pull + restart).
# This script is the manual/bootstrap path.
#
# Default: runs ONLY the app on 127.0.0.1:8080 (put it behind your host reverse
# proxy — see deploy/nginx/). Set WITH_CADDY=1 to also start the bundled Caddy on
# 80/443 (only for a fresh server with nothing else on those ports).
set -euo pipefail
REPO_URL="${REPO_URL:-https://github.com/mauricioabh/arbpulse.git}"
APP_DIR="${APP_DIR:-/root/projects/arbpulse}"
BRANCH="${BRANCH:-main}"
WITH_CADDY="${WITH_CADDY:-0}"
# BUILD=1 builds the image locally instead of pulling from GHCR (fallback).
BUILD="${BUILD:-0}"
echo "==> Arb Pulse VPS deploy (branch: $BRANCH, dir: $APP_DIR)"
# 1) Docker + compose plugin
if ! command -v docker >/dev/null 2>&1; then
echo "==> Installing Docker Engine..."
curl -fsSL https://get.docker.com | sh
fi
docker --version
if ! docker compose version >/dev/null 2>&1; then
echo "ERROR: 'docker compose' plugin not available. Install docker-compose-plugin." >&2
exit 1
fi
# 2) Repo (clone or fast-forward)
if [ ! -d "$APP_DIR/.git" ]; then
echo "==> Cloning $REPO_URL into $APP_DIR"
sudo mkdir -p "$APP_DIR"
sudo chown "$(id -un)":"$(id -gn)" "$APP_DIR"
git clone --branch "$BRANCH" "$REPO_URL" "$APP_DIR"
else
echo "==> Updating existing repo in $APP_DIR"
git -C "$APP_DIR" fetch origin "$BRANCH"
git -C "$APP_DIR" checkout "$BRANCH"
git -C "$APP_DIR" pull --ff-only origin "$BRANCH"
fi
cd "$APP_DIR/deploy"
# 3) Environment file
if [ ! -f .env ]; then
cp .env.vps.example .env
echo
echo "==> Created $APP_DIR/deploy/.env from the example."
echo " Edit it now (set DOMAIN, and optionally SENTRY_DSN / UPSTASH_*),"
echo " then run this script again to build and start."
exit 0
fi
# 4) Image: pull from GHCR (default) or build locally (BUILD=1)
if [ "$BUILD" = "1" ]; then
echo "==> Building image locally..."
docker compose build app
else
echo "==> Pulling image from GHCR..."
docker compose pull app
fi
# 5) Run
if [ "$WITH_CADDY" = "1" ]; then
echo "==> Starting app + bundled Caddy (ports 80/443)..."
docker compose --profile caddy up -d
else
echo "==> Starting app only (127.0.0.1:8080)..."
docker compose up -d app
fi
echo "==> Waiting for health..."
sleep 8
docker compose ps
echo
if curl -fsS http://127.0.0.1:8080/api/health >/dev/null 2>&1; then
echo "==> App healthy on 127.0.0.1:8080."
else
echo "!! Health check not passing yet. Inspect: docker compose logs -f app"
fi
if [ "$WITH_CADDY" = "1" ]; then
echo "==> Bundled Caddy will issue HTTPS once DNS for \$DOMAIN points here."
echo "==> Verify: curl -s https://\$DOMAIN/api/health"
else
echo "==> App is bound to 127.0.0.1:8080. Put it behind your host reverse proxy:"
echo " see $APP_DIR/deploy/nginx/arbpulse.wayool.com.conf (nginx + certbot)."
fi
-57
View File
@@ -1,57 +0,0 @@
# Arb Pulse — VPS deployment.
#
# Default (`docker compose up -d --build`): runs ONLY the app container, bound to
# 127.0.0.1:8080. Put it behind your existing reverse proxy (nginx/Caddy/Traefik)
# on the host — see deploy/nginx/arbpulse.wayool.com.conf. This is the right mode
# when the host already owns ports 80/443 for other apps.
#
# Optional bundled Caddy (`docker compose --profile caddy up -d --build`): only for
# a FRESH server with nothing else on 80/443; Caddy then handles automatic HTTPS.
#
# Requires a ./.env file (copy from .env.vps.example and fill DOMAIN + secrets).
services:
app:
# Normal flow: the image is built by CI (.github/workflows/vps-deploy.yml)
# and pulled from GHCR. `build:` remains for local/manual builds only
# (docker compose build app).
build:
context: ..
dockerfile: Dockerfile
image: ghcr.io/mauricioabh/arbpulse:latest
container_name: arbpulse
restart: unless-stopped
env_file:
- ./.env
# Bind to loopback only; the host reverse proxy forwards public traffic here.
ports:
- "127.0.0.1:8080:8080"
healthcheck:
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:8080/api/health"]
interval: 30s
timeout: 5s
retries: 3
start_period: 15s
# Opt-in only via `--profile caddy`. Do NOT enable if the host already runs
# nginx/another proxy on 80/443 — the ports would collide.
caddy:
image: caddy:2-alpine
container_name: arbpulse-caddy
restart: unless-stopped
profiles: ["caddy"]
depends_on:
- app
ports:
- "80:80"
- "443:443"
environment:
- DOMAIN=${DOMAIN}
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
volumes:
caddy_data:
caddy_config:
-35
View File
@@ -1,35 +0,0 @@
# Arb Pulse — nginx vhost (host-level), to sit alongside consumet/openclaw.
#
# Install:
# sudo cp deploy/nginx/arbpulse.wayool.com.conf /etc/nginx/sites-available/arbpulse.wayool.com
# sudo ln -s /etc/nginx/sites-available/arbpulse.wayool.com /etc/nginx/sites-enabled/
# sudo nginx -t && sudo systemctl reload nginx
# sudo certbot --nginx -d arbpulse.wayool.com # issues cert + adds the 443 block + HTTP->HTTPS redirect
#
# certbot copies the location block into the TLS server it generates, so the
# SSE-friendly proxy settings below carry over to HTTPS.
server {
listen 80;
listen [::]:80;
server_name arbpulse.wayool.com;
# Proxy everything to the app container (loopback-published by docker compose).
location / {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# SSE (/api/stream): stream in real time, never buffer, keep long-lived.
proxy_buffering off;
proxy_cache off;
proxy_set_header Connection "";
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
chunked_transfer_encoding off;
}
}
-79
View File
@@ -1,79 +0,0 @@
## Context
The dashboard (`web/`, React 18 + Vite 6 + Tailwind v3) is served by Express as
static files from `web/dist` (same origin as `/api` + SSE). `App.tsx` already uses a
`grid-cols-1 lg:grid-cols-3` layout, so it collapses to a single column on mobile,
but two things are missing for a good phone experience: (1) the `PriceMatrix` order
book is a 6-column `<table>` that overflows horizontally on ~360430px screens, and
(2) there is no install/mobile metadata (manifest, theme-color, apple-touch-icon).
The app is real-time: the frontend consumes `/api/state` over SSE and shows a
`connected` badge. A domain rule (honesty) forbids ever presenting stale or fake
market data. The target runtime is the 24/7 VPS behind HTTPS at
`arbpulse.wayool.com` (nginx + certbot, Cloudflare DNS-only).
## Goals / Non-Goals
**Goals:**
- Installable to the phone home screen (standalone, branded icon).
- Responsive, touch-friendly dashboard on phones without regressing desktop.
- Zero risk of showing cached/stale market data.
- No new runtime dependencies; no change to build/deploy steps.
**Non-Goals:**
- Offline support of any kind (no cached app shell, no cached data).
- Push notifications / background sync.
- A native app or app-store packaging.
- Redesigning the dashboard; this is layout + metadata only.
## Decisions
### D1: No service worker (installable via manifest + HTTPS only)
Since Chrome 108 (mobile) / 112 (desktop), a service worker is no longer required to
install from the browser menu / "Add to Home Screen"; iOS Safari never required one.
No-op `fetch` handlers are now actively skipped and warned about by Chrome. Because
this app must never serve cached data, we ship **no service worker at all**.
- **Chosen:** manifest + icons + HTTPS → installable; SSE always fetches live data.
- **Alternatives:** `vite-plugin-pwa`/Workbox with a shell precache (rejected: its
value is offline/precaching, which conflicts with the no-stale-data rule and adds
a dependency); a minimal passthrough SW just to get the auto-install banner
(rejected: Chrome ignores no-op fetch handlers, and it adds a moving part for a
banner we don't need — manual install from the menu is sufficient).
- **Trade-off:** no automatic install prompt on Android; users install from the
browser menu (the only path on iOS anyway). Acceptable.
### D2: PriceMatrix responsive strategy — stacked cards below `sm`
Render per-venue cards (exchange + bid/ask/qty/spread as labelvalue rows) on small
screens and keep the existing `<table>` from `sm` up.
- **Chosen:** Tailwind responsive classes to swap layouts (`block sm:table` pattern
or two branches gated by breakpoint) — no JS, no `matchMedia`, purely CSS.
- **Alternative:** wrap the table in `overflow-x-auto` (rejected as primary: a
6-column price table sideways-scrolling on a phone is poor UX for the primary
widget; horizontal scroll can remain as a defensive fallback).
### D3: Icons derived from one master
Generate a single ArbPulse master icon (pulse line → Bitcoin "B", blue→green on
`#0a0e14`) and derive `icon-192.png`, `icon-512.png`, `icon-maskable-512.png`
(with safe padding), and `apple-touch-icon.png` (180). Store under `web/public/` so
Vite copies them verbatim to `web/dist`.
- **theme_color/background_color:** `#0a0e14` (matches the dashboard background).
### D4: Manifest + head metadata, same-origin
`manifest.webmanifest` uses `start_url: "/"`, `scope: "/"`, `display: "standalone"`.
`index.html` adds `theme-color`, `apple-mobile-web-app-capable`,
`apple-mobile-web-app-status-bar-style`, `apple-mobile-web-app-title`,
`viewport-fit=cover` (already has `width=device-width, initial-scale=1`), and links
to the manifest + apple-touch-icon. Everything is same-origin, so no CORS/proxy
concerns and the existing Express static + SPA fallback serve it unchanged.
## Risks / Trade-offs
- [Stale data if a SW is ever added later] → Documented no-SW decision; if a SW is
introduced in the future it MUST deny-list `/api` and never cache the SSE stream.
- [iOS quirks: no auto-prompt, status-bar/notch rendering] → Provide
`apple-touch-icon`, `apple-mobile-web-app-*`, and `viewport-fit=cover`; verify on a
real iPhone (Add to Home Screen), not just Android.
- [PriceMatrix layout duplication risk] → Keep a single data mapping and switch only
presentation via breakpoints to avoid divergent desktop/mobile logic.
- [Maskable icon getting cropped] → Keep the glyph within the ~80% safe zone in the
maskable variant.
@@ -1,48 +0,0 @@
## Why
The Arb Pulse dashboard is desktop-first: the 3-column grid collapses acceptably on
narrow screens, but the order-book table overflows horizontally and there is no
mobile/PWA metadata. We want to demo and monitor the engine from a phone —
installable to the home screen, full-window, and touch-friendly — while the app
runs 24/7 on the VPS behind HTTPS (`arbpulse.wayool.com`).
## What Changes
- Make the dashboard fully responsive on phones (~360430px): stack the
`PriceMatrix` order book as per-venue cards below `sm`, reflow the `StatsBar`
badges, and ensure touch targets are at least ~44px in `Controls`/`ConfigPanel`.
- Add a Web App Manifest (`web/public/manifest.webmanifest`) so the dashboard is
installable to the home screen (name/short_name, `start_url`, `display: standalone`,
theme/background color, icons).
- Add ArbPulse app icons (192, 512, a maskable variant, and a 180px
`apple-touch-icon`) under `web/public/`.
- Add mobile/install metadata to `web/index.html`: `theme-color`,
`apple-mobile-web-app-*`, `viewport-fit=cover`, and links to the manifest and
apple-touch-icon.
- **No service worker and no offline support.** The app is real-time (SSE); it must
never serve cached/stale market data. Installability comes from the manifest +
HTTPS (menu / "Add to Home Screen"), not from a cached shell. When disconnected
the UI shows its existing "reconnecting/offline" state, never fake data.
## Capabilities
### New Capabilities
- `pwa`: installability and mobile-web behavior of the dashboard — how the app is
installable to the home screen via manifest + HTTPS with no service worker, how
it stays responsive on small screens, and the explicit no-offline / no-stale-data
guarantee for this real-time app.
### Modified Capabilities
<!-- None: responsiveness and install metadata are new behavior; no existing spec's requirements change. -->
## Impact
- Frontend: `web/index.html` (head metadata), `web/public/` (new manifest + icons),
`web/src/components/PriceMatrix.tsx` and `StatsBar.tsx` (responsive layout),
minor touch-target tweaks in `Controls.tsx`/`ConfigPanel.tsx`.
- Build/serve: none — `npm run build` copies `web/public/` into `web/dist`, already
served by Express static; the Docker image bakes it in. No new deploy steps.
- Dependencies: none added (no `vite-plugin-pwa`/Workbox, since there is no SW).
- Behavior: no API, wire-format, or engine changes. Desktop layout unchanged.
- Testing: verified on a real phone at `https://arbpulse.wayool.com` after the
normal `dev → PR → main → deploy.sh` flow.
@@ -1,58 +0,0 @@
## ADDED Requirements
### Requirement: Installable to the home screen
The dashboard SHALL be installable to a device home screen via a Web App Manifest
served over HTTPS, without requiring a service worker.
#### Scenario: Manifest is present and valid
- **WHEN** the dashboard is loaded over HTTPS
- **THEN** a linked `manifest.webmanifest` is served that includes `name`,
`short_name`, `start_url` in scope, `display: standalone`, `theme_color`,
`background_color`, and at least 192px and 512px PNG icons
#### Scenario: User installs from the browser
- **WHEN** a user chooses "Install app" / "Add to Home Screen" from the browser menu
- **THEN** the app is added with the ArbPulse icon and launches in a standalone,
full-window mode at `start_url`
### Requirement: No offline mode and no stale data
The app SHALL NOT register a service worker and SHALL NOT cache market data, so it
never presents stale or fake data (real-time honesty rule).
#### Scenario: No service worker is registered
- **WHEN** the dashboard runs in any environment
- **THEN** no service worker is registered and no application cache stores `/api`
responses or the SSE stream
#### Scenario: Disconnected device shows a clear non-data state
- **WHEN** the device has no working connection to the engine
- **THEN** the UI shows its reconnecting/offline indicator and does not display
cached market figures as if they were live
### Requirement: Responsive mobile layout
The dashboard SHALL be usable on phone-sized viewports (~360430px wide) without
horizontal overflow of primary widgets, while the desktop layout is unchanged.
#### Scenario: Order book fits a phone screen
- **WHEN** the viewport is narrower than the `sm` breakpoint
- **THEN** the order-book (`PriceMatrix`) is presented as per-venue stacked cards
showing bid, ask, quantities, and spread without horizontal scrolling
#### Scenario: Stats and controls remain readable and tappable
- **WHEN** the dashboard is viewed on a phone
- **THEN** the stats bar reflows without clipping and interactive controls have
touch targets of at least ~44px
#### Scenario: Desktop layout is preserved
- **WHEN** the viewport is at the `lg` breakpoint or wider
- **THEN** the existing multi-column layout and table order book render as before
### Requirement: Mobile and install metadata
The `index.html` head SHALL include the metadata needed for correct mobile rendering
and home-screen installation on Android and iOS.
#### Scenario: Head includes mobile/install tags
- **WHEN** the page is served
- **THEN** the head contains a `theme-color`, `viewport-fit=cover` in the viewport
meta, a link to the manifest, an `apple-touch-icon`, and the
`apple-mobile-web-app-*` tags for standalone iOS launch
-39
View File
@@ -1,39 +0,0 @@
## 1. Icons
- [x] 1.1 Finalize the ArbPulse master icon and derive `icon-192.png`,
`icon-512.png`, `icon-maskable-512.png` (glyph within ~80% safe zone), and
`apple-touch-icon.png` (180px)
- [x] 1.2 Place all icons under `web/public/`
## 2. Manifest
- [x] 2.1 Create `web/public/manifest.webmanifest` with `name`, `short_name`,
`start_url: "/"`, `scope: "/"`, `display: "standalone"`,
`theme_color`/`background_color` `#0a0e14`, and icon entries (192, 512, and
512 maskable with `"purpose": "maskable"`)
## 3. HTML head metadata
- [x] 3.1 In `web/index.html`, add `viewport-fit=cover` to the viewport meta and a
`theme-color` meta (`#0a0e14`)
- [x] 3.2 Add `<link rel="manifest">`, `<link rel="apple-touch-icon">`, and the
`apple-mobile-web-app-capable` / `apple-mobile-web-app-status-bar-style` /
`apple-mobile-web-app-title` tags
## 4. Responsive layout
- [x] 4.1 `PriceMatrix.tsx`: render per-venue stacked cards (exchange + bid/ask/qty/
spread as labelvalue rows) below `sm`; keep the `<table>` from `sm` up, with
no horizontal overflow on phones
- [x] 4.2 `StatsBar.tsx`: reflow stats and badges on mobile without clipping
- [x] 4.3 Ensure touch targets in `Controls.tsx` and `ConfigPanel.tsx` are ~44px min
- [x] 4.4 Sanity-check `App.tsx` spacing/padding on narrow viewports
## 5. Verify
- [x] 5.1 `npm run typecheck` and `npm test` pass
- [x] 5.2 `npm run build` succeeds and `web/dist` contains the manifest + icons
- [x] 5.3 DevTools device emulation: no horizontal overflow at ~375px; Application →
Manifest shows no errors; confirm no service worker is registered
- [ ] 5.4 After `dev → PR → main → deploy.sh`, install on a real phone from
`https://arbpulse.wayool.com` and confirm standalone launch + live SSE data
@@ -1,2 +0,0 @@
schema: spec-driven
created: 2026-07-16
@@ -1,2 +0,0 @@
schema: spec-driven
created: 2026-07-19
@@ -1,89 +0,0 @@
# Design — fix-kraken-phantom-book-levels
## Context
Los conectores WS mantienen un `LocalBook` (Map precio→qty por lado) que se
actualiza con snapshots + deltas. Kraken v2 (`book`, depth 10) solo gestiona la
ventana top-10: cuando un nivel sale de la ventana porque entran precios
mejores, **no envía delete** — el cliente debe truncar su copia local tras cada
update (documentado en Kraken WS v2). Hoy `BookSide.apply()` solo borra con
`qty <= 0`, y `toArray()` capea el *display* a `depth` pero el Map conserva los
niveles huérfanos. Como bids se ordenan desc y asks asc, un bid fantasma alto o
un ask fantasma bajo queda **siempre** en el tope del array emitido. Resultado
observado en prod: libro de Kraken cruzado (bid > ask) durante horas y $62.9M
de P&L ficticio.
Agravante: `ExchangeConnector.depth = 15` mientras Kraken se suscribe con
`depth: 10` — hasta el cap de display admite 5 niveles que Kraken jamás va a
actualizar.
## Goals / Non-Goals
**Goals:**
- El libro local de Kraken refleja fielmente la ventana top-10 del exchange.
- Un libro internamente cruzado nunca llega al `ArbitrageEngine`.
- Recuperación automática ante corrupción (re-sync), sin intervención manual.
- Tests unitarios que cubran truncado y guard.
**Non-Goals:**
- Validación del checksum CRC32 de Kraken (mejora futura; el truncado +
guard cubren el fallo observado con mucho menos código).
- Cambios en Bybit/OKX/Binance (OKX y Binance reemplazan el libro completo por
mensaje; Bybit manda deletes explícitos para su ventana de 50).
- Cambios de API REST, SSE o frontend.
## Decisions
1. **`BookSide.truncate()` borra del Map, no solo del display.**
Tras aplicar los updates de un mensaje, se ordena por mejor precio y se
eliminan los niveles más allá de `depth`. Alternativa considerada: pasar de
Map a array ordenado permanente — descartada, complica `apply()` O(1) y el
hot path no lo necesita (depth ≤ 50, truncar tras cada mensaje es barato).
2. **El truncado se invoca desde el conector de Kraken con su depth real (10).**
Es un requisito del protocolo de Kraken, no un comportamiento universal:
Bybit mantiene ventana 50 con deletes explícitos, OKX/Binance resetean por
mensaje. Alternativa: truncar siempre en `emit()` de la base — descartada
porque mezclaría semánticas distintas por exchange y ocultaría el contrato.
3. **Depth por conector.** `ExchangeConnector.depth` pasa a ser sobreescribible
y Kraken lo fija en 10, igual a su suscripción. Se elimina el mismatch 15/10.
4. **Guard de libro cruzado en `emit()` de la base, con auto-recovery.**
Si `bids[0].price >= asks[0].price`: no se emite, se loguea `warn` y se
fuerza re-sync cerrando el socket (`ws.close()` → el reconnect existente
con backoff re-suscribe y Kraken re-manda snapshot). Alternativa: solo
descartar la emisión — descartada porque el libro seguiría corrupto y el
quote se volvería stale silenciosamente; reconectar restaura el dato.
El guard vive en la base porque protege a *todos* los conectores (defensa
en profundidad) y su costo es una comparación por emit.
5. **El estado corrupto acumulado (P&L ficticio) no se migra.** El estado es
in-memory: el redeploy lo limpia automáticamente.
## Risks / Trade-offs
- [Reconexión en bucle si un exchange emitiera libros cruzados legítimos] →
imposible en spot con un libro bien sincronizado; si ocurriera, el backoff
exponencial existente (cap 30s) limita el impacto y el log `warn` lo hace
visible.
- [Truncar en cada mensaje añade un sort O(n log n)] → n ≤ ~20 niveles en
Kraken; despreciable frente al parse JSON del propio mensaje.
- [Sin checksum, otros desyncs sutiles (qty desactualizada dentro de la
ventana) no se detectan] → aceptado; el guard de cruce ataja el caso dañino
y el checksum queda como mejora futura documentada.
## Migration Plan
1. Merge a `dev` → PR → `main`.
2. Deploy a la VPS (redeploy limpia el estado in-memory, P&L vuelve a 0).
3. Verificar en prod: `/api/state` con los 4 exchanges `live`, libro de Kraken
no cruzado, y P&L creciendo de forma realista (mayormente rechazos por fees).
Rollback: revertir el commit; no hay migración de datos.
## Open Questions
- Ninguna bloqueante. Checksum CRC32 de Kraken queda anotado como follow-up.
@@ -1,32 +0,0 @@
# Fix: niveles fantasma en el order book de Kraken
> Issue: [WAY-77](https://linear.app/wayool/issue/WAY-77/arb-niveles-fantasma-en-el-order-book-de-kraken-inflan-el-pandl) — `[ARB] Niveles fantasma en el order book de Kraken inflan el P&L ($62.9M ficticios)`
## Why
En producción el P&L realizado llegó a $62.9M ficticios: el libro local de Kraken quedó **cruzado** (bid 64,925.90 > ask 64,316.20, spread -609) con niveles viejos de hace horas (coinciden con el high/low de 24h de Kraken). El motor vio un arbitraje permanente de ~0.5% vendiendo en Kraken y ejecutó ~674k trades falsos. La causa: el conector de Kraken v2 nunca trunca el libro local al depth suscrito, y el protocolo de Kraken **no envía deletes** para niveles que salen de la ventana top-N — exige que el cliente trunque tras cada update.
## What Changes
- `BookSide` (`src/infrastructure/exchanges/local-book.ts`) gana un método `truncate()` que elimina del Map los niveles fuera de los mejores `depth` precios (no solo en el display).
- El conector de Kraken (`src/infrastructure/exchanges/kraken.ts`) trunca ambos lados tras aplicar cada update, usando el depth suscrito (10).
- Se corrige el mismatch de depth: el conector de Kraken suscribe y mantiene el mismo depth (hoy: base mantiene 15, suscripción pide 10).
- Guard de libro cruzado en `ExchangeConnector.emit()`: si `bids[0].price >= asks[0].price`, no se emite el libro, se loguea y se resetea el libro local para forzar re-sincronización (Kraken re-manda snapshot al reconectar/resuscribir).
- Documentación actualizada: skill `exchange-ws` (nota de truncado obligatorio en Kraken v2) y README si aplica.
## Capabilities
### New Capabilities
- `order-book-integrity`: mantenimiento correcto del libro local por exchange — truncado al depth suscrito en feeds delta (Kraken), detección de libro cruzado como señal de corrupción, y re-sincronización en lugar de emitir datos corruptos al motor.
### Modified Capabilities
<!-- ninguna: `observability` no cambia a nivel de requisitos -->
## Impact
- **Código:** `src/infrastructure/exchanges/local-book.ts`, `src/infrastructure/exchanges/kraken.ts`, `src/infrastructure/exchanges/base.ts`.
- **Tests:** nuevos unit tests de `BookSide.truncate` y del guard de libro cruzado.
- **Comportamiento:** el motor deja de recibir libros corruptos; el P&L vuelve a ser realista (mayormente `rejected · fees`, que es lo correcto en mercados eficientes).
- **Sin cambios de API/contrato REST ni de frontend.** Tras el deploy se requiere un Reset manual del estado para limpiar el P&L ficticio acumulado (estado in-memory: el redeploy ya lo limpia solo).
@@ -1,58 +0,0 @@
# order-book-integrity
## ADDED Requirements
### Requirement: Truncado del libro local al depth suscrito en feeds delta
`BookSide` SHALL exponer una operación `truncate()` que elimine del estado
interno (no solo de la salida) todos los niveles de precio más allá de los
mejores `depth` niveles del lado (bids: precios más altos; asks: precios más
bajos). El conector de Kraken SHALL invocar el truncado en ambos lados tras
aplicar cada mensaje `update`, usando el mismo depth con el que se suscribió
al canal `book`.
#### Scenario: Nivel que sale de la ventana top-N se elimina
- **WHEN** el libro local de bids contiene `depth` niveles y un update añade un
bid con precio mejor que todos los existentes
- **THEN** tras el truncado el nivel con peor precio ya no existe en el estado
interno del `BookSide` y el tamaño del lado es exactamente `depth`
#### Scenario: Bid fantasma no sobrevive al movimiento del mercado
- **WHEN** el precio de mercado baja y sucesivos updates llenan la ventana
top-N con precios inferiores a un bid antiguo que Kraken ya no reporta
- **THEN** el bid antiguo es eliminado por truncado y el mejor bid emitido
refleja la ventana real del exchange
### Requirement: Depth del conector consistente con la suscripción
Cada conector SHALL mantener su libro local con el mismo depth que solicita en
su suscripción. El conector de Kraken SHALL usar depth 10 tanto en el mensaje
de suscripción como en su `LocalBook`.
#### Scenario: Sin niveles residuales por mismatch de depth
- **WHEN** el conector de Kraken arranca y se suscribe al canal `book`
- **THEN** el depth del `LocalBook` es igual al depth de la suscripción (10)
### Requirement: Guard de libro cruzado con re-sincronización
`ExchangeConnector` SHALL detectar antes de emitir cuando el libro normalizado
está internamente cruzado (`bids[0].price >= asks[0].price`). En ese caso el
conector MUST NOT emitir el libro a los listeners, SHALL registrar el evento en
el log, y SHALL forzar una re-sincronización (reset del libro local y
reconexión del WebSocket para recibir un snapshot fresco).
#### Scenario: Libro cruzado no llega al motor
- **WHEN** el libro local de un exchange queda con mejor bid ≥ mejor ask
- **THEN** no se emite ningún `OrderBook` a los listeners y el
`ArbitrageEngine` no evalúa ese libro
#### Scenario: Recuperación automática tras corrupción
- **WHEN** se detecta un libro cruzado
- **THEN** el conector resetea su libro local y fuerza reconexión, y tras el
snapshot de re-suscripción vuelve a emitir libros consistentes sin
intervención manual
@@ -1,26 +0,0 @@
# Tasks — fix-kraken-phantom-book-levels
## 1. LocalBook: truncado real
- [x] Añadir `BookSide.truncate()` que elimine del Map los niveles fuera de los mejores `depth` precios del lado
- [x] Unit tests de `truncate()`: elimina el peor nivel al exceder depth, no toca nada si size <= depth, y el bid fantasma desaparece tras updates sucesivos
## 2. Conector Kraken
- [x] Hacer `ExchangeConnector.depth` sobreescribible por subclase e inicializar `LocalBook` con el depth del conector
- [x] Fijar depth 10 en `KrakenConnector` (igual a la suscripción) y truncar ambos lados tras aplicar cada update
## 3. Guard de libro cruzado
- [x] En `ExchangeConnector.emit()`: si `bids[0].price >= asks[0].price`, no emitir, log warn, reset del libro y reconexión para re-sync
- [x] Unit test del guard: libro cruzado no se emite a listeners y dispara re-sync
## 4. Documentación
- [x] Actualizar skill `exchange-ws` (truncado obligatorio en Kraken v2, guard de cruce en la base, depth por conector)
- [x] Revisar README/AGENTS por menciones al manejo del libro que queden desactualizadas
## 5. Verificación
- [x] `npm run typecheck` + `npm test` en verde
- [x] Arrancar en local con feeds reales y verificar via `/api/state` que Kraken emite libro no cruzado y quotes coherentes con el mercado (matar el proceso al terminar)
-47
View File
@@ -1,47 +0,0 @@
# observability Specification
## Purpose
TBD - created by archiving change gate-sentry-spans. Update Purpose after archive.
## Requirements
### Requirement: Error monitoring is always active
The system SHALL report runtime errors to Sentry whenever `SENTRY_DSN` is configured, independent of any tracing configuration. This includes REST, WebSocket feed, and SSE errors captured via `Sentry.captureException`, plus process-level `unhandledRejection` and `uncaughtException` handlers.
#### Scenario: Error captured with DSN set
- **WHEN** `SENTRY_DSN` is set and a handled error occurs on the WebSocket, REST, or SSE path
- **THEN** the error is sent to Sentry as an error event
#### Scenario: No DSN configured
- **WHEN** `SENTRY_DSN` is empty or unset
- **THEN** Sentry is not initialized and no events are sent, and the application continues running normally
### Requirement: Tracing spans are gated by an environment flag
The system SHALL only create and export OpenTelemetry spans when the `SENTRY_TRACING` environment variable is enabled AND `SENTRY_DSN` is set. The flag SHALL default to disabled so that continuous 24/7 operation emits zero spans by default.
#### Scenario: Tracing disabled by default
- **WHEN** `SENTRY_TRACING` is unset or falsy (default)
- **THEN** the tracer is a no-op, no spans are created on the hot path, and no spans are exported to Sentry
#### Scenario: Tracing enabled with DSN
- **WHEN** `SENTRY_TRACING` is truthy and `SENTRY_DSN` is set
- **THEN** hot-path spans (`ws.message`, `orderbook.process`, `arbitrage.evaluate`, `sse.broadcast`) are created and exported to Sentry, and `tracesSampleRate` is applied
#### Scenario: Tracing enabled without DSN
- **WHEN** `SENTRY_TRACING` is truthy but `SENTRY_DSN` is empty or unset
- **THEN** the tracer is a no-op and no spans are exported, since export requires a DSN
### Requirement: Error capture is preserved when tracing is disabled
The system SHALL continue to report exceptions thrown inside hot-path work wrappers to Sentry even when tracing is disabled, so disabling spans never reduces error visibility.
#### Scenario: Exception in hot-path wrapper with tracing off
- **WHEN** `SENTRY_TRACING` is disabled and code wrapped by the hot-path span helper throws
- **THEN** the exception is still captured and reported to Sentry (as an error), and re-thrown to preserve existing control flow
@@ -1,69 +0,0 @@
# order-book-integrity
## Purpose
Garantizar que el libro local de cada exchange refleje fielmente el estado real
del venue: truncado al depth suscrito en feeds delta con ventana top-N,
detección de libros internamente cruzados como señal de corrupción, y
re-sincronización automática en lugar de emitir datos corruptos al motor de
arbitraje.
Origen: incidente de niveles fantasma en Kraken (Linear WAY-77, change
`fix-kraken-phantom-book-levels`).
## Requirements
### Requirement: Truncado del libro local al depth suscrito en feeds delta
`BookSide` SHALL exponer una operación `truncate()` que elimine del estado
interno (no solo de la salida) todos los niveles de precio más allá de los
mejores `depth` niveles del lado (bids: precios más altos; asks: precios más
bajos). El conector de Kraken SHALL invocar el truncado en ambos lados tras
aplicar cada mensaje `update`, usando el mismo depth con el que se suscribió
al canal `book`.
#### Scenario: Nivel que sale de la ventana top-N se elimina
- **WHEN** el libro local de bids contiene `depth` niveles y un update añade un
bid con precio mejor que todos los existentes
- **THEN** tras el truncado el nivel con peor precio ya no existe en el estado
interno del `BookSide` y el tamaño del lado es exactamente `depth`
#### Scenario: Bid fantasma no sobrevive al movimiento del mercado
- **WHEN** el precio de mercado baja y sucesivos updates llenan la ventana
top-N con precios inferiores a un bid antiguo que Kraken ya no reporta
- **THEN** el bid antiguo es eliminado por truncado y el mejor bid emitido
refleja la ventana real del exchange
### Requirement: Depth del conector consistente con la suscripción
Cada conector SHALL mantener su libro local con el mismo depth que solicita en
su suscripción. El conector de Kraken SHALL usar depth 10 tanto en el mensaje
de suscripción como en su `LocalBook`.
#### Scenario: Sin niveles residuales por mismatch de depth
- **WHEN** el conector de Kraken arranca y se suscribe al canal `book`
- **THEN** el depth del `LocalBook` es igual al depth de la suscripción (10)
### Requirement: Guard de libro cruzado con re-sincronización
`ExchangeConnector` SHALL detectar antes de emitir cuando el libro normalizado
está internamente cruzado (`bids[0].price >= asks[0].price`). En ese caso el
conector MUST NOT emitir el libro a los listeners, SHALL registrar el evento en
el log, y SHALL forzar una re-sincronización (reset del libro local y
reconexión del WebSocket para recibir un snapshot fresco).
#### Scenario: Libro cruzado no llega al motor
- **WHEN** el libro local de un exchange queda con mejor bid ≥ mejor ask
- **THEN** no se emite ningún `OrderBook` a los listeners y el
`ArbitrageEngine` no evalúa ese libro
#### Scenario: Recuperación automática tras corrupción
- **WHEN** se detecta un libro cruzado
- **THEN** el conector resetea su libro local y fuerza reconexión, y tras el
snapshot de re-suscripción vuelve a emitir libros consistentes sin
intervención manual
+615 -329
View File
@@ -35,7 +35,7 @@
"husky": "^9.1.7",
"lint-staged": "^16.4.0",
"prettier": "^3.8.3",
"typescript": "^5.7.3",
"typescript": "^7.0.2",
"typescript-eslint": "^8.26.0"
},
"engines": {
@@ -1181,142 +1181,6 @@
"@types/node": "*"
}
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.61.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.61.0.tgz",
"integrity": "sha512-bFNvl9ZczlVb+wR2Akszf3gHfKVj/8WanXaGJ3UstTA7brNKg0cNdk6X1Psu5V7MZ2oQtzZKOEzIUehaoxbDGw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/regexpp": "^4.12.2",
"@typescript-eslint/scope-manager": "8.61.0",
"@typescript-eslint/type-utils": "8.61.0",
"@typescript-eslint/utils": "8.61.0",
"@typescript-eslint/visitor-keys": "8.61.0",
"ignore": "^7.0.5",
"natural-compare": "^1.4.0",
"ts-api-utils": "^2.5.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"@typescript-eslint/parser": "^8.61.0",
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": {
"version": "7.0.5",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz",
"integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 4"
}
},
"node_modules/@typescript-eslint/parser": {
"version": "8.61.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.61.0.tgz",
"integrity": "sha512-5B7PfA2e1NQGCnDHd/0lW7W3gvp3d59Ryw54FYO8Uswxo9f6ikw3AZV+Xj/TvpImmpsiYyUqAfhC6kJID1jF6w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/scope-manager": "8.61.0",
"@typescript-eslint/types": "8.61.0",
"@typescript-eslint/typescript-estree": "8.61.0",
"@typescript-eslint/visitor-keys": "8.61.0",
"debug": "^4.4.3"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/parser/node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/@typescript-eslint/parser/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true,
"license": "MIT"
},
"node_modules/@typescript-eslint/project-service": {
"version": "8.61.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.61.0.tgz",
"integrity": "sha512-DV42F7MLJO6Rax7SK1yg43tcnEfGUrurSpSxKuVX+a3RCTzBlH3fuxprrOJXKCJGAaw82xXocikJ0uQaqwXgGA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/tsconfig-utils": "^8.61.0",
"@typescript-eslint/types": "^8.61.0",
"debug": "^4.4.3"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/project-service/node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/@typescript-eslint/project-service/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true,
"license": "MIT"
},
"node_modules/@typescript-eslint/scope-manager": {
"version": "8.61.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.61.0.tgz",
@@ -1335,73 +1199,6 @@
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@typescript-eslint/tsconfig-utils": {
"version": "8.61.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.61.0.tgz",
"integrity": "sha512-O5Amvdv9ztMpxpf+vmFULGG78IE6Qwdr3bCGvqwG4nwc9H2qXkOYJJnRbRHyMkQTjv1d03olqwwwzHLMqpFePQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/type-utils": {
"version": "8.61.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.61.0.tgz",
"integrity": "sha512-TuBiQYIkd97yBfInHCTKVYMbX4kvEmpOEuixIuzCU9p8BGT1SfyyO0d0IfDMbPIHcjn/hWnusUX5e8v5Xg+X8A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.61.0",
"@typescript-eslint/typescript-estree": "8.61.0",
"@typescript-eslint/utils": "8.61.0",
"debug": "^4.4.3",
"ts-api-utils": "^2.5.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/type-utils/node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/@typescript-eslint/type-utils/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true,
"license": "MIT"
},
"node_modules/@typescript-eslint/types": {
"version": "8.61.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.61.0.tgz",
@@ -1416,122 +1213,6 @@
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@typescript-eslint/typescript-estree": {
"version": "8.61.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.61.0.tgz",
"integrity": "sha512-42zatd5qSvvcV1JdDBCLxYRznvP4eIHpPoZXdkPFnAmanA4FuZ5dibSnCBggY8hQnqajPpoGjXFdZ7fIJKQnlA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/project-service": "8.61.0",
"@typescript-eslint/tsconfig-utils": "8.61.0",
"@typescript-eslint/types": "8.61.0",
"@typescript-eslint/visitor-keys": "8.61.0",
"debug": "^4.4.3",
"minimatch": "^10.2.2",
"semver": "^7.7.3",
"tinyglobby": "^0.2.15",
"ts-api-utils": "^2.5.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
"version": "5.0.6",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
"version": "10.2.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
"integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"brace-expansion": "^5.0.5"
},
"engines": {
"node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true,
"license": "MIT"
},
"node_modules/@typescript-eslint/utils": {
"version": "8.61.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.61.0.tgz",
"integrity": "sha512-3bzFt7ImFMW/jVYwJamDoe/dMOdFLSC6pom6rRjdh4SZJEYupyMzem8e7vKZLclLfpHjlwSAXOUxtKxGXUiLqA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/eslint-utils": "^4.9.1",
"@typescript-eslint/scope-manager": "8.61.0",
"@typescript-eslint/types": "8.61.0",
"@typescript-eslint/typescript-estree": "8.61.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/visitor-keys": {
"version": "8.61.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.61.0.tgz",
@@ -1563,6 +1244,346 @@
"url": "https://opencollective.com/eslint"
}
},
"node_modules/@typescript/typescript-aix-ppc64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz",
"integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-darwin-arm64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz",
"integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-darwin-x64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz",
"integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-freebsd-arm64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz",
"integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-freebsd-x64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz",
"integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-arm": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz",
"integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-arm64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz",
"integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-loong64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz",
"integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==",
"cpu": [
"loong64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-mips64el": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz",
"integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-ppc64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz",
"integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-riscv64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz",
"integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-s390x": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz",
"integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==",
"cpu": [
"s390x"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-x64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz",
"integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-netbsd-arm64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz",
"integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-netbsd-x64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz",
"integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-openbsd-arm64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz",
"integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-openbsd-x64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz",
"integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-sunos-x64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz",
"integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-win32-arm64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz",
"integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-win32-x64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz",
"integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@upstash/core-analytics": {
"version": "0.0.10",
"resolved": "https://registry.npmjs.org/@upstash/core-analytics/-/core-analytics-0.0.10.tgz",
@@ -3751,9 +3772,9 @@
"license": "BSD-3-Clause"
},
"node_modules/semver": {
"version": "7.8.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz",
"integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==",
"version": "7.8.5",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
"dev": true,
"license": "ISC",
"bin": {
@@ -4187,17 +4208,38 @@
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz",
"integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
"tsc": "bin/tsc"
},
"engines": {
"node": ">=14.17"
"node": ">=16.20.0"
},
"optionalDependencies": {
"@typescript/typescript-aix-ppc64": "7.0.2",
"@typescript/typescript-darwin-arm64": "7.0.2",
"@typescript/typescript-darwin-x64": "7.0.2",
"@typescript/typescript-freebsd-arm64": "7.0.2",
"@typescript/typescript-freebsd-x64": "7.0.2",
"@typescript/typescript-linux-arm": "7.0.2",
"@typescript/typescript-linux-arm64": "7.0.2",
"@typescript/typescript-linux-loong64": "7.0.2",
"@typescript/typescript-linux-mips64el": "7.0.2",
"@typescript/typescript-linux-ppc64": "7.0.2",
"@typescript/typescript-linux-riscv64": "7.0.2",
"@typescript/typescript-linux-s390x": "7.0.2",
"@typescript/typescript-linux-x64": "7.0.2",
"@typescript/typescript-netbsd-arm64": "7.0.2",
"@typescript/typescript-netbsd-x64": "7.0.2",
"@typescript/typescript-openbsd-arm64": "7.0.2",
"@typescript/typescript-openbsd-x64": "7.0.2",
"@typescript/typescript-sunos-x64": "7.0.2",
"@typescript/typescript-win32-arm64": "7.0.2",
"@typescript/typescript-win32-x64": "7.0.2"
}
},
"node_modules/typescript-eslint": {
@@ -4224,6 +4266,250 @@
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/typescript-eslint/node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.61.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.61.0.tgz",
"integrity": "sha512-bFNvl9ZczlVb+wR2Akszf3gHfKVj/8WanXaGJ3UstTA7brNKg0cNdk6X1Psu5V7MZ2oQtzZKOEzIUehaoxbDGw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/regexpp": "^4.12.2",
"@typescript-eslint/scope-manager": "8.61.0",
"@typescript-eslint/type-utils": "8.61.0",
"@typescript-eslint/utils": "8.61.0",
"@typescript-eslint/visitor-keys": "8.61.0",
"ignore": "^7.0.5",
"natural-compare": "^1.4.0",
"ts-api-utils": "^2.5.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"@typescript-eslint/parser": "^8.61.0",
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/typescript-eslint/node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/type-utils": {
"version": "8.61.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.61.0.tgz",
"integrity": "sha512-TuBiQYIkd97yBfInHCTKVYMbX4kvEmpOEuixIuzCU9p8BGT1SfyyO0d0IfDMbPIHcjn/hWnusUX5e8v5Xg+X8A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.61.0",
"@typescript-eslint/typescript-estree": "8.61.0",
"@typescript-eslint/utils": "8.61.0",
"debug": "^4.4.3",
"ts-api-utils": "^2.5.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/typescript-eslint/node_modules/@typescript-eslint/parser": {
"version": "8.61.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.61.0.tgz",
"integrity": "sha512-5B7PfA2e1NQGCnDHd/0lW7W3gvp3d59Ryw54FYO8Uswxo9f6ikw3AZV+Xj/TvpImmpsiYyUqAfhC6kJID1jF6w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/scope-manager": "8.61.0",
"@typescript-eslint/types": "8.61.0",
"@typescript-eslint/typescript-estree": "8.61.0",
"@typescript-eslint/visitor-keys": "8.61.0",
"debug": "^4.4.3"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/typescript-eslint/node_modules/@typescript-eslint/typescript-estree": {
"version": "8.61.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.61.0.tgz",
"integrity": "sha512-42zatd5qSvvcV1JdDBCLxYRznvP4eIHpPoZXdkPFnAmanA4FuZ5dibSnCBggY8hQnqajPpoGjXFdZ7fIJKQnlA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/project-service": "8.61.0",
"@typescript-eslint/tsconfig-utils": "8.61.0",
"@typescript-eslint/types": "8.61.0",
"@typescript-eslint/visitor-keys": "8.61.0",
"debug": "^4.4.3",
"minimatch": "^10.2.2",
"semver": "^7.7.3",
"tinyglobby": "^0.2.15",
"ts-api-utils": "^2.5.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/typescript-eslint/node_modules/@typescript-eslint/typescript-estree/node_modules/@typescript-eslint/project-service": {
"version": "8.61.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.61.0.tgz",
"integrity": "sha512-DV42F7MLJO6Rax7SK1yg43tcnEfGUrurSpSxKuVX+a3RCTzBlH3fuxprrOJXKCJGAaw82xXocikJ0uQaqwXgGA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/tsconfig-utils": "^8.61.0",
"@typescript-eslint/types": "^8.61.0",
"debug": "^4.4.3"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/typescript-eslint/node_modules/@typescript-eslint/typescript-estree/node_modules/@typescript-eslint/tsconfig-utils": {
"version": "8.61.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.61.0.tgz",
"integrity": "sha512-O5Amvdv9ztMpxpf+vmFULGG78IE6Qwdr3bCGvqwG4nwc9H2qXkOYJJnRbRHyMkQTjv1d03olqwwwzHLMqpFePQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/typescript-eslint/node_modules/@typescript-eslint/utils": {
"version": "8.61.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.61.0.tgz",
"integrity": "sha512-3bzFt7ImFMW/jVYwJamDoe/dMOdFLSC6pom6rRjdh4SZJEYupyMzem8e7vKZLclLfpHjlwSAXOUxtKxGXUiLqA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/eslint-utils": "^4.9.1",
"@typescript-eslint/scope-manager": "8.61.0",
"@typescript-eslint/types": "8.61.0",
"@typescript-eslint/typescript-estree": "8.61.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/typescript-eslint/node_modules/balanced-match": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/typescript-eslint/node_modules/brace-expansion": {
"version": "5.0.7",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz",
"integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/typescript-eslint/node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/typescript-eslint/node_modules/ignore": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.6.tgz",
"integrity": "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 4"
}
},
"node_modules/typescript-eslint/node_modules/minimatch": {
"version": "10.2.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
"integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"brace-expansion": "^5.0.5"
},
"engines": {
"node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/typescript-eslint/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true,
"license": "MIT"
},
"node_modules/uncrypto": {
"version": "0.1.3",
"resolved": "https://registry.npmjs.org/uncrypto/-/uncrypto-0.1.3.tgz",
+2 -2
View File
@@ -17,7 +17,7 @@
"test:e2e": "playwright test",
"test:observability": "playwright test observability.spec.ts",
"lint": "eslint .",
"prepare": "husky || true"
"prepare": "husky"
},
"lint-staged": {
"src/**/*.ts": [
@@ -57,7 +57,7 @@
"husky": "^9.1.7",
"lint-staged": "^16.4.0",
"prettier": "^3.8.3",
"typescript": "^5.7.3",
"typescript": "^7.0.2",
"typescript-eslint": "^8.26.0"
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 824 KiB

-44
View File
@@ -1,44 +0,0 @@
# One-off ArbPulse PWA icon generator.
# Resizes the AI-generated master icon into the sizes referenced by the
# Web App Manifest and index.html. Uses only Windows System.Drawing (no deps).
param(
[string]$Master = (Join-Path $PSScriptRoot "arbpulse-icon-master.png"),
[string]$OutDir = (Join-Path $PSScriptRoot "..\web\public")
)
Add-Type -AssemblyName System.Drawing
$bg = [System.Drawing.ColorTranslator]::FromHtml("#0a0e14")
function Save-Resized {
param([System.Drawing.Image]$Src, [int]$Size, [string]$Path, [double]$Scale = 1.0)
$bmp = New-Object System.Drawing.Bitmap($Size, $Size)
$g = [System.Drawing.Graphics]::FromImage($bmp)
$g.InterpolationMode = [System.Drawing.Drawing2D.InterpolationMode]::HighQualityBicubic
$g.SmoothingMode = [System.Drawing.Drawing2D.SmoothingMode]::HighQuality
$g.PixelOffsetMode = [System.Drawing.Drawing2D.PixelOffsetMode]::HighQuality
$g.Clear($bg)
$target = [int]([math]::Round($Size * $Scale))
$offset = [int]([math]::Round(($Size - $target) / 2))
$rect = New-Object System.Drawing.Rectangle($offset, $offset, $target, $target)
$g.DrawImage($Src, $rect)
$g.Dispose()
$bmp.Save($Path, [System.Drawing.Imaging.ImageFormat]::Png)
$bmp.Dispose()
Write-Output "wrote $Path ($Size x $Size, scale $Scale)"
}
$src = [System.Drawing.Image]::FromFile($Master)
Save-Resized -Src $src -Size 192 -Path (Join-Path $OutDir "icon-192.png")
Save-Resized -Src $src -Size 512 -Path (Join-Path $OutDir "icon-512.png")
Save-Resized -Src $src -Size 180 -Path (Join-Path $OutDir "apple-touch-icon.png")
# Maskable: full-bleed on the master's own background. The glyph's built-in
# padding already keeps it inside the ~80% safe zone, and full-bleed avoids a
# visible seam from mismatched flat padding vs. the master's subtle glow.
Save-Resized -Src $src -Size 512 -Path (Join-Path $OutDir "icon-maskable-512.png") -Scale 1.0
$src.Dispose()
-72
View File
@@ -1,72 +0,0 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import type { ExchangeId, OrderBook } from "../../domain/entities/index.js";
import { ExchangeConnector } from "./base.js";
/** Minimal connector for exercising emit() without a real WebSocket. */
class TestConnector extends ExchangeConnector {
readonly id: ExchangeId = "kraken";
protected readonly url = "ws://unused";
resyncCount = 0;
constructor() {
super(5);
}
protected subscribeMessage(): unknown {
return {};
}
protected handleMessage(): void {}
protected override resync(): void {
this.resyncCount += 1;
super.resync();
}
applyLevels(bids: [number, number][], asks: [number, number][]): void {
for (const [price, qty] of bids) this.book.bids.apply(price, qty);
for (const [price, qty] of asks) this.book.asks.apply(price, qty);
}
emitNow(): void {
this.emit(null);
}
get bookSize(): number {
return this.book.bids.size + this.book.asks.size;
}
}
test("normal book is emitted to listeners", () => {
const c = new TestConnector();
const received: OrderBook[] = [];
c.onBook((b) => received.push(b));
c.applyLevels([[64560, 1]], [[64561, 1]]);
c.emitNow();
assert.equal(received.length, 1);
assert.equal(received[0]?.bids[0]?.price, 64560);
assert.equal(c.resyncCount, 0);
});
test("crossed book is not emitted and triggers resync", () => {
const c = new TestConnector();
const received: OrderBook[] = [];
c.onBook((b) => received.push(b));
// Phantom bid above the real ask: corrupted local book.
c.applyLevels(
[
[64925.9, 0.15],
[64560, 1],
],
[[64561, 1]],
);
c.emitNow();
assert.equal(received.length, 0, "corrupted book must not reach listeners");
assert.equal(c.resyncCount, 1);
assert.equal(c.bookSize, 0, "local book is reset for a fresh snapshot");
});
+3 -27
View File
@@ -16,7 +16,7 @@ export type BookListener = (book: OrderBook) => void;
export abstract class ExchangeConnector implements MarketDataFeed {
abstract readonly id: ExchangeId;
protected abstract readonly url: string;
protected readonly depth: number;
protected readonly depth = 15;
protected ws: WebSocket | null = null;
protected book: LocalBook;
@@ -29,10 +29,8 @@ export abstract class ExchangeConnector implements MarketDataFeed {
protected closed = false;
private lastEmitTs = 0;
/** `depth` must match the depth the connector subscribes with. */
constructor(depth = 15) {
this.depth = depth;
this.book = new LocalBook(depth);
constructor() {
this.book = new LocalBook(this.depth);
}
onBook(listener: BookListener): void {
@@ -167,31 +165,9 @@ export abstract class ExchangeConnector implements MarketDataFeed {
exchangeTs,
};
if (book.bids.length === 0 || book.asks.length === 0) return;
// A crossed book (best bid >= best ask) is impossible on a spot venue:
// it means our local copy is corrupted (e.g. phantom levels). Never feed
// it to the engine — drop it and force a fresh snapshot via reconnect.
const bestBid = book.bids[0]!;
const bestAsk = book.asks[0]!;
if (bestBid.price >= bestAsk.price) {
this.log.warn("crossed local book detected, forcing resync", {
bid: bestBid.price,
ask: bestAsk.price,
});
this.resync();
return;
}
for (const listener of this.listeners) listener(book);
}
/** Drop the corrupted local book and reconnect to receive a fresh snapshot. */
protected resync(): void {
this.book.reset();
// close() triggers the existing reconnect-with-backoff path (unless stopped).
this.ws?.close();
}
/** Combined-stream URLs (e.g. Binance) set this to skip the subscribe send. */
protected skipSubscribe(): boolean {
return false;
+1 -11
View File
@@ -19,29 +19,20 @@ interface KrakenMessage {
data?: KrakenBookData[];
}
const KRAKEN_BOOK_DEPTH = 10;
/**
* Kraken WebSocket v2 `book` channel.
* Docs: https://docs.kraken.com/websockets-v2/
* Snapshot replaces the book; updates patch individual price levels (qty 0 = remove).
* Kraken does NOT send deletes for levels evicted from the top-N window: the
* client must truncate its local book to the subscribed depth after every
* update, or evicted levels linger forever as phantom quotes.
*/
export class KrakenConnector extends ExchangeConnector {
readonly id: ExchangeId = "kraken";
protected readonly url = "wss://ws.kraken.com/v2";
private readonly symbol = "BTC/USDT";
constructor() {
super(KRAKEN_BOOK_DEPTH);
}
protected subscribeMessage(): unknown {
return {
method: "subscribe",
params: { channel: "book", symbol: [this.symbol], depth: this.depth },
params: { channel: "book", symbol: [this.symbol], depth: 10 },
};
}
@@ -58,7 +49,6 @@ export class KrakenConnector extends ExchangeConnector {
for (const lvl of data.bids ?? []) this.book.bids.apply(lvl.price, lvl.qty);
for (const lvl of data.asks ?? []) this.book.asks.apply(lvl.price, lvl.qty);
this.book.truncate();
const exchangeTs = data.timestamp ? Date.parse(data.timestamp) : null;
this.emit(Number.isFinite(exchangeTs) ? exchangeTs : null);
@@ -1,82 +0,0 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { BookSide, LocalBook } from "./local-book.js";
test("truncate removes worst bid levels beyond depth", () => {
const bids = new BookSide("bid", 3);
for (const price of [100, 101, 102]) bids.apply(price, 1);
// A better bid arrives; Kraken sends no delete for the evicted 100.
bids.apply(103, 1);
bids.truncate();
assert.equal(bids.size, 3);
assert.deepEqual(
bids.toArray().map((l) => l.price),
[103, 102, 101],
);
});
test("truncate removes worst ask levels beyond depth", () => {
const asks = new BookSide("ask", 3);
for (const price of [100, 101, 102]) asks.apply(price, 1);
asks.apply(99, 1);
asks.truncate();
assert.equal(asks.size, 3);
assert.deepEqual(
asks.toArray().map((l) => l.price),
[99, 100, 101],
);
});
test("truncate is a no-op when size <= depth", () => {
const bids = new BookSide("bid", 5);
bids.apply(100, 1);
bids.apply(101, 2);
bids.truncate();
assert.equal(bids.size, 2);
assert.deepEqual(
bids.toArray().map((l) => l.price),
[101, 100],
);
});
test("phantom high bid disappears across a rally-then-drop (Kraken window)", () => {
const depth = 3;
const book = new LocalBook(depth);
// Snapshot during a rally.
book.bids.apply(64920, 1);
book.bids.apply(64921, 1);
book.bids.apply(64922, 1);
book.truncate();
// Higher bids push the low ones out of Kraken's top-3 window. Kraken sends
// NO delete for the evicted 64920/64921 — only the client-side truncate
// removes them. Without it they linger as phantoms.
book.bids.apply(64924, 1);
book.bids.apply(64925.9, 0.15);
book.truncate();
// Market drops: in-window levels get explicit qty-0 deletes and lower bids
// enter the window.
book.bids.apply(64925.9, 0);
book.bids.apply(64924, 0);
book.bids.apply(64922, 0);
book.bids.apply(64564, 1);
book.bids.apply(64563, 1);
book.bids.apply(64562, 1);
book.truncate();
const top = book.bids.toArray();
assert.equal(top.length, depth);
assert.deepEqual(
top.map((l) => l.price),
[64564, 64563, 64562],
);
assert.ok(
top.every((l) => l.price < 64900),
"no phantom bid survives",
);
});
+2 -27
View File
@@ -8,10 +8,7 @@ import type { Level } from "../../domain/entities/index.js";
export class BookSide {
private levels = new Map<number, number>();
constructor(
private readonly side: "bid" | "ask",
private readonly depth: number,
) {}
constructor(private readonly side: "bid" | "ask", private readonly depth: number) {}
clear(): void {
this.levels.clear();
@@ -25,27 +22,11 @@ export class BookSide {
}
}
/**
* Remove levels beyond the best `depth` prices from the internal map.
* Required by delta feeds that do NOT send deletes for levels evicted from
* their top-N window (e.g. Kraken v2 `book`): without this, evicted levels
* linger forever as phantom quotes.
*/
truncate(): void {
if (this.levels.size <= this.depth) return;
const prices = [...this.levels.keys()].sort((a, b) =>
this.side === "bid" ? b - a : a - b,
);
for (const price of prices.slice(this.depth)) this.levels.delete(price);
}
/** Sorted (bids desc, asks asc) and capped to `depth` levels. */
toArray(): Level[] {
const arr: Level[] = [];
for (const [price, qty] of this.levels) arr.push({ price, qty });
arr.sort((a, b) =>
this.side === "bid" ? b.price - a.price : a.price - b.price,
);
arr.sort((a, b) => (this.side === "bid" ? b.price - a.price : a.price - b.price));
return arr.length > this.depth ? arr.slice(0, this.depth) : arr;
}
@@ -67,10 +48,4 @@ export class LocalBook {
this.bids.clear();
this.asks.clear();
}
/** Truncate both sides to their depth (see BookSide.truncate). */
truncate(): void {
this.bids.truncate();
this.asks.truncate();
}
}
+1 -8
View File
@@ -2,16 +2,9 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Arb Pulse — BTC Arbitrage Engine</title>
<meta name="description" content="Real-time cross-exchange BTC arbitrage detection & simulation" />
<meta name="theme-color" content="#0a0e14" />
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="icon" type="image/png" sizes="192x192" href="/icon-192.png" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Arb Pulse" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 254 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 254 KiB

-31
View File
@@ -1,31 +0,0 @@
{
"name": "Arb Pulse — BTC Arbitrage Engine",
"short_name": "Arb Pulse",
"description": "Real-time cross-exchange BTC arbitrage detection & simulation (Kraken, Bybit, OKX, Binance).",
"start_url": "/",
"scope": "/",
"display": "standalone",
"orientation": "any",
"theme_color": "#0a0e14",
"background_color": "#0a0e14",
"icons": [
{
"src": "/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icon-maskable-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
]
}
+5 -13
View File
@@ -19,16 +19,13 @@ export function App(): JSX.Element {
}, []);
return (
<div className="mx-auto max-w-[1400px] px-4 pb-6 pt-[max(1.5rem,env(safe-area-inset-top))] lg:px-8">
<header className="mb-6 flex items-end justify-between gap-3">
<div className="mx-auto max-w-[1400px] px-4 py-6 lg:px-8">
<header className="mb-6 flex items-end justify-between">
<div>
<h1 className="font-display text-2xl font-extrabold tracking-tight text-slate-100">
Arb<span className="text-accent">Pulse</span>
</h1>
<p className="text-sm text-slate-500">
Real-time cross-exchange BTC arbitrage engine · Kraken · Bybit · OKX
· Binance
</p>
<p className="text-sm text-slate-500">Real-time cross-exchange BTC arbitrage engine · Kraken · Bybit · OKX · Binance</p>
</div>
<p className="hidden text-xs text-slate-600 sm:block">
BTC/USDT · WebSocket feeds · inventory model
@@ -36,9 +33,7 @@ export function App(): JSX.Element {
</header>
{!snapshot ? (
<div className="flex h-[60vh] items-center justify-center text-slate-500">
Connecting to engine
</div>
<div className="flex h-[60vh] items-center justify-center text-slate-500">Connecting to engine</div>
) : (
<div className="space-y-5">
<StatsBar stats={snapshot.stats} connected={connected} />
@@ -52,10 +47,7 @@ export function App(): JSX.Element {
<div className="space-y-5">
<Controls stats={snapshot.stats} config={snapshot.config} />
<ConfigPanel config={snapshot.config} />
<Wallets
wallets={snapshot.wallets}
rebalances={snapshot.rebalances}
/>
<Wallets wallets={snapshot.wallets} rebalances={snapshot.rebalances} />
<OpportunityFeed opportunities={snapshot.recentOpportunities} />
</div>
</div>
+7 -22
View File
@@ -37,21 +37,12 @@ function Toggle({
type="button"
onClick={() => onChange(!on)}
className={clsx(
"flex min-h-[44px] items-center justify-between gap-3 rounded-xl border px-3 py-2 text-left text-sm transition",
modified
? "border-accent/50 bg-accent/5"
: "border-ink-600/50 bg-ink-700/30 hover:border-ink-500",
"flex items-center justify-between gap-3 rounded-xl border px-3 py-2 text-left text-sm transition",
modified ? "border-accent/50 bg-accent/5" : "border-ink-600/50 bg-ink-700/30 hover:border-ink-500",
)}
>
<span className={clsx(modified ? "text-accent" : "text-slate-300")}>
{label}
</span>
<span
className={clsx(
"relative h-5 w-9 rounded-full transition",
on ? "bg-accent" : "bg-ink-500",
)}
>
<span className={clsx(modified ? "text-accent" : "text-slate-300")}>{label}</span>
<span className={clsx("relative h-5 w-9 rounded-full transition", on ? "bg-accent" : "bg-ink-500")}>
<span
className={clsx(
"absolute top-0.5 h-4 w-4 rounded-full bg-white transition-all",
@@ -86,15 +77,11 @@ function SliderRow({
<div
className={clsx(
"rounded-xl border px-3 py-2",
modified
? "border-accent/50 bg-accent/5"
: "border-ink-600/50 bg-ink-700/30",
modified ? "border-accent/50 bg-accent/5" : "border-ink-600/50 bg-ink-700/30",
)}
>
<div className="mb-1 flex items-center justify-between text-sm">
<span className={clsx(modified ? "text-accent" : "text-slate-300")}>
{label}
</span>
<span className={clsx(modified ? "text-accent" : "text-slate-300")}>{label}</span>
<span className="font-mono text-accent">{valueLabel}</span>
</div>
<input
@@ -192,9 +179,7 @@ export function ConfigPanel({ config }: Props): JSX.Element {
/>
<div className="space-y-2">
<p className="text-xs font-medium uppercase tracking-wider text-slate-500">
Active exchanges
</p>
<p className="text-xs font-medium uppercase tracking-wider text-slate-500">Active exchanges</p>
{EXCHANGES.map(({ id, label }) => (
<Toggle
key={id}
+8 -28
View File
@@ -9,28 +9,15 @@ interface Props {
config: PublicConfig;
}
function Toggle({
label,
on,
onChange,
}: {
label: string;
on: boolean;
onChange: (v: boolean) => void;
}): JSX.Element {
function Toggle({ label, on, onChange }: { label: string; on: boolean; onChange: (v: boolean) => void }): JSX.Element {
return (
<button
type="button"
onClick={() => onChange(!on)}
className="flex min-h-[44px] items-center justify-between gap-3 rounded-xl border border-ink-600/50 bg-ink-700/30 px-3 py-2 text-left text-sm transition hover:border-ink-500"
className="flex items-center justify-between gap-3 rounded-xl border border-ink-600/50 bg-ink-700/30 px-3 py-2 text-left text-sm transition hover:border-ink-500"
>
<span className="text-slate-300">{label}</span>
<span
className={clsx(
"relative h-5 w-9 rounded-full transition",
on ? "bg-accent" : "bg-ink-500",
)}
>
<span className={clsx("relative h-5 w-9 rounded-full transition", on ? "bg-accent" : "bg-ink-500")}>
<span
className={clsx(
"absolute top-0.5 h-4 w-4 rounded-full bg-white transition-all",
@@ -54,7 +41,7 @@ export function Controls({ stats, config }: Props): JSX.Element {
<button
type="button"
onClick={() => control.resume()}
className="min-h-[44px] rounded-xl border border-profit/40 bg-profit/10 px-3 py-2 text-sm font-semibold text-profit transition hover:bg-profit/20"
className="rounded-xl border border-profit/40 bg-profit/10 px-3 py-2 text-sm font-semibold text-profit transition hover:bg-profit/20"
>
Resume
</button>
@@ -62,7 +49,7 @@ export function Controls({ stats, config }: Props): JSX.Element {
<button
type="button"
onClick={() => control.pause()}
className="min-h-[44px] rounded-xl border border-warn/40 bg-warn/10 px-3 py-2 text-sm font-semibold text-warn transition hover:bg-warn/20"
className="rounded-xl border border-warn/40 bg-warn/10 px-3 py-2 text-sm font-semibold text-warn transition hover:bg-warn/20"
>
Pause
</button>
@@ -70,17 +57,13 @@ export function Controls({ stats, config }: Props): JSX.Element {
<button
type="button"
onClick={() => control.reset()}
className="min-h-[44px] rounded-xl border border-loss/40 bg-loss/10 px-3 py-2 text-sm font-semibold text-loss transition hover:bg-loss/20"
className="rounded-xl border border-loss/40 bg-loss/10 px-3 py-2 text-sm font-semibold text-loss transition hover:bg-loss/20"
>
Reset
</button>
</div>
<Toggle
label="Demo mode (synthetic feed)"
on={stats.demoMode}
onChange={(v) => control.setDemo(v)}
/>
<Toggle label="Demo mode (synthetic feed)" on={stats.demoMode} onChange={(v) => control.setDemo(v)} />
<div className="rounded-xl border border-ink-600/50 bg-ink-700/30 px-3 py-2">
<div className="mb-1 flex items-center justify-between text-sm">
@@ -106,10 +89,7 @@ export function Controls({ stats, config }: Props): JSX.Element {
<div className="flex justify-between">
<span>Taker fees</span>
<span className="font-mono">
K {pct(config.takerFees.kraken, 2)} · By{" "}
{pct(config.takerFees.bybit, 2)} · O{" "}
{pct(config.takerFees.okx, 2)} · Bn{" "}
{pct(config.takerFees.binance, 2)}
K {pct(config.takerFees.kraken, 2)} · By {pct(config.takerFees.bybit, 2)} · O {pct(config.takerFees.okx, 2)} · Bn {pct(config.takerFees.binance, 2)}
</span>
</div>
<div className="mt-1 flex justify-between">
+8 -100
View File
@@ -50,83 +50,7 @@ export function PriceMatrix({ quotes }: Props): JSX.Element {
) : undefined
}
>
{/* Mobile: per-venue stacked cards (no horizontal overflow). */}
<div className="grid grid-cols-1 gap-3 sm:hidden">
{quotes.map((q) => {
const spread =
q.ask !== null && q.bid !== null ? q.ask - q.bid : null;
return (
<div
key={q.exchange}
className="rounded-xl border border-ink-600/50 bg-ink-700/30 p-3"
>
<div className="mb-2 flex items-center gap-2 font-display text-sm">
<span
className={clsx("h-2 w-2 rounded-full", statusDot(q.status))}
/>
{LABELS[q.exchange]}
</div>
<dl className="grid grid-cols-2 gap-x-4 gap-y-1.5 font-mono text-sm tabular-nums">
<div className="flex items-center justify-between">
<dt className="text-xs uppercase tracking-wider text-slate-500">
Bid
</dt>
<dd
className={clsx(
q.bid !== null && q.bid === maxBid
? "font-semibold text-profit"
: "text-slate-300",
)}
>
{q.bid !== null ? usd(q.bid) : "—"}
</dd>
</div>
<div className="flex items-center justify-between">
<dt className="text-xs uppercase tracking-wider text-slate-500">
Bid Qty
</dt>
<dd className="text-slate-500">
{q.bidQty?.toFixed(3) ?? "—"}
</dd>
</div>
<div className="flex items-center justify-between">
<dt className="text-xs uppercase tracking-wider text-slate-500">
Ask
</dt>
<dd
className={clsx(
q.ask !== null && q.ask === minAsk
? "font-semibold text-accent"
: "text-slate-300",
)}
>
{q.ask !== null ? usd(q.ask) : "—"}
</dd>
</div>
<div className="flex items-center justify-between">
<dt className="text-xs uppercase tracking-wider text-slate-500">
Ask Qty
</dt>
<dd className="text-slate-500">
{q.askQty?.toFixed(3) ?? "—"}
</dd>
</div>
<div className="flex items-center justify-between">
<dt className="text-xs uppercase tracking-wider text-slate-500">
Spread
</dt>
<dd className="text-slate-400">
{spread !== null ? usd(spread) : "—"}
</dd>
</div>
</dl>
</div>
);
})}
</div>
{/* Desktop / tablet: full table from the sm breakpoint up. */}
<div className="hidden overflow-hidden rounded-xl border border-ink-600/50 sm:block">
<div className="overflow-hidden rounded-xl border border-ink-600/50">
<table className="w-full text-sm">
<thead>
<tr className="bg-ink-700/50 text-left text-xs uppercase tracking-wider text-slate-400">
@@ -140,50 +64,34 @@ export function PriceMatrix({ quotes }: Props): JSX.Element {
</thead>
<tbody className="font-mono tabular-nums">
{quotes.map((q) => {
const spread =
q.ask !== null && q.bid !== null ? q.ask - q.bid : null;
const spread = q.ask !== null && q.bid !== null ? q.ask - q.bid : null;
return (
<tr key={q.exchange} className="border-t border-ink-600/40">
<td className="px-4 py-2.5">
<span className="flex items-center gap-2 font-display text-sm">
<span
className={clsx(
"h-2 w-2 rounded-full",
statusDot(q.status),
)}
/>
<span className={clsx("h-2 w-2 rounded-full", statusDot(q.status))} />
{LABELS[q.exchange]}
</span>
</td>
<td
className={clsx(
"px-4 py-2.5 text-right",
q.bid !== null && q.bid === maxBid
? "font-semibold text-profit"
: "text-slate-300",
q.bid !== null && q.bid === maxBid ? "font-semibold text-profit" : "text-slate-300",
)}
>
{q.bid !== null ? usd(q.bid) : "—"}
</td>
<td className="px-4 py-2.5 text-right text-slate-500">
{q.bidQty?.toFixed(3) ?? "—"}
</td>
<td className="px-4 py-2.5 text-right text-slate-500">{q.bidQty?.toFixed(3) ?? "—"}</td>
<td
className={clsx(
"px-4 py-2.5 text-right",
q.ask !== null && q.ask === minAsk
? "font-semibold text-accent"
: "text-slate-300",
q.ask !== null && q.ask === minAsk ? "font-semibold text-accent" : "text-slate-300",
)}
>
{q.ask !== null ? usd(q.ask) : "—"}
</td>
<td className="px-4 py-2.5 text-right text-slate-500">
{q.askQty?.toFixed(3) ?? "—"}
</td>
<td className="px-4 py-2.5 text-right text-slate-400">
{spread !== null ? usd(spread) : "—"}
</td>
<td className="px-4 py-2.5 text-right text-slate-500">{q.askQty?.toFixed(3) ?? "—"}</td>
<td className="px-4 py-2.5 text-right text-slate-400">{spread !== null ? usd(spread) : "—"}</td>
</tr>
);
})}
+11 -46
View File
@@ -6,20 +6,10 @@ interface Props {
connected: boolean;
}
function Stat({
label,
value,
tone,
}: {
label: string;
value: string;
tone?: "profit" | "loss" | "warn";
}): JSX.Element {
function Stat({ label, value, tone }: { label: string; value: string; tone?: "profit" | "loss" | "warn" }): JSX.Element {
return (
<div className="flex flex-col">
<span className="text-[10px] uppercase tracking-widest text-slate-500">
{label}
</span>
<span className="text-[10px] uppercase tracking-widest text-slate-500">{label}</span>
<span
className={clsx(
"font-mono text-lg font-semibold tabular-nums",
@@ -35,23 +25,14 @@ function Stat({
);
}
function circuitTone(c: EngineStats["circuit"]): {
label: string;
cls: string;
} {
function circuitTone(c: EngineStats["circuit"]): { label: string; cls: string } {
switch (c) {
case "running":
return {
label: "RUNNING",
cls: "bg-profit/15 text-profit border-profit/40",
};
return { label: "RUNNING", cls: "bg-profit/15 text-profit border-profit/40" };
case "paused":
return { label: "PAUSED", cls: "bg-warn/15 text-warn border-warn/40" };
case "tripped":
return {
label: "BREAKER TRIPPED",
cls: "bg-loss/15 text-loss border-loss/40",
};
return { label: "BREAKER TRIPPED", cls: "bg-loss/15 text-loss border-loss/40" };
default: {
const _exhaustive: never = c;
return { label: _exhaustive, cls: "" };
@@ -64,45 +45,29 @@ export function StatsBar({ stats, connected }: Props): JSX.Element {
const pnlTone = stats.realizedPnl >= 0 ? "profit" : "loss";
return (
<div className="flex flex-wrap items-center gap-x-6 gap-y-4 rounded-2xl border border-ink-600/60 bg-ink-800/60 px-4 py-4 backdrop-blur-sm sm:gap-x-8 sm:px-5">
<Stat
label="Realized P&L"
value={`$${usd(stats.realizedPnl)}`}
tone={pnlTone}
/>
<div className="flex flex-wrap items-center gap-x-8 gap-y-4 rounded-2xl border border-ink-600/60 bg-ink-800/60 px-5 py-4 backdrop-blur-sm">
<Stat label="Realized P&L" value={`$${usd(stats.realizedPnl)}`} tone={pnlTone} />
<Stat label="Trades" value={String(stats.tradesExecuted)} />
<Stat label="Opportunities" value={String(stats.opportunitiesDetected)} />
<Stat label="Rejected" value={String(stats.tradesRejected)} tone="warn" />
<Stat label="Ticks" value={stats.ticksProcessed.toLocaleString()} />
<Stat label="Engine /tick" value={`${stats.avgTickMs.toFixed(3)}ms`} />
<div className="flex w-full flex-wrap items-center gap-2 sm:ml-auto sm:w-auto sm:gap-3">
<div className="ml-auto flex items-center gap-3">
{stats.demoMode && (
<span className="rounded-full border border-accent/40 bg-accent/15 px-3 py-1 text-xs font-semibold uppercase tracking-wider text-accent">
Demo / Simulated Feed
</span>
)}
<span
className={clsx(
"rounded-full border px-3 py-1 text-xs font-semibold tracking-wider",
circuit.cls,
)}
>
<span className={clsx("rounded-full border px-3 py-1 text-xs font-semibold tracking-wider", circuit.cls)}>
{circuit.label}
</span>
<span
className={clsx(
"flex items-center gap-2 rounded-full border px-3 py-1 text-xs font-semibold tracking-wider",
connected
? "border-profit/40 bg-profit/10 text-profit"
: "border-loss/40 bg-loss/10 text-loss",
connected ? "border-profit/40 bg-profit/10 text-profit" : "border-loss/40 bg-loss/10 text-loss",
)}
>
<span
className={clsx(
"h-2 w-2 rounded-full",
connected ? "bg-profit" : "bg-loss",
)}
/>
<span className={clsx("h-2 w-2 rounded-full", connected ? "bg-profit" : "bg-loss")} />
{connected ? "LIVE" : "OFFLINE"}
</span>
</div>