Compare commits
30 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 75dcedcd99 | |||
| b7e5396690 | |||
| 5f8da3487f | |||
| 71c45dca9a | |||
| eddea045ec | |||
| 2ca2e25b23 | |||
| bbe4188119 | |||
| 407005b507 | |||
| f6108d8f8f | |||
| bf27435e30 | |||
| 4ffce9a59e | |||
| 14ea7e3fbd | |||
| 5d84df3e25 | |||
| 4cdfac5ff5 | |||
| 4e8b113b4e | |||
| 27d259a881 | |||
| d7e04a99dd | |||
| 94072b668c | |||
| 80413a3af4 | |||
| 948e87beee | |||
| 3c7c3593ba | |||
| 70b4aa4e92 | |||
| 02a5cbe430 | |||
| a5c83ee026 | |||
| f6e8381c20 | |||
| 8c7127cfde | |||
| e04f3977c6 | |||
| fd4170963e | |||
| f4def0c214 | |||
| b5ff33d716 |
@@ -22,3 +22,18 @@ 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"
|
||||||
|
|||||||
@@ -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@v7
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: "20"
|
node-version: "20"
|
||||||
cache: npm
|
cache: npm
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
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
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
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
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
# 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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
npx lint-staged
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"semi": true,
|
||||||
|
"singleQuote": false,
|
||||||
|
"trailingComma": "all",
|
||||||
|
"tabWidth": 2
|
||||||
|
}
|
||||||
@@ -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 | [Fly.io](https://fly.io) — Docker multi-stage (`Dockerfile` + `fly.toml`) |
|
| Deploy | VPS Hetzner — Docker Compose + GHCR (CI/CD en `vps-deploy.yml`); Fly.io como alternativa manual |
|
||||||
|
|
||||||
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,6 +164,7 @@ 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.
|
||||||
@@ -301,27 +302,25 @@ 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` | **Swagger UI** — documentación interactiva OpenAPI 3.0 |
|
| `GET` | `/api-docs` | **Scalar** — documentación interactiva OpenAPI 3.0 |
|
||||||
|
|
||||||
Contrato completo y schemas en `src/interfaces/http/openapi.ts`.
|
Contrato generado desde Zod (`src/interfaces/http/schemas/`) con `@asteasolutions/zod-to-openapi`; registro de rutas en `src/interfaces/http/openapi.ts`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Deploy en Fly.io
|
## Deploy (producción: VPS Hetzner + GHCR)
|
||||||
|
|
||||||
El repo incluye `Dockerfile` (multi-stage: build Vite + runtime Node), `fly.toml` y el workflow `.github/workflows/fly-deploy.yml`.
|
Producción corre en una **VPS de Hetzner** (`https://arbpulse.wayool.com`) con Docker Compose detrás de nginx. El deploy es **automático**:
|
||||||
|
|
||||||
1. Instala [flyctl](https://fly.io/docs/flyctl/install/) e inicia sesión: `fly auth login`.
|
1. Push/merge a `main` dispara `.github/workflows/vps-deploy.yml`.
|
||||||
2. Desde la raíz del repo: `fly launch` (primera vez) o `fly deploy`.
|
2. GitHub Actions buildea la imagen (`Dockerfile` multi-stage) y la publica en **GHCR** (`ghcr.io/mauricioabh/arbpulse`, tags `latest` + `sha-<commit>`).
|
||||||
3. **Región primaria:** `sin` (Singapore) — menor latencia a matching engines de Binance, Bybit y OKX.
|
3. El workflow entra por SSH a la VPS, hace `docker compose pull` + `up -d` y espera el health check (`GET /api/health`).
|
||||||
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).
|
|
||||||
|
|
||||||
> Producción usa **Fly.io**. `railway.json` es un artefacto legacy del deploy anterior; no lo uses.
|
Detalles, bootstrap manual y rollback: [`deploy/README.md`](deploy/README.md).
|
||||||
|
|
||||||
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.
|
> Fly.io queda como opción alternativa manual (`fly-deploy.yml` vía workflow_dispatch, requiere `FLY_API_TOKEN`; región recomendada `sin`). `railway.json` es un artefacto legacy; no lo uses.
|
||||||
|
|
||||||
|
Para una demo con actividad visible en pocos segundos, define `DEMO_MODE=true` en `deploy/.env` de la VPS o actívalo desde Controls.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -343,6 +342,17 @@ 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.
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# 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=
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
# 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.
|
||||||
Executable
+96
@@ -0,0 +1,96 @@
|
|||||||
|
#!/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
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
# 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:
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
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 });
|
||||||
|
});
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
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();
|
||||||
|
});
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-07-16
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
## 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 ~360–430px 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 label–value 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.
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
## 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 (~360–430px): 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.
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
## 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 (~360–430px 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
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
## 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 label–value 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
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-07-16
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
## 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.
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
## 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.
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
## 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
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
## 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
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-07-19
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
# 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.
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# 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).
|
||||||
+58
@@ -0,0 +1,58 @@
|
|||||||
|
# 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
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# 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)
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
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
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# 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
|
||||||
|
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
# 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
|
||||||
Generated
+2910
-42
File diff suppressed because it is too large
Load Diff
+38
-6
@@ -12,20 +12,52 @@
|
|||||||
"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",
|
||||||
"swagger-ui-express": "^5.0.1",
|
"pino": "^10.3.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",
|
||||||
"openapi-types": "^12.1.3",
|
"eslint": "^9.18.0",
|
||||||
"typescript": "^5.7.3"
|
"eslint-plugin-react-hooks": "^5.1.0",
|
||||||
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
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.
|
After Width: | Height: | Size: 824 KiB |
@@ -0,0 +1,44 @@
|
|||||||
|
# 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,5 +1,10 @@
|
|||||||
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;
|
||||||
@@ -10,12 +15,30 @@ export class ProcessOrderBookUpdate {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly engine: ArbitrageEngine,
|
private readonly engine: ArbitrageEngine,
|
||||||
private readonly recorder: FeedRecorderPort,
|
private readonly recorder: FeedRecorderPort,
|
||||||
private readonly isExchangeActive: (exchange: OrderBook["exchange"]) => boolean,
|
private readonly isExchangeActive: (
|
||||||
|
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);
|
|
||||||
this.engine.onBook(book);
|
const correlationId = newCorrelationId("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);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+19
-7
@@ -1,5 +1,11 @@
|
|||||||
|
import "./load-env.js";
|
||||||
|
import { initInstrumentation } from "./instrumentation/index.js";
|
||||||
|
|
||||||
|
initInstrumentation();
|
||||||
|
|
||||||
import express from "express";
|
import express from "express";
|
||||||
import swaggerUi from "swagger-ui-express";
|
import * as Sentry from "@sentry/node";
|
||||||
|
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";
|
||||||
@@ -8,7 +14,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 { openapiSpec } from "./interfaces/http/openapi.js";
|
import { openapiDocument } from "./interfaces/http/openapi.js";
|
||||||
|
|
||||||
const log = createLogger("server");
|
const log = createLogger("server");
|
||||||
|
|
||||||
@@ -25,11 +31,15 @@ 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("/api-docs", swaggerUi.serve, swaggerUi.setup(openapiSpec, {
|
server.use(
|
||||||
customSiteTitle: "Arb Pulse — API Docs",
|
"/api-docs",
|
||||||
swaggerOptions: { defaultModelsExpandDepth: 2, defaultModelExpandDepth: 2 },
|
apiReference({
|
||||||
}));
|
content: openapiDocument,
|
||||||
|
pageTitle: "Arb Pulse — API Docs",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
if (existsSync(webDist)) {
|
if (existsSync(webDist)) {
|
||||||
server.use(express.static(webDist));
|
server.use(express.static(webDist));
|
||||||
@@ -40,7 +50,9 @@ function main(): void {
|
|||||||
server.get("/", (_req, res) => {
|
server.get("/", (_req, res) => {
|
||||||
res
|
res
|
||||||
.status(200)
|
.status(200)
|
||||||
.send("Backend running. Frontend not built yet — run `npm run build` (or `npm run dev:web`).");
|
.send(
|
||||||
|
"Backend running. Frontend not built yet — run `npm run build` (or `npm run dev:web`).",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Vendored
+48
@@ -0,0 +1,48 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
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");
|
||||||
|
});
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
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";
|
||||||
@@ -14,7 +16,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 = 15;
|
protected readonly depth: number;
|
||||||
|
|
||||||
protected ws: WebSocket | null = null;
|
protected ws: WebSocket | null = null;
|
||||||
protected book: LocalBook;
|
protected book: LocalBook;
|
||||||
@@ -27,8 +29,10 @@ export abstract class ExchangeConnector implements MarketDataFeed {
|
|||||||
protected closed = false;
|
protected closed = false;
|
||||||
private lastEmitTs = 0;
|
private lastEmitTs = 0;
|
||||||
|
|
||||||
constructor() {
|
/** `depth` must match the depth the connector subscribes with. */
|
||||||
this.book = new LocalBook(this.depth);
|
constructor(depth = 15) {
|
||||||
|
this.depth = depth;
|
||||||
|
this.book = new LocalBook(depth);
|
||||||
}
|
}
|
||||||
|
|
||||||
onBook(listener: BookListener): void {
|
onBook(listener: BookListener): void {
|
||||||
@@ -56,12 +60,16 @@ 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.skipSubscribe() ? "connected" : "connected, subscribing");
|
this.log.info(
|
||||||
|
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", err);
|
this.log.error("subscribe send failed", {
|
||||||
|
error: err instanceof Error ? err.message : String(err),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.startPing();
|
this.startPing();
|
||||||
@@ -72,15 +80,27 @@ 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;
|
||||||
try {
|
void withSpan(
|
||||||
this.handleMessage(JSON.parse(text));
|
"ws.message",
|
||||||
} catch (err) {
|
{ exchange: this.id, bytes: text.length },
|
||||||
this.log.warn("failed to parse message", err);
|
async () => {
|
||||||
}
|
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) => {
|
||||||
this.log.error("socket error", err instanceof Error ? err.message : err);
|
const error = err instanceof Error ? err : new Error(String(err));
|
||||||
|
this.log.error("socket error", { error: error.message });
|
||||||
|
Sentry.captureException(error, {
|
||||||
|
tags: { exchange: this.id, component: "ws" },
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
ws.on("close", () => {
|
ws.on("close", () => {
|
||||||
@@ -97,8 +117,13 @@ export abstract class ExchangeConnector implements MarketDataFeed {
|
|||||||
|
|
||||||
private scheduleReconnect(): void {
|
private scheduleReconnect(): void {
|
||||||
this.reconnectAttempts += 1;
|
this.reconnectAttempts += 1;
|
||||||
const delay = Math.min(30_000, 500 * 2 ** Math.min(this.reconnectAttempts, 6));
|
const delay = Math.min(
|
||||||
this.log.warn(`disconnected, reconnecting in ${delay}ms (attempt ${this.reconnectAttempts})`);
|
30_000,
|
||||||
|
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);
|
||||||
@@ -142,9 +167,31 @@ 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;
|
||||||
|
|||||||
@@ -19,20 +19,29 @@ 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: 10 },
|
params: { channel: "book", symbol: [this.symbol], depth: this.depth },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,6 +58,7 @@ 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);
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
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",
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -8,7 +8,10 @@ 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(private readonly side: "bid" | "ask", private readonly depth: number) {}
|
constructor(
|
||||||
|
private readonly side: "bid" | "ask",
|
||||||
|
private readonly depth: number,
|
||||||
|
) {}
|
||||||
|
|
||||||
clear(): void {
|
clear(): void {
|
||||||
this.levels.clear();
|
this.levels.clear();
|
||||||
@@ -22,11 +25,27 @@ 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) => (this.side === "bid" ? b.price - a.price : a.price - b.price));
|
arr.sort((a, b) =>
|
||||||
|
this.side === "bid" ? b.price - a.price : a.price - b.price,
|
||||||
|
);
|
||||||
return arr.length > this.depth ? arr.slice(0, this.depth) : arr;
|
return arr.length > this.depth ? arr.slice(0, this.depth) : arr;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,4 +67,10 @@ 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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
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 };
|
||||||
|
}
|
||||||
@@ -1,22 +1,38 @@
|
|||||||
type Level = "info" | "warn" | "error";
|
import pino from "pino";
|
||||||
|
import { getCorrelationContext } from "./correlation.js";
|
||||||
|
|
||||||
function emit(level: Level, scope: string, msg: string, extra?: unknown): void {
|
const root = pino({
|
||||||
const ts = new Date().toISOString();
|
level: process.env.LOG_LEVEL ?? "info",
|
||||||
const line = `${ts} [${level.toUpperCase()}] (${scope}) ${msg}`;
|
base: { service: "arbpulse" },
|
||||||
if (level === "error") {
|
timestamp: pino.stdTimeFunctions.isoTime,
|
||||||
console.error(line, extra ?? "");
|
...(process.env.NODE_ENV !== "production"
|
||||||
} else if (level === "warn") {
|
? {
|
||||||
console.warn(line, extra ?? "");
|
transport: {
|
||||||
} else {
|
target: "pino-pretty",
|
||||||
console.log(line, extra ?? "");
|
options: { colorize: true, singleLine: true },
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
|
: {}),
|
||||||
|
});
|
||||||
|
|
||||||
export function createLogger(scope: string) {
|
export function createLogger(scope: string) {
|
||||||
|
const child = root.child({ scope });
|
||||||
return {
|
return {
|
||||||
info: (msg: string, extra?: unknown) => emit("info", scope, msg, extra),
|
info: (msg: string, extra?: Record<string, unknown>) =>
|
||||||
warn: (msg: string, extra?: unknown) => emit("warn", scope, msg, extra),
|
child.info({ ...bindings(), ...extra }, msg),
|
||||||
error: (msg: string, extra?: unknown) => emit("error", scope, msg, extra),
|
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> {
|
||||||
|
const ctx = getCorrelationContext();
|
||||||
|
if (!ctx) return {};
|
||||||
|
return {
|
||||||
|
correlationId: ctx.correlationId,
|
||||||
|
...(ctx.exchange ? { exchange: ctx.exchange } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { initOpenTelemetry } from "./otel.js";
|
||||||
|
import { initSentry } from "./sentry.js";
|
||||||
|
|
||||||
|
export function initInstrumentation(): void {
|
||||||
|
initSentry();
|
||||||
|
initOpenTelemetry();
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
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 };
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
/**
|
||||||
|
* 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";
|
||||||
|
}
|
||||||
+269
-718
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,60 @@
|
|||||||
|
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");
|
||||||
|
});
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
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",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,16 +1,64 @@
|
|||||||
import { Router, type Request, type Response } from "express";
|
import {
|
||||||
|
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("/state", (_req: Request, res: Response) => {
|
router.get("/debug/sentry", (_req: Request, res: Response) => {
|
||||||
res.json({ success: true, data: app.getSnapshot() });
|
if (process.env.NODE_ENV === "production") {
|
||||||
|
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) => {
|
||||||
@@ -22,7 +70,10 @@ export function createRouter(app: ApplicationService, sse: SseHub): Router {
|
|||||||
});
|
});
|
||||||
|
|
||||||
router.patch("/config", (req: Request, res: Response) => {
|
router.patch("/config", (req: Request, res: Response) => {
|
||||||
const error = app.patchConfig(req.body ?? {});
|
const patch = parseBody(ConfigPatchSchema, req, res);
|
||||||
|
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;
|
||||||
@@ -46,51 +97,43 @@ 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 enabled = req.body?.enabled;
|
const body = parseBody(DemoControlBodySchema, req, res);
|
||||||
if (typeof enabled !== "boolean") {
|
if (body === null) return;
|
||||||
res.status(400).json({ success: false, error: "enabled must be a boolean" });
|
|
||||||
return;
|
app.setDemoMode(body.enabled);
|
||||||
}
|
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 enabled = req.body?.enabled;
|
const body = parseBody(RecordControlBodySchema, req, res);
|
||||||
if (typeof enabled !== "boolean") {
|
if (body === null) return;
|
||||||
res.status(400).json({ success: false, error: "enabled must be a boolean" });
|
|
||||||
return;
|
app.setRecordFeed(body.enabled);
|
||||||
}
|
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 pct = req.body?.pct;
|
const body = parseBody(ThresholdControlBodySchema, req, res);
|
||||||
if (typeof pct !== "number" || !Number.isFinite(pct)) {
|
if (body === null) return;
|
||||||
res.status(400).json({ success: false, error: "pct must be a finite number" });
|
|
||||||
return;
|
const error = app.setThreshold(body.pct);
|
||||||
}
|
|
||||||
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: pct } });
|
res.json({ success: true, data: { minNetProfitPct: body.pct } });
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post("/control/max-trade", (req: Request, res: Response) => {
|
router.post("/control/max-trade", (req: Request, res: Response) => {
|
||||||
const btc = req.body?.btc;
|
const body = parseBody(MaxTradeControlBodySchema, req, res);
|
||||||
if (typeof btc !== "number" || !Number.isFinite(btc)) {
|
if (body === null) return;
|
||||||
res.status(400).json({ success: false, error: "btc must be a finite number" });
|
|
||||||
return;
|
const error = app.setMaxTradeBtc(body.btc);
|
||||||
}
|
|
||||||
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: btc } });
|
res.json({ success: true, data: { maxTradeBtc: body.btc } });
|
||||||
});
|
});
|
||||||
|
|
||||||
return router;
|
return router;
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
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");
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
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");
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
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 };
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
+50
-15
@@ -1,6 +1,9 @@
|
|||||||
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");
|
||||||
@@ -22,26 +25,58 @@ export class SseHub {
|
|||||||
}
|
}
|
||||||
|
|
||||||
handle(req: Request, res: Response): void {
|
handle(req: Request, res: Response): void {
|
||||||
res.writeHead(200, {
|
try {
|
||||||
"Content-Type": "text/event-stream",
|
res.writeHead(200, {
|
||||||
"Cache-Control": "no-cache, no-transform",
|
"Content-Type": "text/event-stream",
|
||||||
Connection: "keep-alive",
|
"Cache-Control": "no-cache, no-transform",
|
||||||
"X-Accel-Buffering": "no",
|
Connection: "keep-alive",
|
||||||
});
|
"X-Accel-Buffering": "no",
|
||||||
res.write(`retry: 2000\n\n`);
|
});
|
||||||
this.send(res, this.app.getSnapshot());
|
res.write(`retry: 2000\n\n`);
|
||||||
this.clients.add(res);
|
this.send(res, this.app.getSnapshot());
|
||||||
log.info(`client connected (${this.clients.size} total)`);
|
this.clients.add(res);
|
||||||
|
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;
|
||||||
const snapshot = this.app.getSnapshot();
|
void withSpan(
|
||||||
for (const res of this.clients) this.send(res, snapshot);
|
"sse.broadcast",
|
||||||
|
{ 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 {
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { existsSync } from "node:fs";
|
||||||
|
import { loadEnvFile } from "node:process";
|
||||||
|
|
||||||
|
for (const file of [".env.local", ".env"]) {
|
||||||
|
if (existsSync(file)) {
|
||||||
|
loadEnvFile(file);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+8
-1
@@ -2,9 +2,16 @@
|
|||||||
<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" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||||
<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
|
||||||
|
|||||||
Generated
+22
-5
@@ -27,18 +27,35 @@
|
|||||||
"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",
|
||||||
"swagger-ui-express": "^5.0.1",
|
"pino": "^10.3.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",
|
||||||
"openapi-types": "^12.1.3",
|
"eslint": "^9.18.0",
|
||||||
"typescript": "^5.7.3"
|
"eslint-plugin-react-hooks": "^5.1.0",
|
||||||
|
"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.
|
After Width: | Height: | Size: 28 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 33 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 254 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 254 KiB |
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
+13
-5
@@ -19,13 +19,16 @@ export function App(): JSX.Element {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-[1400px] px-4 py-6 lg:px-8">
|
<div className="mx-auto max-w-[1400px] px-4 pb-6 pt-[max(1.5rem,env(safe-area-inset-top))] lg:px-8">
|
||||||
<header className="mb-6 flex items-end justify-between">
|
<header className="mb-6 flex items-end justify-between gap-3">
|
||||||
<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">Real-time cross-exchange BTC arbitrage engine · Kraken · Bybit · OKX · Binance</p>
|
<p className="text-sm text-slate-500">
|
||||||
|
Real-time cross-exchange BTC arbitrage engine · Kraken · Bybit · OKX
|
||||||
|
· Binance
|
||||||
|
</p>
|
||||||
</div>
|
</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
|
||||||
@@ -33,7 +36,9 @@ export function App(): JSX.Element {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
{!snapshot ? (
|
{!snapshot ? (
|
||||||
<div className="flex h-[60vh] items-center justify-center text-slate-500">Connecting to engine…</div>
|
<div className="flex h-[60vh] items-center justify-center text-slate-500">
|
||||||
|
Connecting to engine…
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
<StatsBar stats={snapshot.stats} connected={connected} />
|
<StatsBar stats={snapshot.stats} connected={connected} />
|
||||||
@@ -47,7 +52,10 @@ 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={snapshot.wallets} rebalances={snapshot.rebalances} />
|
<Wallets
|
||||||
|
wallets={snapshot.wallets}
|
||||||
|
rebalances={snapshot.rebalances}
|
||||||
|
/>
|
||||||
<OpportunityFeed opportunities={snapshot.recentOpportunities} />
|
<OpportunityFeed opportunities={snapshot.recentOpportunities} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -37,12 +37,21 @@ function Toggle({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => onChange(!on)}
|
onClick={() => onChange(!on)}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"flex items-center justify-between gap-3 rounded-xl border px-3 py-2 text-left text-sm transition",
|
"flex min-h-[44px] items-center justify-between gap-3 rounded-xl border px-3 py-2 text-left text-sm transition",
|
||||||
modified ? "border-accent/50 bg-accent/5" : "border-ink-600/50 bg-ink-700/30 hover:border-ink-500",
|
modified
|
||||||
|
? "border-accent/50 bg-accent/5"
|
||||||
|
: "border-ink-600/50 bg-ink-700/30 hover:border-ink-500",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span className={clsx(modified ? "text-accent" : "text-slate-300")}>{label}</span>
|
<span className={clsx(modified ? "text-accent" : "text-slate-300")}>
|
||||||
<span className={clsx("relative h-5 w-9 rounded-full transition", on ? "bg-accent" : "bg-ink-500")}>
|
{label}
|
||||||
|
</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",
|
||||||
@@ -77,11 +86,15 @@ function SliderRow({
|
|||||||
<div
|
<div
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"rounded-xl border px-3 py-2",
|
"rounded-xl border px-3 py-2",
|
||||||
modified ? "border-accent/50 bg-accent/5" : "border-ink-600/50 bg-ink-700/30",
|
modified
|
||||||
|
? "border-accent/50 bg-accent/5"
|
||||||
|
: "border-ink-600/50 bg-ink-700/30",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="mb-1 flex items-center justify-between text-sm">
|
<div className="mb-1 flex items-center justify-between text-sm">
|
||||||
<span className={clsx(modified ? "text-accent" : "text-slate-300")}>{label}</span>
|
<span className={clsx(modified ? "text-accent" : "text-slate-300")}>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
<span className="font-mono text-accent">{valueLabel}</span>
|
<span className="font-mono text-accent">{valueLabel}</span>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
@@ -179,7 +192,9 @@ 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">Active exchanges</p>
|
<p className="text-xs font-medium uppercase tracking-wider text-slate-500">
|
||||||
|
Active exchanges
|
||||||
|
</p>
|
||||||
{EXCHANGES.map(({ id, label }) => (
|
{EXCHANGES.map(({ id, label }) => (
|
||||||
<Toggle
|
<Toggle
|
||||||
key={id}
|
key={id}
|
||||||
|
|||||||
@@ -9,15 +9,28 @@ interface Props {
|
|||||||
config: PublicConfig;
|
config: PublicConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
function Toggle({ label, on, onChange }: { label: string; on: boolean; onChange: (v: boolean) => void }): JSX.Element {
|
function Toggle({
|
||||||
|
label,
|
||||||
|
on,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
on: boolean;
|
||||||
|
onChange: (v: boolean) => void;
|
||||||
|
}): JSX.Element {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onChange(!on)}
|
onClick={() => onChange(!on)}
|
||||||
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"
|
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"
|
||||||
>
|
>
|
||||||
<span className="text-slate-300">{label}</span>
|
<span className="text-slate-300">{label}</span>
|
||||||
<span className={clsx("relative h-5 w-9 rounded-full transition", on ? "bg-accent" : "bg-ink-500")}>
|
<span
|
||||||
|
className={clsx(
|
||||||
|
"relative h-5 w-9 rounded-full transition",
|
||||||
|
on ? "bg-accent" : "bg-ink-500",
|
||||||
|
)}
|
||||||
|
>
|
||||||
<span
|
<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",
|
||||||
@@ -41,7 +54,7 @@ export function Controls({ stats, config }: Props): JSX.Element {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => control.resume()}
|
onClick={() => control.resume()}
|
||||||
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"
|
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"
|
||||||
>
|
>
|
||||||
Resume
|
Resume
|
||||||
</button>
|
</button>
|
||||||
@@ -49,7 +62,7 @@ export function Controls({ stats, config }: Props): JSX.Element {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => control.pause()}
|
onClick={() => control.pause()}
|
||||||
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"
|
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"
|
||||||
>
|
>
|
||||||
Pause
|
Pause
|
||||||
</button>
|
</button>
|
||||||
@@ -57,13 +70,17 @@ export function Controls({ stats, config }: Props): JSX.Element {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => control.reset()}
|
onClick={() => control.reset()}
|
||||||
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"
|
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"
|
||||||
>
|
>
|
||||||
Reset
|
Reset
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Toggle label="Demo mode (synthetic feed)" on={stats.demoMode} onChange={(v) => control.setDemo(v)} />
|
<Toggle
|
||||||
|
label="Demo mode (synthetic feed)"
|
||||||
|
on={stats.demoMode}
|
||||||
|
onChange={(v) => control.setDemo(v)}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="rounded-xl border border-ink-600/50 bg-ink-700/30 px-3 py-2">
|
<div className="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">
|
||||||
@@ -89,7 +106,10 @@ 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 {pct(config.takerFees.bybit, 2)} · O {pct(config.takerFees.okx, 2)} · Bn {pct(config.takerFees.binance, 2)}
|
K {pct(config.takerFees.kraken, 2)} · By{" "}
|
||||||
|
{pct(config.takerFees.bybit, 2)} · O{" "}
|
||||||
|
{pct(config.takerFees.okx, 2)} · Bn{" "}
|
||||||
|
{pct(config.takerFees.binance, 2)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 flex justify-between">
|
<div className="mt-1 flex justify-between">
|
||||||
|
|||||||
@@ -50,7 +50,83 @@ export function PriceMatrix({ quotes }: Props): JSX.Element {
|
|||||||
) : undefined
|
) : undefined
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className="overflow-hidden rounded-xl border border-ink-600/50">
|
{/* Mobile: per-venue stacked cards (no horizontal overflow). */}
|
||||||
|
<div className="grid grid-cols-1 gap-3 sm:hidden">
|
||||||
|
{quotes.map((q) => {
|
||||||
|
const spread =
|
||||||
|
q.ask !== null && q.bid !== null ? q.ask - q.bid : null;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={q.exchange}
|
||||||
|
className="rounded-xl border border-ink-600/50 bg-ink-700/30 p-3"
|
||||||
|
>
|
||||||
|
<div className="mb-2 flex items-center gap-2 font-display text-sm">
|
||||||
|
<span
|
||||||
|
className={clsx("h-2 w-2 rounded-full", statusDot(q.status))}
|
||||||
|
/>
|
||||||
|
{LABELS[q.exchange]}
|
||||||
|
</div>
|
||||||
|
<dl className="grid grid-cols-2 gap-x-4 gap-y-1.5 font-mono text-sm tabular-nums">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<dt className="text-xs uppercase tracking-wider text-slate-500">
|
||||||
|
Bid
|
||||||
|
</dt>
|
||||||
|
<dd
|
||||||
|
className={clsx(
|
||||||
|
q.bid !== null && q.bid === maxBid
|
||||||
|
? "font-semibold text-profit"
|
||||||
|
: "text-slate-300",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{q.bid !== null ? usd(q.bid) : "—"}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<dt className="text-xs uppercase tracking-wider text-slate-500">
|
||||||
|
Bid Qty
|
||||||
|
</dt>
|
||||||
|
<dd className="text-slate-500">
|
||||||
|
{q.bidQty?.toFixed(3) ?? "—"}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<dt className="text-xs uppercase tracking-wider text-slate-500">
|
||||||
|
Ask
|
||||||
|
</dt>
|
||||||
|
<dd
|
||||||
|
className={clsx(
|
||||||
|
q.ask !== null && q.ask === minAsk
|
||||||
|
? "font-semibold text-accent"
|
||||||
|
: "text-slate-300",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{q.ask !== null ? usd(q.ask) : "—"}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<dt className="text-xs uppercase tracking-wider text-slate-500">
|
||||||
|
Ask Qty
|
||||||
|
</dt>
|
||||||
|
<dd className="text-slate-500">
|
||||||
|
{q.askQty?.toFixed(3) ?? "—"}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<dt className="text-xs uppercase tracking-wider text-slate-500">
|
||||||
|
Spread
|
||||||
|
</dt>
|
||||||
|
<dd className="text-slate-400">
|
||||||
|
{spread !== null ? usd(spread) : "—"}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Desktop / tablet: full table from the sm breakpoint up. */}
|
||||||
|
<div className="hidden overflow-hidden rounded-xl border border-ink-600/50 sm:block">
|
||||||
<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">
|
||||||
@@ -64,34 +140,50 @@ 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 = q.ask !== null && q.bid !== null ? q.ask - q.bid : null;
|
const spread =
|
||||||
|
q.ask !== null && q.bid !== null ? q.ask - q.bid : null;
|
||||||
return (
|
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 className={clsx("h-2 w-2 rounded-full", statusDot(q.status))} />
|
<span
|
||||||
|
className={clsx(
|
||||||
|
"h-2 w-2 rounded-full",
|
||||||
|
statusDot(q.status),
|
||||||
|
)}
|
||||||
|
/>
|
||||||
{LABELS[q.exchange]}
|
{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 ? "font-semibold text-profit" : "text-slate-300",
|
q.bid !== null && q.bid === maxBid
|
||||||
|
? "font-semibold text-profit"
|
||||||
|
: "text-slate-300",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{q.bid !== null ? usd(q.bid) : "—"}
|
{q.bid !== null ? usd(q.bid) : "—"}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-2.5 text-right text-slate-500">{q.bidQty?.toFixed(3) ?? "—"}</td>
|
<td className="px-4 py-2.5 text-right text-slate-500">
|
||||||
|
{q.bidQty?.toFixed(3) ?? "—"}
|
||||||
|
</td>
|
||||||
<td
|
<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 ? "font-semibold text-accent" : "text-slate-300",
|
q.ask !== null && q.ask === minAsk
|
||||||
|
? "font-semibold text-accent"
|
||||||
|
: "text-slate-300",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{q.ask !== null ? usd(q.ask) : "—"}
|
{q.ask !== null ? usd(q.ask) : "—"}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-2.5 text-right text-slate-500">{q.askQty?.toFixed(3) ?? "—"}</td>
|
<td className="px-4 py-2.5 text-right text-slate-500">
|
||||||
<td className="px-4 py-2.5 text-right text-slate-400">{spread !== null ? usd(spread) : "—"}</td>
|
{q.askQty?.toFixed(3) ?? "—"}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2.5 text-right text-slate-400">
|
||||||
|
{spread !== null ? usd(spread) : "—"}
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -6,10 +6,20 @@ interface Props {
|
|||||||
connected: boolean;
|
connected: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function Stat({ label, value, tone }: { label: string; value: string; tone?: "profit" | "loss" | "warn" }): JSX.Element {
|
function Stat({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
tone,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
tone?: "profit" | "loss" | "warn";
|
||||||
|
}): JSX.Element {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span className="text-[10px] uppercase tracking-widest text-slate-500">{label}</span>
|
<span className="text-[10px] uppercase tracking-widest text-slate-500">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
<span
|
<span
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"font-mono text-lg font-semibold tabular-nums",
|
"font-mono text-lg font-semibold tabular-nums",
|
||||||
@@ -25,14 +35,23 @@ function Stat({ label, value, tone }: { label: string; value: string; tone?: "pr
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function circuitTone(c: EngineStats["circuit"]): { label: string; cls: string } {
|
function circuitTone(c: EngineStats["circuit"]): {
|
||||||
|
label: string;
|
||||||
|
cls: string;
|
||||||
|
} {
|
||||||
switch (c) {
|
switch (c) {
|
||||||
case "running":
|
case "running":
|
||||||
return { label: "RUNNING", cls: "bg-profit/15 text-profit border-profit/40" };
|
return {
|
||||||
|
label: "RUNNING",
|
||||||
|
cls: "bg-profit/15 text-profit border-profit/40",
|
||||||
|
};
|
||||||
case "paused":
|
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 { label: "BREAKER TRIPPED", cls: "bg-loss/15 text-loss border-loss/40" };
|
return {
|
||||||
|
label: "BREAKER TRIPPED",
|
||||||
|
cls: "bg-loss/15 text-loss border-loss/40",
|
||||||
|
};
|
||||||
default: {
|
default: {
|
||||||
const _exhaustive: never = c;
|
const _exhaustive: never = c;
|
||||||
return { label: _exhaustive, cls: "" };
|
return { label: _exhaustive, cls: "" };
|
||||||
@@ -45,29 +64,45 @@ 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-8 gap-y-4 rounded-2xl border border-ink-600/60 bg-ink-800/60 px-5 py-4 backdrop-blur-sm">
|
<div className="flex flex-wrap items-center gap-x-6 gap-y-4 rounded-2xl border border-ink-600/60 bg-ink-800/60 px-4 py-4 backdrop-blur-sm sm:gap-x-8 sm:px-5">
|
||||||
<Stat label="Realized P&L" value={`$${usd(stats.realizedPnl)}`} tone={pnlTone} />
|
<Stat
|
||||||
|
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="ml-auto flex items-center gap-3">
|
<div className="flex w-full flex-wrap items-center gap-2 sm:ml-auto sm:w-auto sm: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 className={clsx("rounded-full border px-3 py-1 text-xs font-semibold tracking-wider", circuit.cls)}>
|
<span
|
||||||
|
className={clsx(
|
||||||
|
"rounded-full border px-3 py-1 text-xs font-semibold tracking-wider",
|
||||||
|
circuit.cls,
|
||||||
|
)}
|
||||||
|
>
|
||||||
{circuit.label}
|
{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 ? "border-profit/40 bg-profit/10 text-profit" : "border-loss/40 bg-loss/10 text-loss",
|
connected
|
||||||
|
? "border-profit/40 bg-profit/10 text-profit"
|
||||||
|
: "border-loss/40 bg-loss/10 text-loss",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span className={clsx("h-2 w-2 rounded-full", connected ? "bg-profit" : "bg-loss")} />
|
<span
|
||||||
|
className={clsx(
|
||||||
|
"h-2 w-2 rounded-full",
|
||||||
|
connected ? "bg-profit" : "bg-loss",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
{connected ? "LIVE" : "OFFLINE"}
|
{connected ? "LIVE" : "OFFLINE"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user