Compare commits

..

1 Commits

Author SHA1 Message Date
dependabot[bot] 95a3cf3271 chore(deps): Bump actions/setup-node from 4 to 7
Bumps [actions/setup-node](https://github.com/actions/setup-node) from 4 to 7.
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/v4...v7)

---
updated-dependencies:
- dependency-name: actions/setup-node
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-16 01:34:03 +00:00
78 changed files with 899 additions and 6033 deletions
-15
View File
@@ -22,18 +22,3 @@ DEMO_MODE=false
# Feed recorder (NDJSON to data/) # Feed recorder (NDJSON to data/)
RECORD_FEED=false RECORD_FEED=false
# API docs — Scalar at GET /api-docs (OpenAPI generated from Zod; no env vars required)
# Observability — https://sentry.io (project: arbpulse)
SENTRY_DSN=
# Tracing/spans gate — default off. Error monitoring is always on when SENTRY_DSN
# is set; spans are only created/exported when SENTRY_TRACING is enabled. Kept off
# by default so 24/7 operation stays within Sentry's free-tier span quota.
SENTRY_TRACING=false
# Structured logs (pino); correlation IDs on REST, SSE, and WS hot path
LOG_LEVEL=info
# Upstash Redis — rate limit on /api/* and short-TTL snapshot cache for GET /api/state
UPSTASH_REDIS_REST_URL="https://natural-baboon-144595.upstash.io"
UPSTASH_REDIS_REST_TOKEN="gQAAAAAAAjTTAAIgcDE0MDk4MGZlOWMwN2M0NTdiOWQ5MWYxMjVhOWMyNWVlZQ"
+1 -1
View File
@@ -21,7 +21,7 @@ jobs:
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v7
with: with:
node-version: "20" node-version: "20"
cache: npm cache: npm
-30
View File
@@ -1,30 +0,0 @@
name: CodeQL Security Scan
on:
push:
branches: [main, dev]
pull_request:
branches: [main, dev]
schedule:
- cron: "0 8 * * 1"
jobs:
analyze:
name: Analyze
runs-on: ubuntu-latest
permissions:
actions: read
contents: read
security-events: write
steps:
- uses: actions/checkout@v4
- uses: github/codeql-action/init@v3
with:
languages: javascript-typescript
queries: security-extended
- uses: github/codeql-action/autobuild@v3
- uses: github/codeql-action/analyze@v3
-45
View File
@@ -1,45 +0,0 @@
name: E2E Smoke
on:
push:
branches: [main, dev]
pull_request:
branches: [main, dev]
jobs:
smoke:
name: Playwright dashboard + SSE
runs-on: ubuntu-latest
env:
DEMO_MODE: "true"
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
cache: npm
cache-dependency-path: |
package-lock.json
web/package-lock.json
- run: npm ci
- run: npm --prefix web ci
- run: npm run build
- name: Start server
run: npm start &
- name: Wait for API
run: |
for i in $(seq 1 45); do
curl -sf http://localhost:8080/api/health > /dev/null && echo "Ready after ${i}s" && exit 0
sleep 1
done
echo "::error::Server did not become ready in 45s" && exit 1
- run: npx playwright install --with-deps chromium
- name: Run smoke tests
run: npm run test:e2e
-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
-1
View File
@@ -1 +0,0 @@
npx lint-staged
-6
View File
@@ -1,6 +0,0 @@
{
"semi": true,
"singleQuote": false,
"trailingComma": "all",
"tabWidth": 2
}
+14 -24
View File
@@ -41,7 +41,7 @@ Capturas completas (scroll): [`dashboard-live.png`](docs/screenshots/dashboard-l
| Feeds | WebSocket nativo (`ws`) — APIs públicas | | Feeds | WebSocket nativo (`ws`) — APIs públicas |
| Frontend | React 18, Vite, Tailwind CSS v3 | | Frontend | React 18, Vite, Tailwind CSS v3 |
| Estado | In-memory (sin base de datos) | | 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. 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 ### 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). - **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. - **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). - **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. - **Partial fills** — volumen limitado por profundidad del libro e inventario de wallet.
@@ -302,25 +301,27 @@ Respuestas REST siguen la forma `{ success, data?, error? }`.
| `POST` | `/api/control/record` | `{ "enabled": boolean }` — grabación NDJSON en `data/` | | `POST` | `/api/control/record` | `{ "enabled": boolean }` — grabación NDJSON en `data/` |
| `POST` | `/api/control/threshold` | `{ "pct": number }` (atajo de `PATCH /api/config`) | | `POST` | `/api/control/threshold` | `{ "pct": number }` (atajo de `PATCH /api/config`) |
| `POST` | `/api/control/max-trade` | `{ "btc": number }` (atajo de `PATCH /api/config`) | | `POST` | `/api/control/max-trade` | `{ "btc": number }` (atajo de `PATCH /api/config`) |
| `GET` | `/api-docs` | **Scalar** — documentación interactiva OpenAPI 3.0 | | `GET` | `/api-docs` | **Swagger UI** — documentación interactiva OpenAPI 3.0 |
Contrato generado desde Zod (`src/interfaces/http/schemas/`) con `@asteasolutions/zod-to-openapi`; registro de rutas en `src/interfaces/http/openapi.ts`. Contrato completo y schemas en `src/interfaces/http/openapi.ts`.
--- ---
## 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`. 1. Instala [flyctl](https://fly.io/docs/flyctl/install/) e inicia sesión: `fly auth login`.
2. GitHub Actions buildea la imagen (`Dockerfile` multi-stage) y la publica en **GHCR** (`ghcr.io/mauricioabh/arbpulse`, tags `latest` + `sha-<commit>`). 2. Desde la raíz del repo: `fly launch` (primera vez) o `fly deploy`.
3. El workflow entra por SSH a la VPS, hace `docker compose pull` + `up -d` y espera el health check (`GET /api/health`). 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 Fly (`fly secrets set` / `[env]` en `fly.toml`) o actívalo desde Controls.
Para una demo con actividad visible en pocos segundos, define `DEMO_MODE=true` en `deploy/.env` de la VPS o actívalo desde Controls.
--- ---
@@ -342,17 +343,6 @@ Eso no indica que el bot “no funcione”: indica que el filtro económico es e
--- ---
## Production practices
- **Pre-commit:** Husky runs lint-staged (`eslint --fix`, `prettier --write`) on staged `*.ts` / `*.tsx` in `src/` and `web/src/`.
- **API contracts:** Zod schemas per REST endpoint → OpenAPI via `@asteasolutions/zod-to-openapi` → Scalar UI at `/api-docs`. Request bodies validated with Zod in route handlers.
- **Observability:** `@sentry/node` captures REST, WebSocket feed, and SSE errors (always on when `SENTRY_DSN` is set); `pino` JSON logs with `correlationId` (from `x-request-id` or per book tick); OpenTelemetry spans (`ws.message``orderbook.process``arbitrage.evaluate``sse.broadcast`) export to Sentry via `@sentry/opentelemetry` **only when `SENTRY_TRACING` is enabled** (default off, so 24/7 operation stays within the free-tier span quota). With tracing off the tracer is a no-op — no spans are created or exported — but errors are still captured. Dev probe: `GET /api/debug/sentry`; verify with `npm run test:observability`.
- **Rate limiting & cache:** Upstash sliding-window limits per IP on read/write/SSE routes (`429` + `Retry-After`). Latest `StateSnapshot` cached in Redis (~1s TTL) for `GET /api/state` and refreshed on SSE broadcast. Set `UPSTASH_REDIS_REST_URL` and `UPSTASH_REDIS_REST_TOKEN`. Verify with `npm run test:rate-limit`.
- **CI:** GitHub Actions quality pipeline (typecheck, unit tests, build); Playwright smoke on PRs — dashboard loads and SSE connects (`npm run test:e2e`, workflow `e2e.yml`, `DEMO_MODE=true` in CI).
- **Security scanning:** CodeQL (`.github/workflows/codeql.yml`); Dependabot for npm (root + `web/`) and GitHub Actions.
---
## Licencia ## Licencia
MIT — ver archivo `LICENSE` cuando se añada al repositorio. MIT — ver archivo `LICENSE` cuando se añada al repositorio.
-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;
}
}
-8
View File
@@ -1,8 +0,0 @@
import { test, expect } from "@playwright/test";
/** Asserts the dev Sentry probe responds; does not verify ingest in sentry.io (see README). */
test("sentry dev probe returns ok", async ({ request }) => {
const res = await request.get("/api/debug/sentry");
expect(res.ok()).toBeTruthy();
await expect(res.json()).resolves.toMatchObject({ ok: true });
});
-19
View File
@@ -1,19 +0,0 @@
import { test, expect } from "@playwright/test";
test("dashboard loads", async ({ page }) => {
await page.goto("/");
await expect(page.getByRole("heading", { name: /ArbPulse/i })).toBeVisible();
});
test("SSE stream connects and delivers engine state", async ({ page, request }) => {
const health = await request.get("/api/health");
expect(health.ok()).toBeTruthy();
await page.goto("/");
await expect(page.getByText("Connecting to engine…")).toBeHidden({
timeout: 45_000,
});
await expect(page.getByText("LIVE", { exact: true })).toBeVisible();
await expect(page.getByText("RUNNING")).toBeVisible();
await expect(page.getByText("Realized P&L")).toBeVisible();
});
-31
View File
@@ -1,31 +0,0 @@
import eslint from "@eslint/js";
import reactHooks from "eslint-plugin-react-hooks";
import globals from "globals";
import tseslint from "typescript-eslint";
export default tseslint.config(
{ ignores: ["web/dist/**", "data/**", "**/node_modules/**"] },
{
files: ["src/**/*.ts"],
extends: [eslint.configs.recommended, ...tseslint.configs.recommended],
languageOptions: {
ecmaVersion: 2022,
sourceType: "module",
globals: globals.node,
},
},
{
files: ["web/src/**/*.{ts,tsx}"],
extends: [eslint.configs.recommended, ...tseslint.configs.recommended],
plugins: { "react-hooks": reactHooks },
languageOptions: {
ecmaVersion: 2022,
sourceType: "module",
globals: globals.browser,
parserOptions: { ecmaFeatures: { jsx: true } },
},
rules: {
...reactHooks.configs.recommended.rules,
},
},
);
@@ -1,2 +0,0 @@
schema: spec-driven
created: 2026-07-16
-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,34 +0,0 @@
## Context
`src/instrumentation/otel.ts` builds a tracer that, when `SENTRY_DSN` is set, registers a `NodeTracerProvider` with `SentrySpanProcessor`/`SentryPropagator`, so every `withSpan(...)` call on the hot path creates and exports a span to Sentry. `src/instrumentation/sentry.ts` sets `tracesSampleRate` unconditionally. Running 24/7 on a VPS multiplies span volume against Sentry's free-tier quota (5M spans/month), while error events stay small. We want tracing to remain a debugging option but be off by default.
## Goals / Non-Goals
**Goals:**
- Add a single boolean env var (`SENTRY_TRACING`, default `false`) that turns span creation/export on or off.
- Default behavior emits zero spans and zero hot-path span allocation.
- Preserve error monitoring exactly as-is regardless of the flag.
- No dependency changes; no API/wire changes.
**Non-Goals:**
- Removing OpenTelemetry or the `withSpan` abstraction.
- Making `tracesSampleRate` itself env-configurable (kept as current 0.1 prod / 1 dev when enabled).
- Changing which operations are wrapped.
## Decisions
- **Boolean gate over sample-rate gate.** A boolean `SENTRY_TRACING` that makes the tracer a no-op avoids hot-path span allocation entirely when off (aligns with the "no unnecessary alloc on the hot path" convention). A `tracesSampleRate=0` approach would still register the provider and create spans that are dropped later — more overhead. Alternative considered and rejected.
- **Gate lives in `otel.ts` init.** `initOpenTelemetry()` returns the no-op `trace.getTracer("arbpulse")` unless both `SENTRY_DSN` and `SENTRY_TRACING` are truthy. This keeps `withSpan`'s signature and all call sites unchanged; its `catch` still calls `Sentry.captureException`, so errors are captured even with a no-op span.
- **Conditional `tracesSampleRate` in `sentry.ts`.** Only set `tracesSampleRate` when tracing is enabled; omit it otherwise so Sentry performance is fully off. A shared helper `isTracingEnabled()` reads the flag so both files agree.
- **Parsing.** Reuse the existing truthy convention (`"1"` or `"true"`, case-insensitive) used by `config.ts`'s `bool()` for consistency; implement locally in the instrumentation module since it initializes before `config` import.
## Risks / Trade-offs
- [Someone enables tracing in prod and hits the quota] → Documented in `.env.example`/README that it is off by default and why; enabling is an explicit opt-in.
- [Flag read in two files could drift] → Centralize in one `isTracingEnabled()` helper exported from the instrumentation layer.
- [No-op tracer still wraps hot path in a promise] → Accepted: `withSpan` remains async as today; the change only removes span export, matching prior behavior when `SENTRY_DSN` was unset.
## Migration Plan
- Deploy with `SENTRY_TRACING` unset → tracing off (safe default), errors still reported.
- To debug, set `SENTRY_TRACING=true` (with `SENTRY_DSN`) temporarily; unset to roll back. No data migration.
@@ -1,25 +0,0 @@
## Why
Arb Pulse is moving to a 24/7 VPS (Hetzner), where the engine runs continuously and emits OpenTelemetry spans on the hot path (`ws.message``orderbook.process``arbitrage.evaluate``sse.broadcast`). At that volume the app can approach or exceed Sentry's free-tier span quota (5M spans/month), while error monitoring stays well within limits. We want to keep tracing available for debugging but off by default so continuous operation is free-tier safe.
## What Changes
- Introduce an environment variable (`SENTRY_TRACING`, boolean, default `false`) that gates OpenTelemetry span creation and export to Sentry.
- When `SENTRY_TRACING` is disabled (default), the tracer is a no-op: no spans are created or exported, and no hot-path span allocation occurs. Error capture (`Sentry.captureException`, `unhandledRejection`, `uncaughtException`) remains fully active.
- When `SENTRY_TRACING` is enabled **and** `SENTRY_DSN` is set, spans are created and exported to Sentry as today, and `tracesSampleRate` is applied.
- Document the new variable in `.env.example` and `README.md`.
## Capabilities
### New Capabilities
- `observability`: error monitoring and optional distributed tracing behavior — how Sentry error capture is always on, and how OpenTelemetry span emission is conditioned on an environment flag.
### Modified Capabilities
<!-- None: no existing specs under openspec/specs/. -->
## Impact
- Code: `src/instrumentation/otel.ts` (gate span provider/export), `src/instrumentation/sentry.ts` (conditional `tracesSampleRate`), possibly `src/instrumentation/index.ts`.
- Config/docs: `.env.example`, `README.md` (observability section + env var table).
- Dependencies: none added or removed (OTel packages stay for the enabled path).
- Behavior: default runtime emits zero spans; error monitoring unchanged. No API or wire-format changes.
@@ -1,43 +0,0 @@
## ADDED 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,16 +0,0 @@
## 1. Tracing gate
- [x] 1.1 Add `isTracingEnabled()` helper (reads `SENTRY_TRACING`, truthy = `"1"`/`"true"` case-insensitive) exported from the instrumentation layer
- [x] 1.2 In `src/instrumentation/otel.ts`, return the no-op tracer unless both `SENTRY_DSN` and `SENTRY_TRACING` are truthy (keep `withSpan` signature and its `Sentry.captureException` in catch)
- [x] 1.3 In `src/instrumentation/sentry.ts`, only set `tracesSampleRate` when tracing is enabled (omit it otherwise)
## 2. Documentation
- [x] 2.1 Add `SENTRY_TRACING` (default `false`) to `.env.example` with a note it gates spans for free-tier safety
- [x] 2.2 Update the README observability section to describe the flag and default-off behavior
## 3. Verification
- [x] 3.1 `npm run typecheck` passes
- [x] 3.2 `npm test` passes
- [x] 3.3 Manually confirm: with `SENTRY_TRACING` unset, no spans are emitted; error capture path unaffected
@@ -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)
-20
View File
@@ -1,20 +0,0 @@
schema: spec-driven
# Project context (optional)
# This is shown to AI when creating artifacts.
# Add your tech stack, conventions, style guides, domain knowledge, etc.
# Example:
# context: |
# Tech stack: TypeScript, React, Node.js
# We use conventional commits
# Domain: e-commerce platform
# Per-artifact rules (optional)
# Add custom rules for specific artifacts.
# Example:
# rules:
# proposal:
# - Keep proposals under 500 words
# - Always include a "Non-goals" section
# tasks:
# - Break tasks into chunks of max 2 hours
-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
+42 -2910
View File
File diff suppressed because it is too large Load Diff
+6 -38
View File
@@ -12,52 +12,20 @@
"start": "tsx src/index.ts", "start": "tsx src/index.ts",
"build": "npm --prefix web install && npm --prefix web run build", "build": "npm --prefix web install && npm --prefix web run build",
"typecheck": "tsc --noEmit && npm --prefix web run typecheck", "typecheck": "tsc --noEmit && npm --prefix web run typecheck",
"test": "node scripts/run-tests.mjs", "test": "node scripts/run-tests.mjs"
"test:rate-limit": "node --import tsx --test src/interfaces/http/rate-limit.test.ts",
"test:e2e": "playwright test",
"test:observability": "playwright test observability.spec.ts",
"lint": "eslint .",
"prepare": "husky || true"
},
"lint-staged": {
"src/**/*.ts": [
"eslint --fix",
"prettier --write"
],
"web/src/**/*.{ts,tsx}": [
"eslint --fix",
"prettier --write"
]
}, },
"dependencies": { "dependencies": {
"@asteasolutions/zod-to-openapi": "^8.5.0",
"@opentelemetry/api": "^1.9.1",
"@opentelemetry/sdk-trace-node": "^2.7.1",
"@scalar/express-api-reference": "^0.8.48",
"@sentry/node": "^10.57.0",
"@sentry/opentelemetry": "^10.57.0",
"@upstash/ratelimit": "^2.0.6",
"@upstash/redis": "^1.35.3",
"express": "^4.21.2", "express": "^4.21.2",
"pino": "^10.3.1", "swagger-ui-express": "^5.0.1",
"pino-pretty": "^13.1.3",
"tsx": "^4.19.2", "tsx": "^4.19.2",
"ws": "^8.18.0", "ws": "^8.18.0"
"zod": "^4.4.3"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.18.0",
"@playwright/test": "^1.51.1",
"@types/express": "^4.17.21", "@types/express": "^4.17.21",
"@types/node": "^22.10.5", "@types/node": "^22.10.5",
"@types/swagger-ui-express": "^4.1.8",
"@types/ws": "^8.5.13", "@types/ws": "^8.5.13",
"eslint": "^9.18.0", "openapi-types": "^12.1.3",
"eslint-plugin-react-hooks": "^5.1.0", "typescript": "^5.7.3"
"globals": "^15.14.0",
"husky": "^9.1.7",
"lint-staged": "^16.4.0",
"prettier": "^3.8.3",
"typescript": "^5.7.3",
"typescript-eslint": "^8.26.0"
} }
} }
-24
View File
@@ -1,24 +0,0 @@
import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
testDir: "e2e",
fullyParallel: false,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: 1,
timeout: 60_000,
reporter: process.env.CI ? "github" : "html",
use: {
baseURL: "http://localhost:8080",
trace: "on-first-retry",
},
projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }],
...(!process.env.CI && {
webServer: {
command: "npm start",
url: "http://localhost:8080/api/health",
reuseExistingServer: !process.env.PW_FRESH_SERVER,
env: { DEMO_MODE: "true" },
},
}),
});
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()
@@ -1,10 +1,5 @@
import type { OrderBook } from "../../domain/entities/index.js"; import type { OrderBook } from "../../domain/entities/index.js";
import type { ArbitrageEngine } from "../../domain/services/arbitrage-engine.js"; import type { ArbitrageEngine } from "../../domain/services/arbitrage-engine.js";
import { withSpan } from "../../instrumentation/otel.js";
import {
newCorrelationId,
runWithCorrelation,
} from "../../infrastructure/logging/correlation.js";
export interface FeedRecorderPort { export interface FeedRecorderPort {
record(book: OrderBook): void; record(book: OrderBook): void;
@@ -15,30 +10,12 @@ export class ProcessOrderBookUpdate {
constructor( constructor(
private readonly engine: ArbitrageEngine, private readonly engine: ArbitrageEngine,
private readonly recorder: FeedRecorderPort, private readonly recorder: FeedRecorderPort,
private readonly isExchangeActive: ( private readonly isExchangeActive: (exchange: OrderBook["exchange"]) => boolean,
exchange: OrderBook["exchange"],
) => boolean,
) {} ) {}
run(book: OrderBook): void { run(book: OrderBook): void {
if (!this.isExchangeActive(book.exchange)) return; if (!this.isExchangeActive(book.exchange)) return;
this.recorder.record(book);
const correlationId = newCorrelationId("book"); this.engine.onBook(book);
runWithCorrelation({ correlationId, exchange: book.exchange }, () => {
void withSpan(
"orderbook.process",
{ exchange: book.exchange, correlationId },
async () => {
this.recorder.record(book);
await withSpan(
"arbitrage.evaluate",
{ exchange: book.exchange, correlationId },
async () => {
this.engine.onBook(book);
},
);
},
);
});
} }
} }
+7 -19
View File
@@ -1,11 +1,5 @@
import "./load-env.js";
import { initInstrumentation } from "./instrumentation/index.js";
initInstrumentation();
import express from "express"; import express from "express";
import * as Sentry from "@sentry/node"; import swaggerUi from "swagger-ui-express";
import { apiReference } from "@scalar/express-api-reference";
import { existsSync } from "node:fs"; import { existsSync } from "node:fs";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path"; import { dirname, join } from "node:path";
@@ -14,7 +8,7 @@ import { bootstrap, config } from "./composition/bootstrap.js";
import { runtime } from "./infrastructure/config/runtime.js"; import { runtime } from "./infrastructure/config/runtime.js";
import { SseHub } from "./interfaces/sse/sse.js"; import { SseHub } from "./interfaces/sse/sse.js";
import { createRouter } from "./interfaces/http/routes.js"; import { createRouter } from "./interfaces/http/routes.js";
import { openapiDocument } from "./interfaces/http/openapi.js"; import { openapiSpec } from "./interfaces/http/openapi.js";
const log = createLogger("server"); const log = createLogger("server");
@@ -31,15 +25,11 @@ function main(): void {
const server = express(); const server = express();
server.use(express.json()); server.use(express.json());
server.use("/api", createRouter(ctx.application, sse)); server.use("/api", createRouter(ctx.application, sse));
Sentry.setupExpressErrorHandler(server);
server.use( server.use("/api-docs", swaggerUi.serve, swaggerUi.setup(openapiSpec, {
"/api-docs", customSiteTitle: "Arb Pulse — API Docs",
apiReference({ swaggerOptions: { defaultModelsExpandDepth: 2, defaultModelExpandDepth: 2 },
content: openapiDocument, }));
pageTitle: "Arb Pulse — API Docs",
}),
);
if (existsSync(webDist)) { if (existsSync(webDist)) {
server.use(express.static(webDist)); server.use(express.static(webDist));
@@ -50,9 +40,7 @@ function main(): void {
server.get("/", (_req, res) => { server.get("/", (_req, res) => {
res res
.status(200) .status(200)
.send( .send("Backend running. Frontend not built yet — run `npm run build` (or `npm run dev:web`).");
"Backend running. Frontend not built yet — run `npm run build` (or `npm run dev:web`).",
);
}); });
} }
-48
View File
@@ -1,48 +0,0 @@
import { Redis } from "@upstash/redis";
const SNAPSHOT_KEY = "arbpulse:snapshot";
const SNAPSHOT_TTL_SEC = 1;
let redis: Redis | null | undefined;
export function isUpstashConfigured(): boolean {
return Boolean(
process.env.UPSTASH_REDIS_REST_URL?.trim() &&
process.env.UPSTASH_REDIS_REST_TOKEN?.trim(),
);
}
function getRedis(): Redis | null {
if (redis !== undefined) {
return redis;
}
const url = process.env.UPSTASH_REDIS_REST_URL?.trim();
const token = process.env.UPSTASH_REDIS_REST_TOKEN?.trim();
if (!url || !token) {
redis = null;
return null;
}
redis = new Redis({ url, token });
return redis;
}
export async function getCachedSnapshot<T>(): Promise<T | null> {
const client = getRedis();
if (!client) return null;
try {
return (await client.get<T>(SNAPSHOT_KEY)) ?? null;
} catch (err) {
console.warn("[upstash] get snapshot failed", err);
return null;
}
}
export async function setCachedSnapshot<T>(snapshot: T): Promise<void> {
const client = getRedis();
if (!client) return;
try {
await client.set(SNAPSHOT_KEY, snapshot, { ex: SNAPSHOT_TTL_SEC });
} catch (err) {
console.warn("[upstash] set snapshot failed", err);
}
}
-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");
});
+13 -60
View File
@@ -1,7 +1,5 @@
import WebSocket from "ws"; import WebSocket from "ws";
import * as Sentry from "@sentry/node";
import type { ExchangeId, OrderBook } from "../../domain/entities/index.js"; import type { ExchangeId, OrderBook } from "../../domain/entities/index.js";
import { withSpan } from "../../instrumentation/otel.js";
import { createLogger, type Logger } from "../logging/logger.js"; import { createLogger, type Logger } from "../logging/logger.js";
import type { MarketDataFeed } from "../../domain/ports/ports.js"; import type { MarketDataFeed } from "../../domain/ports/ports.js";
import { LocalBook } from "./local-book.js"; import { LocalBook } from "./local-book.js";
@@ -16,7 +14,7 @@ export type BookListener = (book: OrderBook) => void;
export abstract class ExchangeConnector implements MarketDataFeed { export abstract class ExchangeConnector implements MarketDataFeed {
abstract readonly id: ExchangeId; abstract readonly id: ExchangeId;
protected abstract readonly url: string; protected abstract readonly url: string;
protected readonly depth: number; protected readonly depth = 15;
protected ws: WebSocket | null = null; protected ws: WebSocket | null = null;
protected book: LocalBook; protected book: LocalBook;
@@ -29,10 +27,8 @@ export abstract class ExchangeConnector implements MarketDataFeed {
protected closed = false; protected closed = false;
private lastEmitTs = 0; private lastEmitTs = 0;
/** `depth` must match the depth the connector subscribes with. */ constructor() {
constructor(depth = 15) { this.book = new LocalBook(this.depth);
this.depth = depth;
this.book = new LocalBook(depth);
} }
onBook(listener: BookListener): void { onBook(listener: BookListener): void {
@@ -60,16 +56,12 @@ export abstract class ExchangeConnector implements MarketDataFeed {
ws.on("open", () => { ws.on("open", () => {
this.reconnectAttempts = 0; this.reconnectAttempts = 0;
this.book.reset(); this.book.reset();
this.log.info( this.log.info(this.skipSubscribe() ? "connected" : "connected, subscribing");
this.skipSubscribe() ? "connected" : "connected, subscribing",
);
if (!this.skipSubscribe()) { if (!this.skipSubscribe()) {
try { try {
ws.send(JSON.stringify(this.subscribeMessage())); ws.send(JSON.stringify(this.subscribeMessage()));
} catch (err) { } catch (err) {
this.log.error("subscribe send failed", { this.log.error("subscribe send failed", err);
error: err instanceof Error ? err.message : String(err),
});
} }
} }
this.startPing(); this.startPing();
@@ -80,27 +72,15 @@ export abstract class ExchangeConnector implements MarketDataFeed {
const text = data.toString(); const text = data.toString();
// Some exchanges (OKX) reply to app-level pings with a plain "pong" frame. // Some exchanges (OKX) reply to app-level pings with a plain "pong" frame.
if (text === "pong" || text === "ping") return; if (text === "pong" || text === "ping") return;
void withSpan( try {
"ws.message", this.handleMessage(JSON.parse(text));
{ exchange: this.id, bytes: text.length }, } catch (err) {
async () => { this.log.warn("failed to parse message", err);
try { }
this.handleMessage(JSON.parse(text));
} catch (err) {
this.log.warn("failed to parse message", {
error: err instanceof Error ? err.message : String(err),
});
}
},
);
}); });
ws.on("error", (err) => { ws.on("error", (err) => {
const error = err instanceof Error ? err : new Error(String(err)); this.log.error("socket error", err instanceof Error ? err.message : err);
this.log.error("socket error", { error: error.message });
Sentry.captureException(error, {
tags: { exchange: this.id, component: "ws" },
});
}); });
ws.on("close", () => { ws.on("close", () => {
@@ -117,13 +97,8 @@ export abstract class ExchangeConnector implements MarketDataFeed {
private scheduleReconnect(): void { private scheduleReconnect(): void {
this.reconnectAttempts += 1; this.reconnectAttempts += 1;
const delay = Math.min( const delay = Math.min(30_000, 500 * 2 ** Math.min(this.reconnectAttempts, 6));
30_000, this.log.warn(`disconnected, reconnecting in ${delay}ms (attempt ${this.reconnectAttempts})`);
500 * 2 ** Math.min(this.reconnectAttempts, 6),
);
this.log.warn(
`disconnected, reconnecting in ${delay}ms (attempt ${this.reconnectAttempts})`,
);
setTimeout(() => { setTimeout(() => {
if (!this.closed) this.connect(); if (!this.closed) this.connect();
}, delay); }, delay);
@@ -167,31 +142,9 @@ export abstract class ExchangeConnector implements MarketDataFeed {
exchangeTs, exchangeTs,
}; };
if (book.bids.length === 0 || book.asks.length === 0) return; 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); 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. */ /** Combined-stream URLs (e.g. Binance) set this to skip the subscribe send. */
protected skipSubscribe(): boolean { protected skipSubscribe(): boolean {
return false; return false;
+1 -11
View File
@@ -19,29 +19,20 @@ interface KrakenMessage {
data?: KrakenBookData[]; data?: KrakenBookData[];
} }
const KRAKEN_BOOK_DEPTH = 10;
/** /**
* Kraken WebSocket v2 — `book` channel. * Kraken WebSocket v2 — `book` channel.
* Docs: https://docs.kraken.com/websockets-v2/ * Docs: https://docs.kraken.com/websockets-v2/
* Snapshot replaces the book; updates patch individual price levels (qty 0 = remove). * 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 { export class KrakenConnector extends ExchangeConnector {
readonly id: ExchangeId = "kraken"; readonly id: ExchangeId = "kraken";
protected readonly url = "wss://ws.kraken.com/v2"; protected readonly url = "wss://ws.kraken.com/v2";
private readonly symbol = "BTC/USDT"; private readonly symbol = "BTC/USDT";
constructor() {
super(KRAKEN_BOOK_DEPTH);
}
protected subscribeMessage(): unknown { protected subscribeMessage(): unknown {
return { return {
method: "subscribe", 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.bids ?? []) this.book.bids.apply(lvl.price, lvl.qty);
for (const lvl of data.asks ?? []) this.book.asks.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; const exchangeTs = data.timestamp ? Date.parse(data.timestamp) : null;
this.emit(Number.isFinite(exchangeTs) ? exchangeTs : 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 { export class BookSide {
private levels = new Map<number, number>(); private levels = new Map<number, number>();
constructor( constructor(private readonly side: "bid" | "ask", private readonly depth: number) {}
private readonly side: "bid" | "ask",
private readonly depth: number,
) {}
clear(): void { clear(): void {
this.levels.clear(); 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. */ /** Sorted (bids desc, asks asc) and capped to `depth` levels. */
toArray(): Level[] { toArray(): Level[] {
const arr: Level[] = []; const arr: Level[] = [];
for (const [price, qty] of this.levels) arr.push({ price, qty }); for (const [price, qty] of this.levels) arr.push({ price, qty });
arr.sort((a, b) => arr.sort((a, b) => (this.side === "bid" ? b.price - a.price : a.price - b.price));
this.side === "bid" ? b.price - a.price : a.price - b.price,
);
return arr.length > this.depth ? arr.slice(0, this.depth) : arr; return arr.length > this.depth ? arr.slice(0, this.depth) : arr;
} }
@@ -67,10 +48,4 @@ export class LocalBook {
this.bids.clear(); this.bids.clear();
this.asks.clear(); this.asks.clear();
} }
/** Truncate both sides to their depth (see BookSide.truncate). */
truncate(): void {
this.bids.truncate();
this.asks.truncate();
}
} }
-39
View File
@@ -1,39 +0,0 @@
import { AsyncLocalStorage } from "node:async_hooks";
import { randomUUID } from "node:crypto";
export interface CorrelationContext {
correlationId: string;
exchange?: string;
}
const storage = new AsyncLocalStorage<CorrelationContext>();
export function getCorrelationContext(): CorrelationContext | undefined {
return storage.getStore();
}
export function getCorrelationId(): string | undefined {
return storage.getStore()?.correlationId;
}
export function runWithCorrelation<T>(
context: CorrelationContext,
fn: () => T,
): T {
return storage.run(context, fn);
}
export function newCorrelationId(prefix = "tick"): string {
return `${prefix}_${randomUUID().slice(0, 8)}`;
}
export function bindRequestCorrelation(
headerValue: string | string[] | undefined,
): CorrelationContext {
const raw = Array.isArray(headerValue) ? headerValue[0] : headerValue;
const correlationId =
typeof raw === "string" && raw.trim()
? raw.trim()
: newCorrelationId("req");
return { correlationId };
}
+15 -31
View File
@@ -1,38 +1,22 @@
import pino from "pino"; type Level = "info" | "warn" | "error";
import { getCorrelationContext } from "./correlation.js";
const root = pino({ function emit(level: Level, scope: string, msg: string, extra?: unknown): void {
level: process.env.LOG_LEVEL ?? "info", const ts = new Date().toISOString();
base: { service: "arbpulse" }, const line = `${ts} [${level.toUpperCase()}] (${scope}) ${msg}`;
timestamp: pino.stdTimeFunctions.isoTime, if (level === "error") {
...(process.env.NODE_ENV !== "production" console.error(line, extra ?? "");
? { } else if (level === "warn") {
transport: { console.warn(line, extra ?? "");
target: "pino-pretty", } else {
options: { colorize: true, singleLine: true }, console.log(line, extra ?? "");
}, }
}
: {}),
});
export function createLogger(scope: string) {
const child = root.child({ scope });
return {
info: (msg: string, extra?: Record<string, unknown>) =>
child.info({ ...bindings(), ...extra }, msg),
warn: (msg: string, extra?: Record<string, unknown>) =>
child.warn({ ...bindings(), ...extra }, msg),
error: (msg: string, extra?: Record<string, unknown>) =>
child.error({ ...bindings(), ...extra }, msg),
};
} }
function bindings(): Record<string, unknown> { export function createLogger(scope: string) {
const ctx = getCorrelationContext();
if (!ctx) return {};
return { return {
correlationId: ctx.correlationId, info: (msg: string, extra?: unknown) => emit("info", scope, msg, extra),
...(ctx.exchange ? { exchange: ctx.exchange } : {}), warn: (msg: string, extra?: unknown) => emit("warn", scope, msg, extra),
error: (msg: string, extra?: unknown) => emit("error", scope, msg, extra),
}; };
} }
-7
View File
@@ -1,7 +0,0 @@
import { initOpenTelemetry } from "./otel.js";
import { initSentry } from "./sentry.js";
export function initInstrumentation(): void {
initSentry();
initOpenTelemetry();
}
-55
View File
@@ -1,55 +0,0 @@
import * as Sentry from "@sentry/node";
import { trace, type Span, type Tracer } from "@opentelemetry/api";
import { NodeTracerProvider } from "@opentelemetry/sdk-trace-node";
import { SentrySpanProcessor, SentryPropagator } from "@sentry/opentelemetry";
import { isTracingEnabled } from "./tracing.js";
let tracer: Tracer | null = null;
/**
* OpenTelemetry spans export to Sentry via @sentry/opentelemetry only when
* SENTRY_DSN is set AND SENTRY_TRACING is enabled. Otherwise spans are no-ops
* (default; safe for 24/7 operation, local dev, and tests). Error capture in
* `withSpan` is preserved regardless.
*/
export function initOpenTelemetry(): Tracer {
if (tracer) return tracer;
if (!process.env.SENTRY_DSN?.trim() || !isTracingEnabled()) {
tracer = trace.getTracer("arbpulse");
return tracer;
}
const provider = new NodeTracerProvider({
spanProcessors: [new SentrySpanProcessor()],
});
provider.register({
propagator: new SentryPropagator(),
});
tracer = provider.getTracer("arbpulse");
return tracer;
}
export function getTracer(): Tracer {
return tracer ?? initOpenTelemetry();
}
export async function withSpan<T>(
name: string,
attributes: Record<string, string | number | boolean>,
fn: (span: Span) => T | Promise<T>,
): Promise<T> {
const activeTracer = getTracer();
return activeTracer.startActiveSpan(name, { attributes }, async (span) => {
try {
return await fn(span);
} catch (err) {
span.setStatus({ code: 2, message: String(err) });
Sentry.captureException(err);
throw err;
} finally {
span.end();
}
});
}
-32
View File
@@ -1,32 +0,0 @@
import * as Sentry from "@sentry/node";
import { isTracingEnabled } from "./tracing.js";
const dsn = process.env.SENTRY_DSN?.trim();
export function initSentry(): void {
if (!dsn) return;
Sentry.init({
dsn,
environment: process.env.NODE_ENV ?? "development",
integrations: [Sentry.httpIntegration(), Sentry.expressIntegration()],
// Tracing/spans are gated by SENTRY_TRACING (default off) to stay within
// the Sentry free-tier span quota during 24/7 operation. Error monitoring
// is always on.
...(isTracingEnabled()
? { tracesSampleRate: process.env.NODE_ENV === "production" ? 0.1 : 1 }
: {}),
});
process.on("unhandledRejection", (reason) => {
Sentry.captureException(
reason instanceof Error ? reason : new Error(String(reason)),
);
});
process.on("uncaughtException", (error) => {
Sentry.captureException(error);
});
}
export { Sentry };
-11
View File
@@ -1,11 +0,0 @@
/**
* Single source of truth for the tracing gate. OpenTelemetry spans are only
* created and exported to Sentry when `SENTRY_TRACING` is truthy (and a
* `SENTRY_DSN` is set). Defaults to disabled so 24/7 operation stays off the
* Sentry free-tier span quota. Error monitoring is unaffected by this flag.
*/
export function isTracingEnabled(): boolean {
const raw = process.env.SENTRY_TRACING;
if (raw === undefined) return false;
return raw === "1" || raw.toLowerCase() === "true";
}
+703 -254
View File
@@ -1,296 +1,745 @@
/** /**
* OpenAPI 3.0 document generated from Zod schemas via @asteasolutions/zod-to-openapi. * OpenAPI 3.0 specification for the Arb Pulse REST API.
* Served interactively at /api-docs through Scalar. *
* Arb Pulse is a **simulated** cross-exchange BTC/USDT arbitrage system.
* No real funds are at risk — all trades are paper-executed against live order
* book data from Kraken, Bybit, OKX and Binance (or a synthetic feed in demo mode).
*/ */
import { import type { OpenAPIV3 } from "openapi-types";
OpenAPIRegistry,
OpenApiGeneratorV3,
} from "@asteasolutions/zod-to-openapi";
import {
ErrorEnvelopeSchema,
SuccessEnvelopeNoDataSchema,
} from "./schemas/common.js";
import {
ConfigPatchSchema,
DemoControlBodySchema,
MaxTradeControlBodySchema,
RecordControlBodySchema,
ThresholdControlBodySchema,
} from "./schemas/requests.js";
import {
ConfigResponseSchema,
DemoModeResponseSchema,
HealthResponseSchema,
MaxTradeResponseSchema,
RecordFeedResponseSchema,
StateResponseSchema,
ThresholdResponseSchema,
} from "./schemas/responses.js";
import { StateSnapshotSchema } from "./schemas/domain.js";
const registry = new OpenAPIRegistry(); // ---------------------------------------------------------------------------
// Reusable schema components
// ---------------------------------------------------------------------------
registry.register("ErrorEnvelope", ErrorEnvelopeSchema); const schemas: Record<string, OpenAPIV3.SchemaObject> = {
registry.register("SuccessEnvelopeNoData", SuccessEnvelopeNoDataSchema); ExchangeId: {
registry.register("StateSnapshot", StateSnapshotSchema); type: "string",
enum: ["kraken", "bybit", "okx", "binance"],
const jsonError = { description: "One of the supported exchanges.",
description: "Invalid request body or out-of-range value.",
content: { "application/json": { schema: ErrorEnvelopeSchema } },
};
const jsonServerError = {
description: "Unexpected server error.",
content: { "application/json": { schema: ErrorEnvelopeSchema } },
};
registry.registerPath({
method: "get",
path: "/api/health",
tags: ["Monitoring"],
summary: "Health check",
description:
"Lightweight endpoint used by Fly.io (and any uptime monitor) to verify the server is alive.",
operationId: "getHealth",
responses: {
200: {
description: "Server is healthy.",
content: { "application/json": { schema: HealthResponseSchema } },
},
500: jsonServerError,
}, },
});
registry.registerPath({ FeedStatus: {
method: "get", type: "string",
path: "/api/state", enum: ["connecting", "live", "stale", "down"],
tags: ["Monitoring"], description: "Connectivity state of the exchange WebSocket feed.",
summary: "Full state snapshot",
description:
"Returns the complete in-memory engine state as a single JSON document.",
operationId: "getState",
responses: {
200: {
description: "Current engine state.",
content: { "application/json": { schema: StateResponseSchema } },
},
500: jsonServerError,
}, },
});
registry.registerPath({ CircuitState: {
method: "get", type: "string",
path: "/api/stream", enum: ["running", "paused", "tripped"],
tags: ["Streaming"], description:
summary: "SSE real-time feed", "State of the circuit-breaker: `running` = normal, `paused` = manually paused, `tripped` = auto-paused after N consecutive losses.",
description: },
"Opens a persistent Server-Sent Events connection. Each event carries a JSON-encoded StateSnapshot.",
operationId: "getStream", OpportunityStatus: {
responses: { type: "string",
200: { enum: [
description: "SSE stream opened.", "executed",
content: { "executed_partial",
"text/event-stream": { "rejected_fees",
schema: { "rejected_liquidity",
type: "string", "rejected_risk",
description: "Newline-delimited SSE events.", "rejected_flicker",
}, "rejected_stale",
}, "pending_confirm",
],
description: "Disposition of a detected arbitrage opportunity.",
},
BestQuote: {
type: "object",
description: "Best bid/ask snapshot for one exchange, as seen by the engine.",
required: ["exchange", "bid", "bidQty", "ask", "askQty", "recvTs", "status", "ageMs"],
properties: {
exchange: { $ref: "#/components/schemas/ExchangeId" },
bid: { type: "number", nullable: true, description: "Best bid price in USDT (null when feed is down)." },
bidQty: { type: "number", nullable: true, description: "Quantity available at the best bid (BTC)." },
ask: { type: "number", nullable: true, description: "Best ask price in USDT (null when feed is down)." },
askQty: { type: "number", nullable: true, description: "Quantity available at the best ask (BTC)." },
recvTs: { type: "integer", nullable: true, description: "Local receive timestamp (ms since epoch)." },
status: { $ref: "#/components/schemas/FeedStatus" },
ageMs: { type: "integer", nullable: true, description: "Milliseconds since the last update was received." },
},
},
Wallet: {
type: "object",
description:
"Simulated per-exchange wallet. Each exchange starts with pre-positioned USDT **and** BTC so buy/sell legs execute in parallel without on-chain transfers.",
required: ["exchange", "usdt", "btc"],
properties: {
exchange: { $ref: "#/components/schemas/ExchangeId" },
usdt: { type: "number", description: "USDT balance at this exchange." },
btc: { type: "number", description: "BTC balance at this exchange." },
},
},
Opportunity: {
type: "object",
description:
"An arbitrage opportunity detected by the engine. May have been executed or rejected for various reasons (fees, liquidity, flicker, stale quotes, risk).",
required: [
"id", "ts", "buyExchange", "sellExchange",
"topBuyAsk", "topSellBid", "volumeBtc",
"buyVwap", "sellVwap",
"grossSpread", "grossSpreadPct",
"feeBuy", "feeSell",
"netProfit", "netProfitPct",
"status", "reason", "demo",
],
properties: {
id: { type: "string" },
ts: { type: "integer", description: "Detection timestamp (ms epoch)." },
buyExchange: { $ref: "#/components/schemas/ExchangeId" },
sellExchange: { $ref: "#/components/schemas/ExchangeId" },
topBuyAsk: { type: "number", description: "Top-of-book ask price on the buy exchange (USDT)." },
topSellBid: { type: "number", description: "Top-of-book bid price on the sell exchange (USDT)." },
volumeBtc: {
type: "number",
description: "Volume evaluated (BTC) after applying liquidity depth and wallet inventory caps.",
},
buyVwap: {
type: "number",
description: "Volume-weighted average buy price after walking the order book depth (USDT). Slippage is embedded here.",
},
sellVwap: {
type: "number",
description: "Volume-weighted average sell price after walking the order book depth (USDT). Slippage is embedded here.",
},
grossSpread: { type: "number", description: "Raw spread (sellVwap buyVwap) in USDT." },
grossSpreadPct: { type: "number", description: "Gross spread as a percentage of buyVwap." },
feeBuy: { type: "number", description: "Taker fee rate for the buy exchange (e.g. 0.0026 for Kraken)." },
feeSell: { type: "number", description: "Taker fee rate for the sell exchange." },
netProfit: {
type: "number",
description: "Net P&L in USDT: sellVwap·vol·(1feeSell) buyVwap·vol·(1+feeBuy). Negative = loss.",
},
netProfitPct: { type: "number", description: "Net profit as a percentage of cost basis." },
status: { $ref: "#/components/schemas/OpportunityStatus" },
reason: { type: "string", description: "Human-readable explanation of the disposition." },
demo: {
type: "boolean",
description: "True when this opportunity was synthetically injected by the demo feed — never real market data.",
}, },
}, },
}, },
});
registry.registerPath({ Trade: {
method: "get", type: "object",
path: "/api/config", description:
tags: ["Configuration"], "A simulated trade that was executed (or partially executed). Includes latency-drift adjustment on both VWAP prices to model realistic fill slippage between detection and execution.",
summary: "Get engine configuration", required: [
operationId: "getConfig", "id", "ts", "buyExchange", "sellExchange",
responses: { "volumeBtc", "requestedBtc",
200: { "buyVwap", "sellVwap", "execBuyVwap", "execSellVwap",
description: "Current configuration.", "feeBuy", "feeSell",
content: { "application/json": { schema: ConfigResponseSchema } }, "netProfit", "netProfitPct",
}, "partial", "demo",
500: jsonServerError, ],
}, properties: {
}); id: { type: "string" },
ts: { type: "integer", description: "Execution timestamp (ms epoch)." },
registry.registerPath({ buyExchange: { $ref: "#/components/schemas/ExchangeId" },
method: "patch", sellExchange: { $ref: "#/components/schemas/ExchangeId" },
path: "/api/config", volumeBtc: { type: "number", description: "Actual filled volume (BTC)." },
tags: ["Configuration"], requestedBtc: { type: "number", description: "Originally requested volume before partial fill." },
summary: "Update engine configuration", buyVwap: { type: "number", description: "VWAP at detection time (pre-latency, USDT)." },
operationId: "patchConfig", sellVwap: { type: "number", description: "VWAP at detection time (pre-latency, USDT)." },
request: { execBuyVwap: {
body: { type: "number",
content: { "application/json": { schema: ConfigPatchSchema } }, description: "Simulated fill price after latency drift on the buy side (USDT). Reflects real-world price movement during order routing.",
required: true, },
execSellVwap: {
type: "number",
description: "Simulated fill price after latency drift on the sell side (USDT).",
},
feeBuy: { type: "number" },
feeSell: { type: "number" },
netProfit: { type: "number", description: "Realized net P&L in USDT (negative = loss)." },
netProfitPct: { type: "number" },
partial: { type: "boolean", description: "True when the fill was limited by order book depth or wallet balance." },
demo: { type: "boolean" },
}, },
}, },
responses: {
200: {
description: "Updated configuration.",
content: { "application/json": { schema: ConfigResponseSchema } },
},
400: jsonError,
500: jsonServerError,
},
});
function registerControlPost( RebalanceEvent: {
path: string, type: "object",
operationId: string, description:
summary: string, "An inter-exchange rebalance triggered to correct inventory drift. Withdrawal fees apply here — not per-trade.",
description: string, required: ["id", "ts", "fromExchange", "toExchange", "asset", "amount", "withdrawalFee", "reason"],
): void { properties: {
registry.registerPath({ id: { type: "string" },
method: "post", ts: { type: "integer" },
path, fromExchange: { $ref: "#/components/schemas/ExchangeId" },
tags: ["Control"], toExchange: { $ref: "#/components/schemas/ExchangeId" },
summary, asset: { type: "string", enum: ["BTC", "USDT"] },
amount: { type: "number" },
withdrawalFee: { type: "number", description: "Withdrawal fee in the transferred asset." },
reason: { type: "string" },
},
},
PnlPoint: {
type: "object",
description: "A cumulative P&L data point for the time-series chart.",
required: ["ts", "pnl"],
properties: {
ts: { type: "integer", description: "Timestamp (ms epoch)." },
pnl: { type: "number", description: "Cumulative realized P&L in USDT at this timestamp." },
},
},
EngineStats: {
type: "object",
description: "Runtime statistics for the arbitrage engine.",
required: [
"uptimeMs", "ticksProcessed", "opportunitiesDetected",
"tradesExecuted", "tradesRejected",
"realizedPnl", "consecutiveLosses",
"circuit", "demoMode", "avgTickMs",
],
properties: {
uptimeMs: { type: "integer", description: "Engine uptime in milliseconds." },
ticksProcessed: { type: "integer", description: "Total order-book ticks processed." },
opportunitiesDetected: { type: "integer" },
tradesExecuted: { type: "integer" },
tradesRejected: { type: "integer" },
realizedPnl: { type: "number", description: "Cumulative realized P&L in USDT." },
consecutiveLosses: {
type: "integer",
description: "Consecutive losing trades since the last reset — triggers the circuit-breaker when it reaches the configured threshold.",
},
circuit: { $ref: "#/components/schemas/CircuitState" },
demoMode: {
type: "boolean",
description: "When true the engine consumes a synthetic (clearly-labelled) feed instead of live exchange data.",
},
avgTickMs: { type: "number", description: "EWMA of tick processing time in milliseconds." },
},
},
PublicConfig: {
type: "object",
description: "Live engine configuration. All fields are read/write unless noted.",
required: [
"minNetProfitPct", "maxTradeBtc", "staleMs",
"flickerConfirmMs", "latencyMs",
"activeExchanges", "defaults", "takerFees", "withdrawalFeesBtc",
],
properties: {
minNetProfitPct: {
type: "number",
description: "Minimum net profit percentage required to execute a trade. Opportunities below this threshold are rejected as `rejected_fees`.",
},
maxTradeBtc: {
type: "number",
description: "Maximum BTC volume per simulated trade. Caps partial fills.",
},
staleMs: {
type: "integer",
description: "Quote age (ms) beyond which a price is considered stale and the opportunity is rejected. Read-only (set via env).",
},
flickerConfirmMs: {
type: "integer",
description: "Minimum time (ms) a spread must persist before execution. Prevents acting on transient latency artefacts.",
},
latencyMs: {
type: "integer",
description: "Simulated order-routing latency applied when computing exec VWAP drift. Read-only (set via env).",
},
activeExchanges: {
type: "object",
additionalProperties: { type: "boolean" },
description: "Map of exchangeId → enabled. Disabled exchanges are excluded from opportunity detection.",
},
defaults: {
type: "object",
description: "Factory defaults for the mutable fields.",
properties: {
minNetProfitPct: { type: "number" },
maxTradeBtc: { type: "number" },
flickerConfirmMs: { type: "integer" },
activeExchanges: { type: "object", additionalProperties: { type: "boolean" } },
},
},
takerFees: {
type: "object",
additionalProperties: { type: "number" },
description: "Taker fee rates per exchange (e.g. kraken: 0.0026, bybit: 0.001, okx: 0.001, binance: 0.001).",
},
withdrawalFeesBtc: {
type: "object",
additionalProperties: { type: "number" },
description: "BTC withdrawal fees per exchange — only relevant for the rebalancer, never charged per trade.",
},
},
},
StateSnapshot: {
type: "object",
description: "Full engine snapshot — same payload pushed over SSE on every tick.",
required: ["ts", "quotes", "wallets", "stats", "recentOpportunities", "recentTrades", "rebalances", "pnlSeries", "config"],
properties: {
ts: { type: "integer", description: "Snapshot timestamp (ms epoch)." },
quotes: { type: "array", items: { $ref: "#/components/schemas/BestQuote" } },
wallets: { type: "array", items: { $ref: "#/components/schemas/Wallet" } },
stats: { $ref: "#/components/schemas/EngineStats" },
recentOpportunities: { type: "array", items: { $ref: "#/components/schemas/Opportunity" } },
recentTrades: { type: "array", items: { $ref: "#/components/schemas/Trade" } },
rebalances: { type: "array", items: { $ref: "#/components/schemas/RebalanceEvent" } },
pnlSeries: { type: "array", items: { $ref: "#/components/schemas/PnlPoint" } },
config: { $ref: "#/components/schemas/PublicConfig" },
},
},
SuccessEnvelope: {
type: "object",
required: ["success"],
properties: {
success: { type: "boolean", enum: [true] },
data: { description: "Response payload (shape depends on the endpoint)." },
},
additionalProperties: false,
},
SuccessEnvelopeNoData: {
type: "object",
required: ["success"],
properties: {
success: { type: "boolean", enum: [true] },
},
additionalProperties: false,
example: { success: true },
},
ErrorEnvelope: {
type: "object",
required: ["success", "error"],
properties: {
success: { type: "boolean", enum: [false] },
error: { type: "string", description: "Human-readable error message." },
},
additionalProperties: false,
example: { success: false, error: "enabled must be a boolean" },
},
DemoControlBody: {
type: "object",
required: ["enabled"],
properties: {
enabled: {
type: "boolean",
description: "`true` to activate the synthetic feed, `false` to return to live data.",
example: true,
},
},
additionalProperties: false,
},
RecordControlBody: {
type: "object",
required: ["enabled"],
properties: {
enabled: { type: "boolean", description: "Start or stop NDJSON feed recording.", example: false },
},
additionalProperties: false,
},
ThresholdControlBody: {
type: "object",
required: ["pct"],
properties: {
pct: {
type: "number",
description: "Net profit threshold as a decimal percentage (0.00010.01, e.g. `0.0005` = 0.05 %).",
minimum: 0.0001,
maximum: 0.01,
example: 0.0005,
},
},
additionalProperties: false,
},
MaxTradeControlBody: {
type: "object",
required: ["btc"],
properties: {
btc: {
type: "number",
description: "Maximum BTC volume per trade (0.011.0).",
minimum: 0.01,
maximum: 1.0,
example: 0.05,
},
},
additionalProperties: false,
},
ConfigPatch: {
type: "object",
description: "Partial update for engine configuration. Only provided fields are changed.",
properties: {
minNetProfitPct: { type: "number" },
maxTradeBtc: { type: "number" },
flickerConfirmMs: { type: "integer" },
activeExchanges: {
type: "object",
additionalProperties: { type: "boolean" },
description: "Partial map — only provided exchange entries are updated.",
},
},
},
};
// ---------------------------------------------------------------------------
// Reusable response helpers
// ---------------------------------------------------------------------------
function successResponseNoData(description: string): OpenAPIV3.ResponseObject {
return {
description, description,
operationId, content: {
responses: { "application/json": {
200: { schema: { $ref: "#/components/schemas/SuccessEnvelopeNoData" },
description: summary,
content: {
"application/json": { schema: SuccessEnvelopeNoDataSchema },
},
}, },
500: jsonServerError,
}, },
}); };
} }
registerControlPost( function successResponse(description: string, dataSchema: OpenAPIV3.SchemaObject | OpenAPIV3.ReferenceObject, example?: unknown): OpenAPIV3.ResponseObject {
"/api/control/pause", return {
"controlPause", description,
"Pause the arbitrage engine", content: {
"Manually pauses the engine until resume is called.", "application/json": {
); schema: {
registerControlPost( type: "object",
"/api/control/resume", required: ["success", "data"],
"controlResume", properties: {
"Resume the arbitrage engine", success: { type: "boolean", enum: [true] },
"Resumes opportunity evaluation after pause or circuit-breaker trip.", data: dataSchema,
); },
registerControlPost( additionalProperties: false,
"/api/control/reset", },
"controlReset", ...(example !== undefined ? { example } : {}),
"Reset simulated state", },
"Resets wallets, trade history, and cumulative P&L.", },
); };
}
registry.registerPath({ const badRequestResponse: OpenAPIV3.ResponseObject = {
method: "post", description: "Invalid request body or out-of-range value.",
path: "/api/control/demo", content: {
tags: ["Control"], "application/json": {
summary: "Enable / disable demo feed", schema: { $ref: "#/components/schemas/ErrorEnvelope" },
operationId: "controlDemo",
request: {
body: {
content: { "application/json": { schema: DemoControlBodySchema } },
required: true,
}, },
}, },
responses: { };
200: {
description: "Demo mode updated.",
content: { "application/json": { schema: DemoModeResponseSchema } },
},
400: jsonError,
500: jsonServerError,
},
});
registry.registerPath({ const serverErrorResponse: OpenAPIV3.ResponseObject = {
method: "post", description: "Unexpected server error.",
path: "/api/control/record", content: {
tags: ["Control"], "application/json": {
summary: "Enable / disable feed recording", schema: { $ref: "#/components/schemas/ErrorEnvelope" },
operationId: "controlRecord", example: { success: false, error: "Internal server error" },
request: {
body: {
content: { "application/json": { schema: RecordControlBodySchema } },
required: true,
}, },
}, },
responses: { };
200: {
description: "Recording state updated.",
content: { "application/json": { schema: RecordFeedResponseSchema } },
},
400: jsonError,
500: jsonServerError,
},
});
registry.registerPath({ function controlBodyResponses(
method: "post", okDescription: string,
path: "/api/control/threshold", dataSchema: OpenAPIV3.SchemaObject | OpenAPIV3.ReferenceObject,
tags: ["Control"], example: unknown,
summary: "Set minimum net profit threshold", ): OpenAPIV3.ResponsesObject {
operationId: "controlThreshold", return {
request: { "200": successResponse(okDescription, dataSchema, example),
body: { "400": badRequestResponse,
content: { "application/json": { schema: ThresholdControlBodySchema } }, "500": serverErrorResponse,
required: true, };
}, }
},
responses: {
200: {
description: "Threshold updated.",
content: { "application/json": { schema: ThresholdResponseSchema } },
},
400: jsonError,
500: jsonServerError,
},
});
registry.registerPath({ // ---------------------------------------------------------------------------
method: "post", // Full OpenAPI document
path: "/api/control/max-trade", // ---------------------------------------------------------------------------
tags: ["Control"],
summary: "Set maximum trade volume",
operationId: "controlMaxTrade",
request: {
body: {
content: { "application/json": { schema: MaxTradeControlBodySchema } },
required: true,
},
},
responses: {
200: {
description: "Max trade volume updated.",
content: { "application/json": { schema: MaxTradeResponseSchema } },
},
400: jsonError,
500: jsonServerError,
},
});
const generator = new OpenApiGeneratorV3(registry.definitions); export const openapiSpec: OpenAPIV3.Document = {
export const openapiDocument = generator.generateDocument({
openapi: "3.0.3", openapi: "3.0.3",
info: { info: {
title: "Arb Pulse API", title: "Arb Pulse API",
version: "1.0.0", version: "1.0.0",
description: description: `
"Real-time BTC/USDT arbitrage detection and simulation engine. Simulated only — no real funds are committed.", **Arb Pulse** is a real-time BTC/USDT arbitrage detection and simulation engine
that monitors live order books from **Kraken**, **Bybit**, **OKX** and **Binance** simultaneously.
> ⚠️ **Simulated only** — no real funds are ever committed. All trades are paper-executed
> against live (or synthetic demo) market data. The system uses a *pre-positioned inventory
> model*: each exchange holds both USDT and BTC so buy and sell legs run in parallel without
> on-chain transfers.
### Key concepts
| Concept | Detail |
|---|---|
| Profit formula | \`sellVwap·vol·(1feeSell) buyVwap·vol·(1+feeBuy)\` |
| Slippage | Embedded in VWAP by walking the order book depth level by level |
| Anti-flicker | Spread must persist \`flickerConfirmMs\` before execution |
| Circuit breaker | Auto-pauses after N consecutive losses |
| Demo mode | Injects synthetic divergences — clearly labelled, never presented as real |
`.trim(),
contact: { name: "Arb Pulse" }, contact: { name: "Arb Pulse" },
license: { name: "MIT" }, license: { name: "MIT" },
}, },
servers: [{ url: "/", description: "Current host (Fly.io / local)" }], servers: [
{ url: "/", description: "Current host (Fly.io / local)" },
],
tags: [ tags: [
{ name: "Monitoring", description: "Health and state inspection." }, { name: "Monitoring", description: "Health and state inspection." },
{ name: "Streaming", description: "Server-Sent Events real-time feed." }, { name: "Streaming", description: "Server-Sent Events real-time feed." },
{ { name: "Configuration", description: "Read and update engine parameters." },
name: "Configuration",
description: "Read and update engine parameters.",
},
{ name: "Control", description: "Pause, resume, reset and mode switches." }, { name: "Control", description: "Pause, resume, reset and mode switches." },
], ],
}); paths: {
"/api/health": {
get: {
tags: ["Monitoring"],
summary: "Health check",
description:
"Lightweight endpoint used by Fly.io (and any uptime monitor) to verify the server is alive. Always returns HTTP 200 while the process is running.",
operationId: "getHealth",
responses: {
"200": successResponse("Server is healthy.", {
type: "object",
required: ["status", "ts"],
properties: {
status: { type: "string", enum: ["ok"] },
ts: { type: "integer", description: "Server timestamp (ms epoch)." },
},
}, { success: true, data: { status: "ok", ts: 1_700_000_000_000 } }),
"500": serverErrorResponse,
},
},
},
"/api/state": {
get: {
tags: ["Monitoring"],
summary: "Full state snapshot",
description:
"Returns the complete in-memory engine state as a single JSON document: live quotes from all active exchanges, simulated wallet balances, engine statistics, the last N detected opportunities and executed trades, rebalance history, the cumulative P&L time-series, and the current engine configuration.",
operationId: "getState",
responses: {
"200": successResponse("Current engine state.", { $ref: "#/components/schemas/StateSnapshot" }),
"500": serverErrorResponse,
},
},
},
"/api/stream": {
get: {
tags: ["Streaming"],
summary: "SSE real-time feed",
description:
"Opens a persistent [Server-Sent Events](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events) connection. The server pushes a `StateSnapshot` JSON payload on every engine tick (typically every few hundred milliseconds). The dashboard React app subscribes here — no WebSocket or polling needed.\n\nConnect with `EventSource` or any SSE client. The stream never closes unless the server restarts or the client disconnects.",
operationId: "getStream",
responses: {
"200": {
description: "SSE stream opened. Each `data:` event carries a JSON-encoded `StateSnapshot`.",
content: {
"text/event-stream": {
schema: {
type: "string",
description: "Newline-delimited SSE events. Each `data:` line contains a serialized `StateSnapshot`.",
},
},
},
},
},
},
},
"/api/config": {
get: {
tags: ["Configuration"],
summary: "Get engine configuration",
description:
"Returns the current mutable and immutable engine parameters: profit threshold, max trade size, flicker window, active exchanges, fee tables and factory defaults.",
operationId: "getConfig",
responses: {
"200": successResponse("Current configuration.", { $ref: "#/components/schemas/PublicConfig" }),
"500": serverErrorResponse,
},
},
patch: {
tags: ["Configuration"],
summary: "Update engine configuration",
description:
"Applies a partial update to mutable engine parameters. Only the fields present in the request body are changed; omitted fields retain their current values.\n\nReturns the full updated configuration on success.",
operationId: "patchConfig",
requestBody: {
required: true,
content: {
"application/json": { schema: { $ref: "#/components/schemas/ConfigPatch" } },
},
},
responses: {
"200": successResponse("Updated configuration.", { $ref: "#/components/schemas/PublicConfig" }),
"400": badRequestResponse,
"500": serverErrorResponse,
},
},
},
"/api/control/pause": {
post: {
tags: ["Control"],
summary: "Pause the arbitrage engine",
description:
"Manually pauses the engine. Opportunities are no longer evaluated and no new trades are executed until `resume` is called. Order book feeds remain connected. This is equivalent to setting the circuit state to `paused`.",
operationId: "controlPause",
responses: {
"200": successResponseNoData("Engine paused."),
"500": serverErrorResponse,
},
},
},
"/api/control/resume": {
post: {
tags: ["Control"],
summary: "Resume the arbitrage engine",
description:
"Resumes opportunity evaluation after a `pause` or a circuit-breaker trip. The engine immediately re-enters the `running` circuit state.",
operationId: "controlResume",
responses: {
"200": successResponseNoData("Engine resumed."),
"500": serverErrorResponse,
},
},
},
"/api/control/reset": {
post: {
tags: ["Control"],
summary: "Reset simulated state",
description:
"Resets the simulated wallets back to their initial pre-positioned balances, clears the trade history, resets the cumulative P&L to zero, and restores the consecutive-loss counter. Engine configuration (thresholds, fee tables) is not affected. Useful for starting a clean simulation session.",
operationId: "controlReset",
responses: {
"200": successResponseNoData("State reset."),
"500": serverErrorResponse,
},
},
},
"/api/control/demo": {
post: {
tags: ["Control"],
summary: "Enable / disable demo feed",
description:
"Switches between the live exchange feeds and the **synthetic demo injector**. When `enabled: true` the engine receives artificially inflated spreads that guarantee visible arbitrage opportunities — clearly labelled with `demo: true` in every event so they are never mistaken for real market signals.\n\n**Note:** Real feeds are paused while demo mode is active.",
operationId: "controlDemo",
requestBody: {
required: true,
content: {
"application/json": { schema: { $ref: "#/components/schemas/DemoControlBody" } },
},
},
responses: controlBodyResponses(
"Demo mode updated.",
{
type: "object",
required: ["demoMode"],
properties: { demoMode: { type: "boolean" } },
additionalProperties: false,
},
{ success: true, data: { demoMode: true } },
),
},
},
"/api/control/record": {
post: {
tags: ["Control"],
summary: "Enable / disable feed recording",
description:
"Starts or stops recording live order-book events to an NDJSON file on disk for later replay. Useful for capturing real market sessions to run deterministic back-tests without re-connecting to exchanges.",
operationId: "controlRecord",
requestBody: {
required: true,
content: {
"application/json": { schema: { $ref: "#/components/schemas/RecordControlBody" } },
},
},
responses: controlBodyResponses(
"Recording state updated.",
{
type: "object",
required: ["recordFeed"],
properties: { recordFeed: { type: "boolean" } },
additionalProperties: false,
},
{ success: true, data: { recordFeed: false } },
),
},
},
"/api/control/threshold": {
post: {
tags: ["Control"],
summary: "Set minimum net profit threshold",
description:
"Adjusts the minimum net profit percentage an opportunity must exceed to be executed. Lower values detect more opportunities but increase the chance of tiny losses due to fee rounding. Opportunities below this threshold are logged as `rejected_fees`.\n\nEquivalent to `PATCH /api/config` with `{ minNetProfitPct }` but provided as a convenience endpoint.",
operationId: "controlThreshold",
requestBody: {
required: true,
content: {
"application/json": { schema: { $ref: "#/components/schemas/ThresholdControlBody" } },
},
},
responses: controlBodyResponses(
"Threshold updated.",
{
type: "object",
required: ["minNetProfitPct"],
properties: { minNetProfitPct: { type: "number" } },
additionalProperties: false,
},
{ success: true, data: { minNetProfitPct: 0.0005 } },
),
},
},
"/api/control/max-trade": {
post: {
tags: ["Control"],
summary: "Set maximum trade volume",
description:
"Sets the upper bound on BTC volume per simulated trade. The engine also caps volume by available order-book liquidity and wallet inventory — this parameter acts as an additional hard cap.\n\nEquivalent to `PATCH /api/config` with `{ maxTradeBtc }` but provided as a convenience endpoint.",
operationId: "controlMaxTrade",
requestBody: {
required: true,
content: {
"application/json": { schema: { $ref: "#/components/schemas/MaxTradeControlBody" } },
},
},
responses: controlBodyResponses(
"Max trade volume updated.",
{
type: "object",
required: ["maxTradeBtc"],
properties: { maxTradeBtc: { type: "number" } },
additionalProperties: false,
},
{ success: true, data: { maxTradeBtc: 0.05 } },
),
},
},
},
components: {
schemas,
responses: {
BadRequest: badRequestResponse,
ServerError: serverErrorResponse,
},
},
};
-60
View File
@@ -1,60 +0,0 @@
import assert from "node:assert/strict";
import { randomUUID } from "node:crypto";
import type { AddressInfo } from "node:net";
import { test } from "node:test";
import express, { Router } from "express";
import { isUpstashConfigured } from "../../infrastructure/cache/upstash.js";
import { loadEnvLocal } from "../../test-support/load-env-local.js";
import { createRateLimitMiddleware } from "./rate-limit.js";
loadEnvLocal();
const READ_LIMIT_PER_MIN = 60;
test(
"read tier returns 429 with Retry-After after limit",
{ skip: !isUpstashConfigured() },
async () => {
const app = express();
const router = Router();
router.use(createRateLimitMiddleware());
router.get("/state", (_req, res) => {
res.json({ success: true, data: { ok: true } });
});
app.use("/api", router);
const server = await new Promise<ReturnType<typeof app.listen>>(
(resolve) => {
const s = app.listen(0, "127.0.0.1", () => resolve(s));
},
);
const port = (server.address() as AddressInfo).port;
const url = `http://127.0.0.1:${port}/api/state`;
const clientIp = `rate-limit-qa-${randomUUID()}`;
try {
for (let i = 0; i < READ_LIMIT_PER_MIN; i++) {
const res = await fetch(url, {
headers: { "x-forwarded-for": clientIp },
});
assert.equal(res.status, 200, `request ${i + 1} should pass`);
}
const blocked = await fetch(url, {
headers: { "x-forwarded-for": clientIp },
});
assert.equal(blocked.status, 429);
assert.ok(blocked.headers.get("retry-after"));
const body = (await blocked.json()) as { error?: string };
assert.equal(body.error, "Too many requests");
} finally {
await new Promise<void>((resolve, reject) => {
server.close((err) => (err ? reject(err) : resolve()));
});
}
},
);
test("reports whether Upstash REST credentials are set", () => {
assert.equal(typeof isUpstashConfigured(), "boolean");
});
-86
View File
@@ -1,86 +0,0 @@
import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";
import type { NextFunction, Request, Response } from "express";
import { isUpstashConfigured } from "../../infrastructure/cache/upstash.js";
type RouteTier = "read" | "stream" | "write" | "health";
const LIMITS: Record<
Exclude<RouteTier, "health">,
{ requests: number; window: `${number} s` | `${number} m` }
> = {
read: { requests: 60, window: "1 m" },
stream: { requests: 10, window: "1 m" },
write: { requests: 30, window: "1 m" },
};
const limiters = new Map<Exclude<RouteTier, "health">, Ratelimit>();
function getLimiter(tier: Exclude<RouteTier, "health">): Ratelimit | null {
if (!isUpstashConfigured()) {
return null;
}
let limiter = limiters.get(tier);
if (limiter) {
return limiter;
}
const url = process.env.UPSTASH_REDIS_REST_URL!.trim();
const token = process.env.UPSTASH_REDIS_REST_TOKEN!.trim();
const redis = new Redis({ url, token });
const cfg = LIMITS[tier];
limiter = new Ratelimit({
redis,
limiter: Ratelimit.slidingWindow(cfg.requests, cfg.window),
prefix: `arbpulse:${tier}`,
});
limiters.set(tier, limiter);
return limiter;
}
function clientKey(req: Request): string {
const forwarded = req.header("x-forwarded-for");
if (forwarded) {
return forwarded.split(",")[0]?.trim() || "unknown";
}
return req.ip || req.socket.remoteAddress || "unknown";
}
function tierForPath(path: string, method: string): RouteTier {
if (path === "/health") return "health";
if (path === "/stream") return "stream";
if (method === "GET" || method === "HEAD") return "read";
return "write";
}
export function createRateLimitMiddleware() {
return async (
req: Request,
res: Response,
next: NextFunction,
): Promise<void> => {
const tier = tierForPath(req.path, req.method);
if (tier === "health") {
next();
return;
}
const limiter = getLimiter(tier);
if (!limiter) {
next();
return;
}
const { success, reset } = await limiter.limit(`${tier}:${clientKey(req)}`);
if (success) {
next();
return;
}
const retryAfterSec = Math.max(1, Math.ceil((reset - Date.now()) / 1000));
res.setHeader("Retry-After", String(retryAfterSec));
res.status(429).json({
success: false,
error: "Too many requests",
});
};
}
+32 -75
View File
@@ -1,64 +1,16 @@
import { import { Router, type Request, type Response } from "express";
Router,
type NextFunction,
type Request,
type Response,
} from "express";
import { Sentry } from "../../instrumentation/sentry.js";
import type { ApplicationService } from "../../composition/application-service.js"; import type { ApplicationService } from "../../composition/application-service.js";
import {
bindRequestCorrelation,
runWithCorrelation,
} from "../../infrastructure/logging/correlation.js";
import {
getCachedSnapshot,
setCachedSnapshot,
} from "../../infrastructure/cache/upstash.js";
import type { SseHub } from "../sse/sse.js"; import type { SseHub } from "../sse/sse.js";
import { createRateLimitMiddleware } from "./rate-limit.js";
import {
ConfigPatchSchema,
DemoControlBodySchema,
MaxTradeControlBodySchema,
RecordControlBodySchema,
ThresholdControlBodySchema,
} from "./schemas/requests.js";
import { parseBody } from "./validate.js";
export function createRouter(app: ApplicationService, sse: SseHub): Router { export function createRouter(app: ApplicationService, sse: SseHub): Router {
const router = Router(); const router = Router();
router.use((req: Request, res: Response, next: NextFunction) => {
const ctx = bindRequestCorrelation(req.header("x-request-id"));
res.setHeader("x-request-id", ctx.correlationId);
runWithCorrelation(ctx, () => next());
});
router.use(createRateLimitMiddleware());
router.get("/health", (_req: Request, res: Response) => { router.get("/health", (_req: Request, res: Response) => {
res.json({ success: true, data: { status: "ok", ts: Date.now() } }); res.json({ success: true, data: { status: "ok", ts: Date.now() } });
}); });
router.get("/debug/sentry", (_req: Request, res: Response) => { router.get("/state", (_req: Request, res: Response) => {
if (process.env.NODE_ENV === "production") { res.json({ success: true, data: app.getSnapshot() });
res.status(404).json({ error: "Not found" });
return;
}
Sentry.captureException(new Error("Arb Pulse Sentry test error"));
res.json({ ok: true, message: "Test error sent to Sentry" });
});
router.get("/state", async (_req: Request, res: Response) => {
const cached =
await getCachedSnapshot<ReturnType<ApplicationService["getSnapshot"]>>();
if (cached) {
res.json({ success: true, data: cached });
return;
}
const snapshot = app.getSnapshot();
void setCachedSnapshot(snapshot);
res.json({ success: true, data: snapshot });
}); });
router.get("/stream", (req: Request, res: Response) => { router.get("/stream", (req: Request, res: Response) => {
@@ -70,10 +22,7 @@ export function createRouter(app: ApplicationService, sse: SseHub): Router {
}); });
router.patch("/config", (req: Request, res: Response) => { router.patch("/config", (req: Request, res: Response) => {
const patch = parseBody(ConfigPatchSchema, req, res); const error = app.patchConfig(req.body ?? {});
if (patch === null) return;
const error = app.patchConfig(patch);
if (error) { if (error) {
res.status(400).json({ success: false, error }); res.status(400).json({ success: false, error });
return; return;
@@ -97,43 +46,51 @@ export function createRouter(app: ApplicationService, sse: SseHub): Router {
}); });
router.post("/control/demo", (req: Request, res: Response) => { router.post("/control/demo", (req: Request, res: Response) => {
const body = parseBody(DemoControlBodySchema, req, res); const enabled = req.body?.enabled;
if (body === null) return; if (typeof enabled !== "boolean") {
res.status(400).json({ success: false, error: "enabled must be a boolean" });
app.setDemoMode(body.enabled); return;
res.json({ success: true, data: { demoMode: body.enabled } }); }
app.setDemoMode(enabled);
res.json({ success: true, data: { demoMode: enabled } });
}); });
router.post("/control/record", (req: Request, res: Response) => { router.post("/control/record", (req: Request, res: Response) => {
const body = parseBody(RecordControlBodySchema, req, res); const enabled = req.body?.enabled;
if (body === null) return; if (typeof enabled !== "boolean") {
res.status(400).json({ success: false, error: "enabled must be a boolean" });
app.setRecordFeed(body.enabled); return;
res.json({ success: true, data: { recordFeed: body.enabled } }); }
app.setRecordFeed(enabled);
res.json({ success: true, data: { recordFeed: enabled } });
}); });
router.post("/control/threshold", (req: Request, res: Response) => { router.post("/control/threshold", (req: Request, res: Response) => {
const body = parseBody(ThresholdControlBodySchema, req, res); const pct = req.body?.pct;
if (body === null) return; if (typeof pct !== "number" || !Number.isFinite(pct)) {
res.status(400).json({ success: false, error: "pct must be a finite number" });
const error = app.setThreshold(body.pct); return;
}
const error = app.setThreshold(pct);
if (error) { if (error) {
res.status(400).json({ success: false, error }); res.status(400).json({ success: false, error });
return; return;
} }
res.json({ success: true, data: { minNetProfitPct: body.pct } }); res.json({ success: true, data: { minNetProfitPct: pct } });
}); });
router.post("/control/max-trade", (req: Request, res: Response) => { router.post("/control/max-trade", (req: Request, res: Response) => {
const body = parseBody(MaxTradeControlBodySchema, req, res); const btc = req.body?.btc;
if (body === null) return; if (typeof btc !== "number" || !Number.isFinite(btc)) {
res.status(400).json({ success: false, error: "btc must be a finite number" });
const error = app.setMaxTradeBtc(body.btc); return;
}
const error = app.setMaxTradeBtc(btc);
if (error) { if (error) {
res.status(400).json({ success: false, error }); res.status(400).json({ success: false, error });
return; return;
} }
res.json({ success: true, data: { maxTradeBtc: body.btc } }); res.json({ success: true, data: { maxTradeBtc: btc } });
}); });
return router; return router;
-59
View File
@@ -1,59 +0,0 @@
import { extendZodWithOpenApi } from "@asteasolutions/zod-to-openapi";
import { z } from "zod";
extendZodWithOpenApi(z);
export { z };
export const ExchangeIdSchema = z
.enum(["kraken", "bybit", "okx", "binance"])
.openapi("ExchangeId");
export const FeedStatusSchema = z
.enum(["connecting", "live", "stale", "down"])
.openapi("FeedStatus");
export const CircuitStateSchema = z
.enum(["running", "paused", "tripped"])
.openapi("CircuitState");
export const OpportunityStatusSchema = z
.enum([
"executed",
"executed_partial",
"rejected_fees",
"rejected_liquidity",
"rejected_risk",
"rejected_flicker",
"rejected_stale",
"pending_confirm",
])
.openapi("OpportunityStatus");
export const ErrorEnvelopeSchema = z
.object({
success: z.literal(false),
error: z.string(),
})
.strict()
.openapi("ErrorEnvelope");
export const SuccessEnvelopeNoDataSchema = z
.object({
success: z.literal(true),
})
.strict()
.openapi("SuccessEnvelopeNoData");
export function successEnvelope<T extends z.ZodType>(
dataSchema: T,
name: string,
) {
return z
.object({
success: z.literal(true),
data: dataSchema,
})
.strict()
.openapi(name);
}
-161
View File
@@ -1,161 +0,0 @@
import {
CircuitStateSchema,
ExchangeIdSchema,
FeedStatusSchema,
OpportunityStatusSchema,
z,
} from "./common.js";
export const BestQuoteSchema = z
.object({
exchange: ExchangeIdSchema,
bid: z.number().nullable(),
bidQty: z.number().nullable(),
ask: z.number().nullable(),
askQty: z.number().nullable(),
recvTs: z.number().int().nullable(),
status: FeedStatusSchema,
ageMs: z.number().int().nullable(),
})
.strict()
.openapi("BestQuote");
export const WalletSchema = z
.object({
exchange: ExchangeIdSchema,
usdt: z.number(),
btc: z.number(),
})
.strict()
.openapi("Wallet");
export const OpportunitySchema = z
.object({
id: z.string(),
ts: z.number().int(),
buyExchange: ExchangeIdSchema,
sellExchange: ExchangeIdSchema,
topBuyAsk: z.number(),
topSellBid: z.number(),
volumeBtc: z.number(),
buyVwap: z.number(),
sellVwap: z.number(),
grossSpread: z.number(),
grossSpreadPct: z.number(),
feeBuy: z.number(),
feeSell: z.number(),
netProfit: z.number(),
netProfitPct: z.number(),
status: OpportunityStatusSchema,
reason: z.string(),
demo: z.boolean(),
})
.strict()
.openapi("Opportunity");
export const TradeSchema = z
.object({
id: z.string(),
ts: z.number().int(),
buyExchange: ExchangeIdSchema,
sellExchange: ExchangeIdSchema,
volumeBtc: z.number(),
requestedBtc: z.number(),
buyVwap: z.number(),
sellVwap: z.number(),
execBuyVwap: z.number(),
execSellVwap: z.number(),
feeBuy: z.number(),
feeSell: z.number(),
netProfit: z.number(),
netProfitPct: z.number(),
partial: z.boolean(),
demo: z.boolean(),
})
.strict()
.openapi("Trade");
export const RebalanceEventSchema = z
.object({
id: z.string(),
ts: z.number().int(),
fromExchange: ExchangeIdSchema,
toExchange: ExchangeIdSchema,
asset: z.enum(["BTC", "USDT"]),
amount: z.number(),
withdrawalFee: z.number(),
reason: z.string(),
})
.strict()
.openapi("RebalanceEvent");
export const PnlPointSchema = z
.object({
ts: z.number().int(),
pnl: z.number(),
})
.strict()
.openapi("PnlPoint");
export const EngineStatsSchema = z
.object({
uptimeMs: z.number().int(),
ticksProcessed: z.number().int(),
opportunitiesDetected: z.number().int(),
tradesExecuted: z.number().int(),
tradesRejected: z.number().int(),
realizedPnl: z.number(),
consecutiveLosses: z.number().int(),
circuit: CircuitStateSchema,
demoMode: z.boolean(),
avgTickMs: z.number(),
})
.strict()
.openapi("EngineStats");
const ActiveExchangesSchema = z.record(ExchangeIdSchema, z.boolean());
export const PublicConfigSchema = z
.object({
minNetProfitPct: z.number(),
maxTradeBtc: z.number(),
staleMs: z.number().int(),
flickerConfirmMs: z.number().int(),
latencyMs: z.number().int(),
activeExchanges: ActiveExchangesSchema,
defaults: z
.object({
minNetProfitPct: z.number(),
maxTradeBtc: z.number(),
flickerConfirmMs: z.number().int(),
activeExchanges: ActiveExchangesSchema,
})
.strict(),
takerFees: z.record(ExchangeIdSchema, z.number()),
withdrawalFeesBtc: z.record(ExchangeIdSchema, z.number()),
})
.strict()
.openapi("PublicConfig");
export const StateSnapshotSchema = z
.object({
ts: z.number().int(),
quotes: z.array(BestQuoteSchema),
wallets: z.array(WalletSchema),
stats: EngineStatsSchema,
recentOpportunities: z.array(OpportunitySchema),
recentTrades: z.array(TradeSchema),
rebalances: z.array(RebalanceEventSchema),
pnlSeries: z.array(PnlPointSchema),
config: PublicConfigSchema,
})
.strict()
.openapi("StateSnapshot");
export const HealthDataSchema = z
.object({
status: z.literal("ok"),
ts: z.number().int(),
})
.strict()
.openapi("HealthData");
-53
View File
@@ -1,53 +0,0 @@
import { ExchangeIdSchema, z } from "./common.js";
const MIN_PROFIT_PCT = 0.0001;
const MAX_PROFIT_PCT = 0.01;
const MIN_TRADE_BTC = 0.01;
const MAX_TRADE_BTC = 1.0;
const MAX_FLICKER_MS = 500;
export const BooleanControlBodySchema = z
.object({
enabled: z.boolean(),
})
.strict()
.openapi("BooleanControlBody");
export const DemoControlBodySchema =
BooleanControlBodySchema.openapi("DemoControlBody");
export const RecordControlBodySchema =
BooleanControlBodySchema.openapi("RecordControlBody");
export const ThresholdControlBodySchema = z
.object({
pct: z.number().finite().min(MIN_PROFIT_PCT).max(MAX_PROFIT_PCT),
})
.strict()
.openapi("ThresholdControlBody");
export const MaxTradeControlBodySchema = z
.object({
btc: z.number().finite().min(MIN_TRADE_BTC).max(MAX_TRADE_BTC),
})
.strict()
.openapi("MaxTradeControlBody");
export const ConfigPatchSchema = z
.object({
minNetProfitPct: z
.number()
.finite()
.min(MIN_PROFIT_PCT)
.max(MAX_PROFIT_PCT)
.optional(),
maxTradeBtc: z
.number()
.finite()
.min(MIN_TRADE_BTC)
.max(MAX_TRADE_BTC)
.optional(),
flickerConfirmMs: z.number().int().min(0).max(MAX_FLICKER_MS).optional(),
activeExchanges: z.record(ExchangeIdSchema, z.boolean()).optional(),
})
.strict()
.openapi("ConfigPatch");
-42
View File
@@ -1,42 +0,0 @@
import { successEnvelope, SuccessEnvelopeNoDataSchema } from "./common.js";
import {
HealthDataSchema,
PublicConfigSchema,
StateSnapshotSchema,
} from "./domain.js";
import { z } from "./common.js";
export const HealthResponseSchema = successEnvelope(
HealthDataSchema,
"HealthResponse",
);
export const StateResponseSchema = successEnvelope(
StateSnapshotSchema,
"StateResponse",
);
export const ConfigResponseSchema = successEnvelope(
PublicConfigSchema,
"ConfigResponse",
);
export const DemoModeResponseSchema = successEnvelope(
z.object({ demoMode: z.boolean() }).strict(),
"DemoModeResponse",
);
export const RecordFeedResponseSchema = successEnvelope(
z.object({ recordFeed: z.boolean() }).strict(),
"RecordFeedResponse",
);
export const ThresholdResponseSchema = successEnvelope(
z.object({ minNetProfitPct: z.number() }).strict(),
"ThresholdResponse",
);
export const MaxTradeResponseSchema = successEnvelope(
z.object({ maxTradeBtc: z.number() }).strict(),
"MaxTradeResponse",
);
export { SuccessEnvelopeNoDataSchema };
-18
View File
@@ -1,18 +0,0 @@
import type { Request, Response } from "express";
import type { ZodType } from "zod";
export function parseBody<T>(
schema: ZodType<T>,
req: Request,
res: Response,
): T | null {
const result = schema.safeParse(req.body ?? {});
if (!result.success) {
const message = result.error.issues
.map((issue) => issue.message)
.join("; ");
res.status(400).json({ success: false, error: message });
return null;
}
return result.data;
}
+15 -50
View File
@@ -1,9 +1,6 @@
import type { Request, Response } from "express"; import type { Request, Response } from "express";
import * as Sentry from "@sentry/node";
import { config } from "../../infrastructure/config/config.js"; import { config } from "../../infrastructure/config/config.js";
import { setCachedSnapshot } from "../../infrastructure/cache/upstash.js";
import { createLogger } from "../../infrastructure/logging/logger.js"; import { createLogger } from "../../infrastructure/logging/logger.js";
import { withSpan } from "../../instrumentation/otel.js";
import type { ApplicationService } from "../../composition/application-service.js"; import type { ApplicationService } from "../../composition/application-service.js";
const log = createLogger("sse"); const log = createLogger("sse");
@@ -25,58 +22,26 @@ export class SseHub {
} }
handle(req: Request, res: Response): void { handle(req: Request, res: Response): void {
try { res.writeHead(200, {
res.writeHead(200, { "Content-Type": "text/event-stream",
"Content-Type": "text/event-stream", "Cache-Control": "no-cache, no-transform",
"Cache-Control": "no-cache, no-transform", Connection: "keep-alive",
Connection: "keep-alive", "X-Accel-Buffering": "no",
"X-Accel-Buffering": "no", });
}); res.write(`retry: 2000\n\n`);
res.write(`retry: 2000\n\n`); this.send(res, this.app.getSnapshot());
this.send(res, this.app.getSnapshot()); this.clients.add(res);
this.clients.add(res); log.info(`client connected (${this.clients.size} total)`);
log.info(`client connected`, { clientCount: this.clients.size });
req.on("close", () => { req.on("close", () => {
this.clients.delete(res); this.clients.delete(res);
}); });
res.on("error", (err) => {
const error = err instanceof Error ? err : new Error(String(err));
log.error("sse client error", { error: error.message });
Sentry.captureException(error, { tags: { component: "sse" } });
this.clients.delete(res);
});
} catch (err) {
const error = err instanceof Error ? err : new Error(String(err));
log.error("sse handle failed", { error: error.message });
Sentry.captureException(error, { tags: { component: "sse" } });
if (!res.headersSent) {
res.status(500).json({ success: false, error: "SSE setup failed" });
}
}
} }
private broadcast(): void { private broadcast(): void {
if (this.clients.size === 0) return; if (this.clients.size === 0) return;
void withSpan( const snapshot = this.app.getSnapshot();
"sse.broadcast", for (const res of this.clients) this.send(res, snapshot);
{ clientCount: this.clients.size },
async () => {
const snapshot = this.app.getSnapshot();
void setCachedSnapshot(snapshot);
for (const res of this.clients) {
try {
this.send(res, snapshot);
} catch (err) {
const error = err instanceof Error ? err : new Error(String(err));
log.warn("sse send failed", { error: error.message });
Sentry.captureException(error, { tags: { component: "sse" } });
this.clients.delete(res);
}
}
},
);
} }
private send(res: Response, data: unknown): void { private send(res: Response, data: unknown): void {
-9
View File
@@ -1,9 +0,0 @@
import { existsSync } from "node:fs";
import { loadEnvFile } from "node:process";
for (const file of [".env.local", ".env"]) {
if (existsSync(file)) {
loadEnvFile(file);
break;
}
}
-35
View File
@@ -1,35 +0,0 @@
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
/** Loads `.env.local` without overriding variables already set in the shell. */
export function loadEnvLocal(): void {
const file = join(process.cwd(), ".env.local");
if (!existsSync(file)) {
return;
}
for (const line of readFileSync(file, "utf8").split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) {
continue;
}
const eq = trimmed.indexOf("=");
if (eq <= 0) {
continue;
}
const key = trimmed.slice(0, eq).trim();
let value = trimmed.slice(eq + 1).trim();
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1);
}
if (process.env[key] === undefined) {
process.env[key] = value;
}
}
}
+1 -8
View File
@@ -2,16 +2,9 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <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> <title>Arb Pulse — BTC Arbitrage Engine</title>
<meta name="description" content="Real-time cross-exchange BTC arbitrage detection & simulation" /> <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.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link <link
+5 -22
View File
@@ -27,35 +27,18 @@
"name": "arb-pulse", "name": "arb-pulse",
"version": "1.0.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"@asteasolutions/zod-to-openapi": "^8.5.0",
"@opentelemetry/api": "^1.9.1",
"@opentelemetry/sdk-trace-node": "^2.7.1",
"@scalar/express-api-reference": "^0.8.48",
"@sentry/node": "^10.57.0",
"@sentry/opentelemetry": "^10.57.0",
"@upstash/ratelimit": "^2.0.6",
"@upstash/redis": "^1.35.3",
"express": "^4.21.2", "express": "^4.21.2",
"pino": "^10.3.1", "swagger-ui-express": "^5.0.1",
"pino-pretty": "^13.1.3",
"tsx": "^4.19.2", "tsx": "^4.19.2",
"ws": "^8.18.0", "ws": "^8.18.0"
"zod": "^4.4.3"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.18.0",
"@playwright/test": "^1.51.1",
"@types/express": "^4.17.21", "@types/express": "^4.17.21",
"@types/node": "^22.10.5", "@types/node": "^22.10.5",
"@types/swagger-ui-express": "^4.1.8",
"@types/ws": "^8.5.13", "@types/ws": "^8.5.13",
"eslint": "^9.18.0", "openapi-types": "^12.1.3",
"eslint-plugin-react-hooks": "^5.1.0", "typescript": "^5.7.3"
"globals": "^15.14.0",
"husky": "^9.1.7",
"lint-staged": "^16.4.0",
"prettier": "^3.8.3",
"typescript": "^5.7.3",
"typescript-eslint": "^8.26.0"
}, },
"engines": { "engines": {
"node": ">=20" "node": ">=20"
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 ( return (
<div className="mx-auto max-w-[1400px] px-4 pb-6 pt-[max(1.5rem,env(safe-area-inset-top))] lg:px-8"> <div className="mx-auto max-w-[1400px] px-4 py-6 lg:px-8">
<header className="mb-6 flex items-end justify-between gap-3"> <header className="mb-6 flex items-end justify-between">
<div> <div>
<h1 className="font-display text-2xl font-extrabold tracking-tight text-slate-100"> <h1 className="font-display text-2xl font-extrabold tracking-tight text-slate-100">
Arb<span className="text-accent">Pulse</span> Arb<span className="text-accent">Pulse</span>
</h1> </h1>
<p className="text-sm text-slate-500"> <p className="text-sm text-slate-500">Real-time cross-exchange BTC arbitrage engine · Kraken · Bybit · OKX · Binance</p>
Real-time cross-exchange BTC arbitrage engine · Kraken · Bybit · OKX
· Binance
</p>
</div> </div>
<p className="hidden text-xs text-slate-600 sm:block"> <p className="hidden text-xs text-slate-600 sm:block">
BTC/USDT · WebSocket feeds · inventory model BTC/USDT · WebSocket feeds · inventory model
@@ -36,9 +33,7 @@ export function App(): JSX.Element {
</header> </header>
{!snapshot ? ( {!snapshot ? (
<div className="flex h-[60vh] items-center justify-center text-slate-500"> <div className="flex h-[60vh] items-center justify-center text-slate-500">Connecting to engine</div>
Connecting to engine
</div>
) : ( ) : (
<div className="space-y-5"> <div className="space-y-5">
<StatsBar stats={snapshot.stats} connected={connected} /> <StatsBar stats={snapshot.stats} connected={connected} />
@@ -52,10 +47,7 @@ export function App(): JSX.Element {
<div className="space-y-5"> <div className="space-y-5">
<Controls stats={snapshot.stats} config={snapshot.config} /> <Controls stats={snapshot.stats} config={snapshot.config} />
<ConfigPanel config={snapshot.config} /> <ConfigPanel config={snapshot.config} />
<Wallets <Wallets wallets={snapshot.wallets} rebalances={snapshot.rebalances} />
wallets={snapshot.wallets}
rebalances={snapshot.rebalances}
/>
<OpportunityFeed opportunities={snapshot.recentOpportunities} /> <OpportunityFeed opportunities={snapshot.recentOpportunities} />
</div> </div>
</div> </div>
+7 -22
View File
@@ -37,21 +37,12 @@ function Toggle({
type="button" type="button"
onClick={() => onChange(!on)} onClick={() => onChange(!on)}
className={clsx( className={clsx(
"flex min-h-[44px] items-center justify-between gap-3 rounded-xl border px-3 py-2 text-left text-sm transition", "flex items-center justify-between gap-3 rounded-xl border px-3 py-2 text-left text-sm transition",
modified modified ? "border-accent/50 bg-accent/5" : "border-ink-600/50 bg-ink-700/30 hover:border-ink-500",
? "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")}> <span className={clsx(modified ? "text-accent" : "text-slate-300")}>{label}</span>
{label} <span className={clsx("relative h-5 w-9 rounded-full transition", on ? "bg-accent" : "bg-ink-500")}>
</span>
<span
className={clsx(
"relative h-5 w-9 rounded-full transition",
on ? "bg-accent" : "bg-ink-500",
)}
>
<span <span
className={clsx( className={clsx(
"absolute top-0.5 h-4 w-4 rounded-full bg-white transition-all", "absolute top-0.5 h-4 w-4 rounded-full bg-white transition-all",
@@ -86,15 +77,11 @@ function SliderRow({
<div <div
className={clsx( className={clsx(
"rounded-xl border px-3 py-2", "rounded-xl border px-3 py-2",
modified modified ? "border-accent/50 bg-accent/5" : "border-ink-600/50 bg-ink-700/30",
? "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"> <div className="mb-1 flex items-center justify-between text-sm">
<span className={clsx(modified ? "text-accent" : "text-slate-300")}> <span className={clsx(modified ? "text-accent" : "text-slate-300")}>{label}</span>
{label}
</span>
<span className="font-mono text-accent">{valueLabel}</span> <span className="font-mono text-accent">{valueLabel}</span>
</div> </div>
<input <input
@@ -192,9 +179,7 @@ export function ConfigPanel({ config }: Props): JSX.Element {
/> />
<div className="space-y-2"> <div className="space-y-2">
<p className="text-xs font-medium uppercase tracking-wider text-slate-500"> <p className="text-xs font-medium uppercase tracking-wider text-slate-500">Active exchanges</p>
Active exchanges
</p>
{EXCHANGES.map(({ id, label }) => ( {EXCHANGES.map(({ id, label }) => (
<Toggle <Toggle
key={id} key={id}
+8 -28
View File
@@ -9,28 +9,15 @@ interface Props {
config: PublicConfig; config: PublicConfig;
} }
function Toggle({ function Toggle({ label, on, onChange }: { label: string; on: boolean; onChange: (v: boolean) => void }): JSX.Element {
label,
on,
onChange,
}: {
label: string;
on: boolean;
onChange: (v: boolean) => void;
}): JSX.Element {
return ( return (
<button <button
type="button" type="button"
onClick={() => onChange(!on)} 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="text-slate-300">{label}</span>
<span <span className={clsx("relative h-5 w-9 rounded-full transition", on ? "bg-accent" : "bg-ink-500")}>
className={clsx(
"relative h-5 w-9 rounded-full transition",
on ? "bg-accent" : "bg-ink-500",
)}
>
<span <span
className={clsx( className={clsx(
"absolute top-0.5 h-4 w-4 rounded-full bg-white transition-all", "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 <button
type="button" type="button"
onClick={() => control.resume()} 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 Resume
</button> </button>
@@ -62,7 +49,7 @@ export function Controls({ stats, config }: Props): JSX.Element {
<button <button
type="button" type="button"
onClick={() => control.pause()} 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 Pause
</button> </button>
@@ -70,17 +57,13 @@ export function Controls({ stats, config }: Props): JSX.Element {
<button <button
type="button" type="button"
onClick={() => control.reset()} 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 Reset
</button> </button>
</div> </div>
<Toggle <Toggle label="Demo mode (synthetic feed)" on={stats.demoMode} onChange={(v) => control.setDemo(v)} />
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="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"> <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"> <div className="flex justify-between">
<span>Taker fees</span> <span>Taker fees</span>
<span className="font-mono"> <span className="font-mono">
K {pct(config.takerFees.kraken, 2)} · By{" "} K {pct(config.takerFees.kraken, 2)} · By {pct(config.takerFees.bybit, 2)} · O {pct(config.takerFees.okx, 2)} · Bn {pct(config.takerFees.binance, 2)}
{pct(config.takerFees.bybit, 2)} · O{" "}
{pct(config.takerFees.okx, 2)} · Bn{" "}
{pct(config.takerFees.binance, 2)}
</span> </span>
</div> </div>
<div className="mt-1 flex justify-between"> <div className="mt-1 flex justify-between">
+8 -100
View File
@@ -50,83 +50,7 @@ export function PriceMatrix({ quotes }: Props): JSX.Element {
) : undefined ) : undefined
} }
> >
{/* Mobile: per-venue stacked cards (no horizontal overflow). */} <div className="overflow-hidden rounded-xl border border-ink-600/50">
<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">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
<tr className="bg-ink-700/50 text-left text-xs uppercase tracking-wider text-slate-400"> <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> </thead>
<tbody className="font-mono tabular-nums"> <tbody className="font-mono tabular-nums">
{quotes.map((q) => { {quotes.map((q) => {
const spread = const spread = q.ask !== null && q.bid !== null ? q.ask - q.bid : null;
q.ask !== null && q.bid !== null ? q.ask - q.bid : null;
return ( return (
<tr key={q.exchange} className="border-t border-ink-600/40"> <tr key={q.exchange} className="border-t border-ink-600/40">
<td className="px-4 py-2.5"> <td className="px-4 py-2.5">
<span className="flex items-center gap-2 font-display text-sm"> <span className="flex items-center gap-2 font-display text-sm">
<span <span className={clsx("h-2 w-2 rounded-full", statusDot(q.status))} />
className={clsx(
"h-2 w-2 rounded-full",
statusDot(q.status),
)}
/>
{LABELS[q.exchange]} {LABELS[q.exchange]}
</span> </span>
</td> </td>
<td <td
className={clsx( className={clsx(
"px-4 py-2.5 text-right", "px-4 py-2.5 text-right",
q.bid !== null && q.bid === maxBid q.bid !== null && q.bid === maxBid ? "font-semibold text-profit" : "text-slate-300",
? "font-semibold text-profit"
: "text-slate-300",
)} )}
> >
{q.bid !== null ? usd(q.bid) : "—"} {q.bid !== null ? usd(q.bid) : "—"}
</td> </td>
<td className="px-4 py-2.5 text-right text-slate-500"> <td className="px-4 py-2.5 text-right text-slate-500">{q.bidQty?.toFixed(3) ?? "—"}</td>
{q.bidQty?.toFixed(3) ?? "—"}
</td>
<td <td
className={clsx( className={clsx(
"px-4 py-2.5 text-right", "px-4 py-2.5 text-right",
q.ask !== null && q.ask === minAsk q.ask !== null && q.ask === minAsk ? "font-semibold text-accent" : "text-slate-300",
? "font-semibold text-accent"
: "text-slate-300",
)} )}
> >
{q.ask !== null ? usd(q.ask) : "—"} {q.ask !== null ? usd(q.ask) : "—"}
</td> </td>
<td className="px-4 py-2.5 text-right text-slate-500"> <td className="px-4 py-2.5 text-right text-slate-500">{q.askQty?.toFixed(3) ?? "—"}</td>
{q.askQty?.toFixed(3) ?? "—"} <td className="px-4 py-2.5 text-right text-slate-400">{spread !== null ? usd(spread) : "—"}</td>
</td>
<td className="px-4 py-2.5 text-right text-slate-400">
{spread !== null ? usd(spread) : "—"}
</td>
</tr> </tr>
); );
})} })}
+11 -46
View File
@@ -6,20 +6,10 @@ interface Props {
connected: boolean; connected: boolean;
} }
function Stat({ function Stat({ label, value, tone }: { label: string; value: string; tone?: "profit" | "loss" | "warn" }): JSX.Element {
label,
value,
tone,
}: {
label: string;
value: string;
tone?: "profit" | "loss" | "warn";
}): JSX.Element {
return ( return (
<div className="flex flex-col"> <div className="flex flex-col">
<span className="text-[10px] uppercase tracking-widest text-slate-500"> <span className="text-[10px] uppercase tracking-widest text-slate-500">{label}</span>
{label}
</span>
<span <span
className={clsx( className={clsx(
"font-mono text-lg font-semibold tabular-nums", "font-mono text-lg font-semibold tabular-nums",
@@ -35,23 +25,14 @@ function Stat({
); );
} }
function circuitTone(c: EngineStats["circuit"]): { function circuitTone(c: EngineStats["circuit"]): { label: string; cls: string } {
label: string;
cls: string;
} {
switch (c) { switch (c) {
case "running": case "running":
return { return { label: "RUNNING", cls: "bg-profit/15 text-profit border-profit/40" };
label: "RUNNING",
cls: "bg-profit/15 text-profit border-profit/40",
};
case "paused": case "paused":
return { label: "PAUSED", cls: "bg-warn/15 text-warn border-warn/40" }; return { label: "PAUSED", cls: "bg-warn/15 text-warn border-warn/40" };
case "tripped": case "tripped":
return { return { label: "BREAKER TRIPPED", cls: "bg-loss/15 text-loss border-loss/40" };
label: "BREAKER TRIPPED",
cls: "bg-loss/15 text-loss border-loss/40",
};
default: { default: {
const _exhaustive: never = c; const _exhaustive: never = c;
return { label: _exhaustive, cls: "" }; return { label: _exhaustive, cls: "" };
@@ -64,45 +45,29 @@ export function StatsBar({ stats, connected }: Props): JSX.Element {
const pnlTone = stats.realizedPnl >= 0 ? "profit" : "loss"; const pnlTone = stats.realizedPnl >= 0 ? "profit" : "loss";
return ( 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"> <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 <Stat label="Realized P&L" value={`$${usd(stats.realizedPnl)}`} tone={pnlTone} />
label="Realized P&L"
value={`$${usd(stats.realizedPnl)}`}
tone={pnlTone}
/>
<Stat label="Trades" value={String(stats.tradesExecuted)} /> <Stat label="Trades" value={String(stats.tradesExecuted)} />
<Stat label="Opportunities" value={String(stats.opportunitiesDetected)} /> <Stat label="Opportunities" value={String(stats.opportunitiesDetected)} />
<Stat label="Rejected" value={String(stats.tradesRejected)} tone="warn" /> <Stat label="Rejected" value={String(stats.tradesRejected)} tone="warn" />
<Stat label="Ticks" value={stats.ticksProcessed.toLocaleString()} /> <Stat label="Ticks" value={stats.ticksProcessed.toLocaleString()} />
<Stat label="Engine /tick" value={`${stats.avgTickMs.toFixed(3)}ms`} /> <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 && ( {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"> <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 Demo / Simulated Feed
</span> </span>
)} )}
<span <span className={clsx("rounded-full border px-3 py-1 text-xs font-semibold tracking-wider", circuit.cls)}>
className={clsx(
"rounded-full border px-3 py-1 text-xs font-semibold tracking-wider",
circuit.cls,
)}
>
{circuit.label} {circuit.label}
</span> </span>
<span <span
className={clsx( className={clsx(
"flex items-center gap-2 rounded-full border px-3 py-1 text-xs font-semibold tracking-wider", "flex items-center gap-2 rounded-full border px-3 py-1 text-xs font-semibold tracking-wider",
connected connected ? "border-profit/40 bg-profit/10 text-profit" : "border-loss/40 bg-loss/10 text-loss",
? "border-profit/40 bg-profit/10 text-profit"
: "border-loss/40 bg-loss/10 text-loss",
)} )}
> >
<span <span className={clsx("h-2 w-2 rounded-full", connected ? "bg-profit" : "bg-loss")} />
className={clsx(
"h-2 w-2 rounded-full",
connected ? "bg-profit" : "bg-loss",
)}
/>
{connected ? "LIVE" : "OFFLINE"} {connected ? "LIVE" : "OFFLINE"}
</span> </span>
</div> </div>