Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 95a3cf3271 |
@@ -22,18 +22,3 @@ DEMO_MODE=false
|
|||||||
|
|
||||||
# Feed recorder (NDJSON to data/)
|
# Feed recorder (NDJSON to data/)
|
||||||
RECORD_FEED=false
|
RECORD_FEED=false
|
||||||
|
|
||||||
# API docs — Scalar at GET /api-docs (OpenAPI generated from Zod; no env vars required)
|
|
||||||
|
|
||||||
# Observability — https://sentry.io (project: arbpulse)
|
|
||||||
SENTRY_DSN=
|
|
||||||
# Tracing/spans gate — default off. Error monitoring is always on when SENTRY_DSN
|
|
||||||
# is set; spans are only created/exported when SENTRY_TRACING is enabled. Kept off
|
|
||||||
# by default so 24/7 operation stays within Sentry's free-tier span quota.
|
|
||||||
SENTRY_TRACING=false
|
|
||||||
# Structured logs (pino); correlation IDs on REST, SSE, and WS hot path
|
|
||||||
LOG_LEVEL=info
|
|
||||||
|
|
||||||
# Upstash Redis — rate limit on /api/* and short-TTL snapshot cache for GET /api/state
|
|
||||||
UPSTASH_REDIS_REST_URL="https://natural-baboon-144595.upstash.io"
|
|
||||||
UPSTASH_REDIS_REST_TOKEN="gQAAAAAAAjTTAAIgcDE0MDk4MGZlOWMwN2M0NTdiOWQ5MWYxMjVhOWMyNWVlZQ"
|
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ jobs:
|
|||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v7
|
||||||
with:
|
with:
|
||||||
node-version: "20"
|
node-version: "20"
|
||||||
cache: npm
|
cache: npm
|
||||||
|
|||||||
@@ -1,30 +0,0 @@
|
|||||||
name: CodeQL Security Scan
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [main, dev]
|
|
||||||
pull_request:
|
|
||||||
branches: [main, dev]
|
|
||||||
schedule:
|
|
||||||
- cron: "0 8 * * 1"
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
analyze:
|
|
||||||
name: Analyze
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
actions: read
|
|
||||||
contents: read
|
|
||||||
security-events: write
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- uses: github/codeql-action/init@v3
|
|
||||||
with:
|
|
||||||
languages: javascript-typescript
|
|
||||||
queries: security-extended
|
|
||||||
|
|
||||||
- uses: github/codeql-action/autobuild@v3
|
|
||||||
|
|
||||||
- uses: github/codeql-action/analyze@v3
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
name: E2E Smoke
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [main, dev]
|
|
||||||
pull_request:
|
|
||||||
branches: [main, dev]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
smoke:
|
|
||||||
name: Playwright dashboard + SSE
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
env:
|
|
||||||
DEMO_MODE: "true"
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: "20"
|
|
||||||
cache: npm
|
|
||||||
cache-dependency-path: |
|
|
||||||
package-lock.json
|
|
||||||
web/package-lock.json
|
|
||||||
|
|
||||||
- run: npm ci
|
|
||||||
- run: npm --prefix web ci
|
|
||||||
- run: npm run build
|
|
||||||
|
|
||||||
- name: Start server
|
|
||||||
run: npm start &
|
|
||||||
|
|
||||||
- name: Wait for API
|
|
||||||
run: |
|
|
||||||
for i in $(seq 1 45); do
|
|
||||||
curl -sf http://localhost:8080/api/health > /dev/null && echo "Ready after ${i}s" && exit 0
|
|
||||||
sleep 1
|
|
||||||
done
|
|
||||||
echo "::error::Server did not become ready in 45s" && exit 1
|
|
||||||
|
|
||||||
- run: npx playwright install --with-deps chromium
|
|
||||||
|
|
||||||
- name: Run smoke tests
|
|
||||||
run: npm run test:e2e
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
# Production deploy: build the image in CI, publish to GHCR, then SSH into the
|
|
||||||
# Hetzner VPS to pull + restart. The VPS never builds — it only pulls the image
|
|
||||||
# that passed CI. Rollback = re-run this workflow from an older commit, or
|
|
||||||
# `docker compose pull` a previous sha-tag on the VPS.
|
|
||||||
#
|
|
||||||
# Secrets: VPS_SSH_KEY (dedicated deploy key), VPS_HOST, VPS_USER.
|
|
||||||
|
|
||||||
name: VPS Deploy
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [main]
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: vps-deploy
|
|
||||||
cancel-in-progress: false
|
|
||||||
|
|
||||||
env:
|
|
||||||
IMAGE: ghcr.io/${{ github.repository }}
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build-push:
|
|
||||||
name: Build & push to GHCR
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
packages: write
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- uses: docker/setup-buildx-action@v3
|
|
||||||
|
|
||||||
- uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
registry: ghcr.io
|
|
||||||
username: ${{ github.actor }}
|
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- uses: docker/metadata-action@v5
|
|
||||||
id: meta
|
|
||||||
with:
|
|
||||||
images: ${{ env.IMAGE }}
|
|
||||||
tags: |
|
|
||||||
type=raw,value=latest
|
|
||||||
type=sha,format=long
|
|
||||||
|
|
||||||
- uses: docker/build-push-action@v6
|
|
||||||
with:
|
|
||||||
context: .
|
|
||||||
push: true
|
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
|
||||||
cache-from: type=gha
|
|
||||||
cache-to: type=gha,mode=max
|
|
||||||
|
|
||||||
deploy:
|
|
||||||
name: Deploy to VPS
|
|
||||||
needs: build-push
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Install SSH key
|
|
||||||
uses: webfactory/ssh-agent@v0.9.1
|
|
||||||
with:
|
|
||||||
ssh-private-key: ${{ secrets.VPS_SSH_KEY }}
|
|
||||||
|
|
||||||
- name: Trust VPS host key
|
|
||||||
run: |
|
|
||||||
mkdir -p ~/.ssh
|
|
||||||
ssh-keyscan -H "${{ secrets.VPS_HOST }}" >> ~/.ssh/known_hosts
|
|
||||||
|
|
||||||
- name: Pull image and restart app
|
|
||||||
run: |
|
|
||||||
ssh "${{ secrets.VPS_USER }}@${{ secrets.VPS_HOST }}" bash -s <<'EOF'
|
|
||||||
set -euo pipefail
|
|
||||||
cd /root/projects/arbpulse
|
|
||||||
git fetch origin main
|
|
||||||
git checkout main
|
|
||||||
git reset --hard origin/main
|
|
||||||
cd deploy
|
|
||||||
docker compose pull app
|
|
||||||
docker compose up -d app
|
|
||||||
echo "waiting for health..."
|
|
||||||
for i in $(seq 1 12); do
|
|
||||||
if curl -fsS http://127.0.0.1:8080/api/health >/dev/null 2>&1; then
|
|
||||||
echo "app healthy"
|
|
||||||
docker image prune -f >/dev/null
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
sleep 5
|
|
||||||
done
|
|
||||||
echo "app did not become healthy in time" >&2
|
|
||||||
docker compose logs --tail 50 app >&2
|
|
||||||
exit 1
|
|
||||||
EOF
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
npx lint-staged
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
{
|
|
||||||
"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 | VPS Hetzner — Docker Compose + GHCR (CI/CD en `vps-deploy.yml`); Fly.io como alternativa manual |
|
| Deploy | [Fly.io](https://fly.io) — Docker multi-stage (`Dockerfile` + `fly.toml`) |
|
||||||
|
|
||||||
No se requieren API keys: los feeds de mercado son públicos.
|
No se requieren API keys: los feeds de mercado son públicos.
|
||||||
|
|
||||||
@@ -164,7 +164,6 @@ Consecuencia esperada en feed **real**: la mayoría de divergencias brutas salen
|
|||||||
### Robustez en el hot path
|
### Robustez en el hot path
|
||||||
|
|
||||||
- **One trade per tick** — si varios pares confirman en el mismo tick, solo se ejecuta el de mayor `netProfit` (desempate por `netProfitPct` y par lexicográfico).
|
- **One trade per tick** — si varios pares confirman en el mismo tick, solo se ejecuta el de mayor `netProfit` (desempate por `netProfitPct` y par lexicográfico).
|
||||||
- **Integridad del libro local** — los feeds delta con ventana top-N (Kraken v2) se truncan al depth suscrito tras cada update (Kraken no manda deletes para niveles expulsados de la ventana); un libro internamente cruzado (bid ≥ ask) nunca se emite al engine: se descarta, se loguea y se fuerza re-sync vía reconexión.
|
|
||||||
- **Staleness** — quotes más viejos que `STALE_MS` no disparan ejecución.
|
- **Staleness** — quotes más viejos que `STALE_MS` no disparan ejecución.
|
||||||
- **Anti-flicker** — la divergencia debe persistir `FLICKER_CONFIRM_MS` antes de actuar (filtra artefactos de latencia).
|
- **Anti-flicker** — la divergencia debe persistir `FLICKER_CONFIRM_MS` antes de actuar (filtra artefactos de latencia).
|
||||||
- **Partial fills** — volumen limitado por profundidad del libro e inventario de wallet.
|
- **Partial fills** — volumen limitado por profundidad del libro e inventario de wallet.
|
||||||
@@ -302,25 +301,27 @@ Respuestas REST siguen la forma `{ success, data?, error? }`.
|
|||||||
| `POST` | `/api/control/record` | `{ "enabled": boolean }` — grabación NDJSON en `data/` |
|
| `POST` | `/api/control/record` | `{ "enabled": boolean }` — grabación NDJSON en `data/` |
|
||||||
| `POST` | `/api/control/threshold` | `{ "pct": number }` (atajo de `PATCH /api/config`) |
|
| `POST` | `/api/control/threshold` | `{ "pct": number }` (atajo de `PATCH /api/config`) |
|
||||||
| `POST` | `/api/control/max-trade` | `{ "btc": number }` (atajo de `PATCH /api/config`) |
|
| `POST` | `/api/control/max-trade` | `{ "btc": number }` (atajo de `PATCH /api/config`) |
|
||||||
| `GET` | `/api-docs` | **Scalar** — documentación interactiva OpenAPI 3.0 |
|
| `GET` | `/api-docs` | **Swagger UI** — documentación interactiva OpenAPI 3.0 |
|
||||||
|
|
||||||
Contrato generado desde Zod (`src/interfaces/http/schemas/`) con `@asteasolutions/zod-to-openapi`; registro de rutas en `src/interfaces/http/openapi.ts`.
|
Contrato completo y schemas en `src/interfaces/http/openapi.ts`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Deploy (producción: VPS Hetzner + GHCR)
|
## Deploy en Fly.io
|
||||||
|
|
||||||
Producción corre en una **VPS de Hetzner** (`https://arbpulse.wayool.com`) con Docker Compose detrás de nginx. El deploy es **automático**:
|
El repo incluye `Dockerfile` (multi-stage: build Vite + runtime Node), `fly.toml` y el workflow `.github/workflows/fly-deploy.yml`.
|
||||||
|
|
||||||
1. Push/merge a `main` dispara `.github/workflows/vps-deploy.yml`.
|
1. Instala [flyctl](https://fly.io/docs/flyctl/install/) e inicia sesión: `fly auth login`.
|
||||||
2. GitHub Actions buildea la imagen (`Dockerfile` multi-stage) y la publica en **GHCR** (`ghcr.io/mauricioabh/arbpulse`, tags `latest` + `sha-<commit>`).
|
2. Desde la raíz del repo: `fly launch` (primera vez) o `fly deploy`.
|
||||||
3. El workflow entra por SSH a la VPS, hace `docker compose pull` + `up -d` y espera el health check (`GET /api/health`).
|
3. **Región primaria:** `sin` (Singapore) — menor latencia a matching engines de Binance, Bybit y OKX.
|
||||||
|
4. Fly expone el servicio en el puerto interno **8080** con healthcheck `GET /api/health`.
|
||||||
|
5. Variables de entorno: opcionales; `fly.toml` incluye defaults razonables (`STALE_MS`, inventario inicial, etc.).
|
||||||
|
6. **CI/CD:** cada push a `main` dispara deploy automático vía GitHub Actions (`fly-deploy.yml`; requiere secret `FLY_API_TOKEN`). Los PRs pasan `CI / quality` (typecheck, test, build) sin desplegar.
|
||||||
|
7. Abre la URL pública: badge **LIVE**, cuatro venues en la price matrix, y —en mercado real— sobre todo oportunidades rechazadas por fees (esperado).
|
||||||
|
|
||||||
Detalles, bootstrap manual y rollback: [`deploy/README.md`](deploy/README.md).
|
> Producción usa **Fly.io**. `railway.json` es un artefacto legacy del deploy anterior; no lo uses.
|
||||||
|
|
||||||
> Fly.io queda como opción alternativa manual (`fly-deploy.yml` vía workflow_dispatch, requiere `FLY_API_TOKEN`; región recomendada `sin`). `railway.json` es un artefacto legacy; no lo uses.
|
Para una demo con actividad visible en pocos segundos, define `DEMO_MODE=true` en Fly (`fly secrets set` / `[env]` en `fly.toml`) o actívalo desde Controls.
|
||||||
|
|
||||||
Para una demo con actividad visible en pocos segundos, define `DEMO_MODE=true` en `deploy/.env` de la VPS o actívalo desde Controls.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -342,17 +343,6 @@ Eso no indica que el bot “no funcione”: indica que el filtro económico es e
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Production practices
|
|
||||||
|
|
||||||
- **Pre-commit:** Husky runs lint-staged (`eslint --fix`, `prettier --write`) on staged `*.ts` / `*.tsx` in `src/` and `web/src/`.
|
|
||||||
- **API contracts:** Zod schemas per REST endpoint → OpenAPI via `@asteasolutions/zod-to-openapi` → Scalar UI at `/api-docs`. Request bodies validated with Zod in route handlers.
|
|
||||||
- **Observability:** `@sentry/node` captures REST, WebSocket feed, and SSE errors (always on when `SENTRY_DSN` is set); `pino` JSON logs with `correlationId` (from `x-request-id` or per book tick); OpenTelemetry spans (`ws.message` → `orderbook.process` → `arbitrage.evaluate` → `sse.broadcast`) export to Sentry via `@sentry/opentelemetry` **only when `SENTRY_TRACING` is enabled** (default off, so 24/7 operation stays within the free-tier span quota). With tracing off the tracer is a no-op — no spans are created or exported — but errors are still captured. Dev probe: `GET /api/debug/sentry`; verify with `npm run test:observability`.
|
|
||||||
- **Rate limiting & cache:** Upstash sliding-window limits per IP on read/write/SSE routes (`429` + `Retry-After`). Latest `StateSnapshot` cached in Redis (~1s TTL) for `GET /api/state` and refreshed on SSE broadcast. Set `UPSTASH_REDIS_REST_URL` and `UPSTASH_REDIS_REST_TOKEN`. Verify with `npm run test:rate-limit`.
|
|
||||||
- **CI:** GitHub Actions quality pipeline (typecheck, unit tests, build); Playwright smoke on PRs — dashboard loads and SSE connects (`npm run test:e2e`, workflow `e2e.yml`, `DEMO_MODE=true` in CI).
|
|
||||||
- **Security scanning:** CodeQL (`.github/workflows/codeql.yml`); Dependabot for npm (root + `web/`) and GitHub Actions.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Licencia
|
## Licencia
|
||||||
|
|
||||||
MIT — ver archivo `LICENSE` cuando se añada al repositorio.
|
MIT — ver archivo `LICENSE` cuando se añada al repositorio.
|
||||||
|
|||||||
@@ -1,34 +0,0 @@
|
|||||||
# Copy to ./.env on the VPS and fill in. Never commit the real .env.
|
|
||||||
|
|
||||||
# Domain for Caddy automatic HTTPS. Its Cloudflare DNS A record must point to this
|
|
||||||
# VPS IP as "DNS only" (grey cloud) so Caddy can issue the Let's Encrypt cert and
|
|
||||||
# SSE (/api/stream) is not buffered by Cloudflare's proxy.
|
|
||||||
DOMAIN=arbpulse.wayool.com
|
|
||||||
|
|
||||||
NODE_ENV=production
|
|
||||||
PORT=8080
|
|
||||||
|
|
||||||
# Engine tuning (mirrors render.yaml production defaults)
|
|
||||||
STALE_MS=3000
|
|
||||||
FLICKER_CONFIRM_MS=150
|
|
||||||
MIN_NET_PROFIT_PCT=0.0005
|
|
||||||
MAX_TRADE_BTC=0.25
|
|
||||||
LATENCY_MS=120
|
|
||||||
LATENCY_SLIPPAGE_BPS=2
|
|
||||||
CIRCUIT_BREAKER_LOSSES=5
|
|
||||||
CIRCUIT_BREAKER_COOLDOWN_MS=15000
|
|
||||||
INITIAL_USDT=50000
|
|
||||||
INITIAL_BTC=0.5
|
|
||||||
DEMO_MODE=false
|
|
||||||
RECORD_FEED=false
|
|
||||||
|
|
||||||
# Observability. Error monitoring is on when SENTRY_DSN is set.
|
|
||||||
# Leave SENTRY_TRACING=false for 24/7 (keeps spans off the free-tier quota).
|
|
||||||
SENTRY_DSN=
|
|
||||||
SENTRY_TRACING=false
|
|
||||||
LOG_LEVEL=info
|
|
||||||
|
|
||||||
# Upstash (optional): rate limiting on /api/* + short-TTL snapshot cache.
|
|
||||||
# Leave empty to disable both features.
|
|
||||||
UPSTASH_REDIS_REST_URL=
|
|
||||||
UPSTASH_REDIS_REST_TOKEN=
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
# Automatic HTTPS via Let's Encrypt. DOMAIN comes from ./.env (docker compose
|
|
||||||
# substitutes ${DOMAIN}). Point the domain's DNS A record to this VPS first.
|
|
||||||
{$DOMAIN} {
|
|
||||||
# flush_interval -1 disables response buffering so SSE (/api/stream) streams
|
|
||||||
# in real time. Do NOT gzip event-stream responses (would buffer them).
|
|
||||||
reverse_proxy app:8080 {
|
|
||||||
flush_interval -1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,130 +0,0 @@
|
|||||||
# Deploy en VPS (Hetzner) — Arb Pulse
|
|
||||||
|
|
||||||
Despliegue con Docker Compose para correr 24/7 desde la rama `main`. El contenedor
|
|
||||||
`app` (Node + tsx) queda en `127.0.0.1:8080` y un **reverse proxy del host** expone
|
|
||||||
HTTPS.
|
|
||||||
|
|
||||||
## Deploy automático (CI/CD con GHCR) — flujo normal
|
|
||||||
|
|
||||||
Cada push/merge a `main` dispara `.github/workflows/vps-deploy.yml`:
|
|
||||||
|
|
||||||
1. **Build en GitHub Actions** → imagen publicada en `ghcr.io/mauricioabh/arbpulse`
|
|
||||||
con tags `latest` + `sha-<commit>` (la VPS nunca buildea).
|
|
||||||
2. **Deploy por SSH** → en la VPS: `git reset --hard origin/main` (en
|
|
||||||
`/root/projects/arbpulse`), `docker compose pull app`, `docker compose up -d app`
|
|
||||||
y espera del health check.
|
|
||||||
|
|
||||||
Secrets del repo: `VPS_SSH_KEY` (deploy key dedicada, solo para esto),
|
|
||||||
`VPS_HOST`, `VPS_USER`. Rollback: re-ejecutar el workflow desde un commit
|
|
||||||
anterior (`workflow_dispatch`) o en la VPS hacer `docker compose pull` de un
|
|
||||||
tag `sha-<commit>` previo.
|
|
||||||
|
|
||||||
> No editar archivos del repo directamente en la VPS: el deploy hace
|
|
||||||
> `git reset --hard` y los pisará. La config local vive solo en `deploy/.env`
|
|
||||||
> (no trackeado).
|
|
||||||
|
|
||||||
Lo que sigue abajo es el **camino manual/bootstrap** (primera instalación o
|
|
||||||
fallback). Dos modos:
|
|
||||||
|
|
||||||
- **Opción A — detrás de nginx existente (recomendado en este VPS).** El servidor ya
|
|
||||||
corre nginx en 80/443 con certbot para otras apps (p. ej. `consumet.wayool.com`,
|
|
||||||
`openclaw.wayool.com`). Se agrega un vhost para `arbpulse.wayool.com` y punto.
|
|
||||||
- **Opción B — Caddy bundleado.** Solo para un server **fresco** sin nada en 80/443.
|
|
||||||
|
|
||||||
## Prerrequisitos
|
|
||||||
|
|
||||||
1. VPS Ubuntu/Debian (recomendado 2 vCPU / 2 GB RAM — p. ej. Hetzner CX22).
|
|
||||||
2. Subdominio `arbpulse.wayool.com` con registro **A → IP del VPS** (ya creado en
|
|
||||||
Cloudflare, ver abajo).
|
|
||||||
3. Docker + plugin compose (el `deploy.sh` los instala si faltan).
|
|
||||||
|
|
||||||
## DNS en Cloudflare (zona wayool.com)
|
|
||||||
|
|
||||||
- Registro **A**, nombre `arbpulse`, contenido = IP del VPS, **"DNS only" (nube gris)**.
|
|
||||||
- Motivo: certbot/Caddy emiten el cert vía HTTP-01 (reto directo al origen) y el
|
|
||||||
SSE de `/api/stream` fluye sin buffering del proxy de Cloudflare.
|
|
||||||
- Verificar: `dig +short arbpulse.wayool.com` debe devolver la IP del VPS.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Opción A — detrás del nginx existente (recomendado)
|
|
||||||
|
|
||||||
No toca tus otras apps ni Caddy. Solo levanta el contenedor `app` en loopback y le
|
|
||||||
pone un vhost de nginx con certbot (mismo patrón que consumet/openclaw).
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1) Traer y correr el script (1ra vez: crea .env y se detiene). App-only por default.
|
|
||||||
curl -fsSL https://raw.githubusercontent.com/mauricioabh/arbpulse/main/deploy/deploy.sh -o /tmp/arbpulse-deploy.sh
|
|
||||||
bash /tmp/arbpulse-deploy.sh
|
|
||||||
|
|
||||||
# 2) Editar el .env (DOMAIN + secretos opcionales)
|
|
||||||
nano /root/projects/arbpulse/deploy/.env # DOMAIN=arbpulse.wayool.com ; SENTRY_TRACING=false ; ...
|
|
||||||
|
|
||||||
# 3) Volver a correr: build + up del contenedor app (127.0.0.1:8080)
|
|
||||||
bash /tmp/arbpulse-deploy.sh
|
|
||||||
|
|
||||||
# 4) Instalar el vhost de nginx y emitir el cert
|
|
||||||
sudo cp /root/projects/arbpulse/deploy/nginx/arbpulse.wayool.com.conf /etc/nginx/sites-available/arbpulse.wayool.com
|
|
||||||
sudo ln -s /etc/nginx/sites-available/arbpulse.wayool.com /etc/nginx/sites-enabled/
|
|
||||||
sudo nginx -t && sudo systemctl reload nginx
|
|
||||||
sudo certbot --nginx -d arbpulse.wayool.com
|
|
||||||
```
|
|
||||||
|
|
||||||
El vhost (`deploy/nginx/arbpulse.wayool.com.conf`) trae `proxy_buffering off` y
|
|
||||||
timeouts largos para que el SSE funcione. certbot copia el bloque `location` al
|
|
||||||
server TLS que genera, así que los ajustes SSE se mantienen en HTTPS.
|
|
||||||
|
|
||||||
## Opción B — Caddy bundleado (server fresco, sin nginx)
|
|
||||||
|
|
||||||
Solo si nada más usa 80/443:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /root/projects/arbpulse/deploy
|
|
||||||
cp .env.vps.example .env && nano .env # DOMAIN=arbpulse.wayool.com ...
|
|
||||||
WITH_CADDY=1 bash /tmp/arbpulse-deploy.sh # o: docker compose --profile caddy up -d --build
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Verificar
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose -f /root/projects/arbpulse/deploy/docker-compose.yml ps # app healthy/running
|
|
||||||
curl -s http://127.0.0.1:8080/api/health # local
|
|
||||||
curl -s https://arbpulse.wayool.com/api/health # público (HTTPS)
|
|
||||||
```
|
|
||||||
|
|
||||||
Dashboard: `https://arbpulse.wayool.com` → badge **LIVE** y los 4 exchanges.
|
|
||||||
|
|
||||||
## Variables de entorno
|
|
||||||
|
|
||||||
Ver `.env.vps.example`. Claves:
|
|
||||||
|
|
||||||
- `DOMAIN` — `arbpulse.wayool.com` (usado por Caddy en Opción B; informativo en A).
|
|
||||||
- `SENTRY_DSN` — opcional; activa error monitoring.
|
|
||||||
- `SENTRY_TRACING` — dejar en `false` (default) para no consumir la cuota free-tier
|
|
||||||
de spans en operación 24/7.
|
|
||||||
- `UPSTASH_REDIS_REST_URL` / `UPSTASH_REDIS_REST_TOKEN` — opcionales (rate limit +
|
|
||||||
cache). Vacío = desactivado.
|
|
||||||
|
|
||||||
> Nunca comitees el `.env` real. Solo `.env.vps.example` vive en el repo.
|
|
||||||
|
|
||||||
## Operación
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /root/projects/arbpulse/deploy
|
|
||||||
docker compose logs -f app # logs de la app
|
|
||||||
docker compose restart app # reiniciar
|
|
||||||
docker compose down # detener (app; agrega --profile caddy si aplica)
|
|
||||||
bash /tmp/arbpulse-deploy.sh # actualizar manualmente (git pull main + pull GHCR)
|
|
||||||
BUILD=1 bash /tmp/arbpulse-deploy.sh # fallback: build local en la VPS
|
|
||||||
```
|
|
||||||
|
|
||||||
## Notas
|
|
||||||
|
|
||||||
- El puerto 8080 se publica solo en `127.0.0.1` (no expuesto a Internet); el tráfico
|
|
||||||
público entra por el reverse proxy del host (nginx o Caddy).
|
|
||||||
- La app ya envía `X-Accel-Buffering: no` en el SSE, que nginx respeta para no
|
|
||||||
bufferear ese response.
|
|
||||||
- Región: Hetzner no tiene Asia; para menor latencia a Binance/Bybit/OKX evalúa un
|
|
||||||
VPS en Singapur. Para Kraken (EU) Falkenstein/Helsinki va bien.
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Idempotent VPS deploy for Arb Pulse (Ubuntu/Debian).
|
|
||||||
# Safe to re-run: installs Docker if missing, clones or fast-forwards the repo,
|
|
||||||
# pulls the CI-built image from GHCR (or builds locally with BUILD=1) and
|
|
||||||
# restarts the containers. On first run it creates .env from the example and
|
|
||||||
# stops so you can fill in DOMAIN + secrets.
|
|
||||||
#
|
|
||||||
# NOTE: normal production deploys are automatic — push/merge to `main` triggers
|
|
||||||
# .github/workflows/vps-deploy.yml (build in CI -> GHCR -> SSH pull + restart).
|
|
||||||
# This script is the manual/bootstrap path.
|
|
||||||
#
|
|
||||||
# Default: runs ONLY the app on 127.0.0.1:8080 (put it behind your host reverse
|
|
||||||
# proxy — see deploy/nginx/). Set WITH_CADDY=1 to also start the bundled Caddy on
|
|
||||||
# 80/443 (only for a fresh server with nothing else on those ports).
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
REPO_URL="${REPO_URL:-https://github.com/mauricioabh/arbpulse.git}"
|
|
||||||
APP_DIR="${APP_DIR:-/root/projects/arbpulse}"
|
|
||||||
BRANCH="${BRANCH:-main}"
|
|
||||||
WITH_CADDY="${WITH_CADDY:-0}"
|
|
||||||
# BUILD=1 builds the image locally instead of pulling from GHCR (fallback).
|
|
||||||
BUILD="${BUILD:-0}"
|
|
||||||
|
|
||||||
echo "==> Arb Pulse VPS deploy (branch: $BRANCH, dir: $APP_DIR)"
|
|
||||||
|
|
||||||
# 1) Docker + compose plugin
|
|
||||||
if ! command -v docker >/dev/null 2>&1; then
|
|
||||||
echo "==> Installing Docker Engine..."
|
|
||||||
curl -fsSL https://get.docker.com | sh
|
|
||||||
fi
|
|
||||||
docker --version
|
|
||||||
if ! docker compose version >/dev/null 2>&1; then
|
|
||||||
echo "ERROR: 'docker compose' plugin not available. Install docker-compose-plugin." >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 2) Repo (clone or fast-forward)
|
|
||||||
if [ ! -d "$APP_DIR/.git" ]; then
|
|
||||||
echo "==> Cloning $REPO_URL into $APP_DIR"
|
|
||||||
sudo mkdir -p "$APP_DIR"
|
|
||||||
sudo chown "$(id -un)":"$(id -gn)" "$APP_DIR"
|
|
||||||
git clone --branch "$BRANCH" "$REPO_URL" "$APP_DIR"
|
|
||||||
else
|
|
||||||
echo "==> Updating existing repo in $APP_DIR"
|
|
||||||
git -C "$APP_DIR" fetch origin "$BRANCH"
|
|
||||||
git -C "$APP_DIR" checkout "$BRANCH"
|
|
||||||
git -C "$APP_DIR" pull --ff-only origin "$BRANCH"
|
|
||||||
fi
|
|
||||||
|
|
||||||
cd "$APP_DIR/deploy"
|
|
||||||
|
|
||||||
# 3) Environment file
|
|
||||||
if [ ! -f .env ]; then
|
|
||||||
cp .env.vps.example .env
|
|
||||||
echo
|
|
||||||
echo "==> Created $APP_DIR/deploy/.env from the example."
|
|
||||||
echo " Edit it now (set DOMAIN, and optionally SENTRY_DSN / UPSTASH_*),"
|
|
||||||
echo " then run this script again to build and start."
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 4) Image: pull from GHCR (default) or build locally (BUILD=1)
|
|
||||||
if [ "$BUILD" = "1" ]; then
|
|
||||||
echo "==> Building image locally..."
|
|
||||||
docker compose build app
|
|
||||||
else
|
|
||||||
echo "==> Pulling image from GHCR..."
|
|
||||||
docker compose pull app
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 5) Run
|
|
||||||
if [ "$WITH_CADDY" = "1" ]; then
|
|
||||||
echo "==> Starting app + bundled Caddy (ports 80/443)..."
|
|
||||||
docker compose --profile caddy up -d
|
|
||||||
else
|
|
||||||
echo "==> Starting app only (127.0.0.1:8080)..."
|
|
||||||
docker compose up -d app
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "==> Waiting for health..."
|
|
||||||
sleep 8
|
|
||||||
docker compose ps
|
|
||||||
echo
|
|
||||||
if curl -fsS http://127.0.0.1:8080/api/health >/dev/null 2>&1; then
|
|
||||||
echo "==> App healthy on 127.0.0.1:8080."
|
|
||||||
else
|
|
||||||
echo "!! Health check not passing yet. Inspect: docker compose logs -f app"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ "$WITH_CADDY" = "1" ]; then
|
|
||||||
echo "==> Bundled Caddy will issue HTTPS once DNS for \$DOMAIN points here."
|
|
||||||
echo "==> Verify: curl -s https://\$DOMAIN/api/health"
|
|
||||||
else
|
|
||||||
echo "==> App is bound to 127.0.0.1:8080. Put it behind your host reverse proxy:"
|
|
||||||
echo " see $APP_DIR/deploy/nginx/arbpulse.wayool.com.conf (nginx + certbot)."
|
|
||||||
fi
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
# Arb Pulse — VPS deployment.
|
|
||||||
#
|
|
||||||
# Default (`docker compose up -d --build`): runs ONLY the app container, bound to
|
|
||||||
# 127.0.0.1:8080. Put it behind your existing reverse proxy (nginx/Caddy/Traefik)
|
|
||||||
# on the host — see deploy/nginx/arbpulse.wayool.com.conf. This is the right mode
|
|
||||||
# when the host already owns ports 80/443 for other apps.
|
|
||||||
#
|
|
||||||
# Optional bundled Caddy (`docker compose --profile caddy up -d --build`): only for
|
|
||||||
# a FRESH server with nothing else on 80/443; Caddy then handles automatic HTTPS.
|
|
||||||
#
|
|
||||||
# Requires a ./.env file (copy from .env.vps.example and fill DOMAIN + secrets).
|
|
||||||
|
|
||||||
services:
|
|
||||||
app:
|
|
||||||
# Normal flow: the image is built by CI (.github/workflows/vps-deploy.yml)
|
|
||||||
# and pulled from GHCR. `build:` remains for local/manual builds only
|
|
||||||
# (docker compose build app).
|
|
||||||
build:
|
|
||||||
context: ..
|
|
||||||
dockerfile: Dockerfile
|
|
||||||
image: ghcr.io/mauricioabh/arbpulse:latest
|
|
||||||
container_name: arbpulse
|
|
||||||
restart: unless-stopped
|
|
||||||
env_file:
|
|
||||||
- ./.env
|
|
||||||
# Bind to loopback only; the host reverse proxy forwards public traffic here.
|
|
||||||
ports:
|
|
||||||
- "127.0.0.1:8080:8080"
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:8080/api/health"]
|
|
||||||
interval: 30s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 3
|
|
||||||
start_period: 15s
|
|
||||||
|
|
||||||
# Opt-in only via `--profile caddy`. Do NOT enable if the host already runs
|
|
||||||
# nginx/another proxy on 80/443 — the ports would collide.
|
|
||||||
caddy:
|
|
||||||
image: caddy:2-alpine
|
|
||||||
container_name: arbpulse-caddy
|
|
||||||
restart: unless-stopped
|
|
||||||
profiles: ["caddy"]
|
|
||||||
depends_on:
|
|
||||||
- app
|
|
||||||
ports:
|
|
||||||
- "80:80"
|
|
||||||
- "443:443"
|
|
||||||
environment:
|
|
||||||
- DOMAIN=${DOMAIN}
|
|
||||||
volumes:
|
|
||||||
- ./Caddyfile:/etc/caddy/Caddyfile:ro
|
|
||||||
- caddy_data:/data
|
|
||||||
- caddy_config:/config
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
caddy_data:
|
|
||||||
caddy_config:
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
# Arb Pulse — nginx vhost (host-level), to sit alongside consumet/openclaw.
|
|
||||||
#
|
|
||||||
# Install:
|
|
||||||
# sudo cp deploy/nginx/arbpulse.wayool.com.conf /etc/nginx/sites-available/arbpulse.wayool.com
|
|
||||||
# sudo ln -s /etc/nginx/sites-available/arbpulse.wayool.com /etc/nginx/sites-enabled/
|
|
||||||
# sudo nginx -t && sudo systemctl reload nginx
|
|
||||||
# sudo certbot --nginx -d arbpulse.wayool.com # issues cert + adds the 443 block + HTTP->HTTPS redirect
|
|
||||||
#
|
|
||||||
# certbot copies the location block into the TLS server it generates, so the
|
|
||||||
# SSE-friendly proxy settings below carry over to HTTPS.
|
|
||||||
|
|
||||||
server {
|
|
||||||
listen 80;
|
|
||||||
listen [::]:80;
|
|
||||||
server_name arbpulse.wayool.com;
|
|
||||||
|
|
||||||
# Proxy everything to the app container (loopback-published by docker compose).
|
|
||||||
location / {
|
|
||||||
proxy_pass http://127.0.0.1:8080;
|
|
||||||
proxy_http_version 1.1;
|
|
||||||
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
|
|
||||||
# SSE (/api/stream): stream in real time, never buffer, keep long-lived.
|
|
||||||
proxy_buffering off;
|
|
||||||
proxy_cache off;
|
|
||||||
proxy_set_header Connection "";
|
|
||||||
proxy_read_timeout 3600s;
|
|
||||||
proxy_send_timeout 3600s;
|
|
||||||
chunked_transfer_encoding off;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
import { test, expect } from "@playwright/test";
|
|
||||||
|
|
||||||
/** Asserts the dev Sentry probe responds; does not verify ingest in sentry.io (see README). */
|
|
||||||
test("sentry dev probe returns ok", async ({ request }) => {
|
|
||||||
const res = await request.get("/api/debug/sentry");
|
|
||||||
expect(res.ok()).toBeTruthy();
|
|
||||||
await expect(res.json()).resolves.toMatchObject({ ok: true });
|
|
||||||
});
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
import { test, expect } from "@playwright/test";
|
|
||||||
|
|
||||||
test("dashboard loads", async ({ page }) => {
|
|
||||||
await page.goto("/");
|
|
||||||
await expect(page.getByRole("heading", { name: /ArbPulse/i })).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("SSE stream connects and delivers engine state", async ({ page, request }) => {
|
|
||||||
const health = await request.get("/api/health");
|
|
||||||
expect(health.ok()).toBeTruthy();
|
|
||||||
|
|
||||||
await page.goto("/");
|
|
||||||
await expect(page.getByText("Connecting to engine…")).toBeHidden({
|
|
||||||
timeout: 45_000,
|
|
||||||
});
|
|
||||||
await expect(page.getByText("LIVE", { exact: true })).toBeVisible();
|
|
||||||
await expect(page.getByText("RUNNING")).toBeVisible();
|
|
||||||
await expect(page.getByText("Realized P&L")).toBeVisible();
|
|
||||||
});
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
import eslint from "@eslint/js";
|
|
||||||
import reactHooks from "eslint-plugin-react-hooks";
|
|
||||||
import globals from "globals";
|
|
||||||
import tseslint from "typescript-eslint";
|
|
||||||
|
|
||||||
export default tseslint.config(
|
|
||||||
{ ignores: ["web/dist/**", "data/**", "**/node_modules/**"] },
|
|
||||||
{
|
|
||||||
files: ["src/**/*.ts"],
|
|
||||||
extends: [eslint.configs.recommended, ...tseslint.configs.recommended],
|
|
||||||
languageOptions: {
|
|
||||||
ecmaVersion: 2022,
|
|
||||||
sourceType: "module",
|
|
||||||
globals: globals.node,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
files: ["web/src/**/*.{ts,tsx}"],
|
|
||||||
extends: [eslint.configs.recommended, ...tseslint.configs.recommended],
|
|
||||||
plugins: { "react-hooks": reactHooks },
|
|
||||||
languageOptions: {
|
|
||||||
ecmaVersion: 2022,
|
|
||||||
sourceType: "module",
|
|
||||||
globals: globals.browser,
|
|
||||||
parserOptions: { ecmaFeatures: { jsx: true } },
|
|
||||||
},
|
|
||||||
rules: {
|
|
||||||
...reactHooks.configs.recommended.rules,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
schema: spec-driven
|
|
||||||
created: 2026-07-16
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
## Context
|
|
||||||
|
|
||||||
The dashboard (`web/`, React 18 + Vite 6 + Tailwind v3) is served by Express as
|
|
||||||
static files from `web/dist` (same origin as `/api` + SSE). `App.tsx` already uses a
|
|
||||||
`grid-cols-1 lg:grid-cols-3` layout, so it collapses to a single column on mobile,
|
|
||||||
but two things are missing for a good phone experience: (1) the `PriceMatrix` order
|
|
||||||
book is a 6-column `<table>` that overflows horizontally on ~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.
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
## Why
|
|
||||||
|
|
||||||
The Arb Pulse dashboard is desktop-first: the 3-column grid collapses acceptably on
|
|
||||||
narrow screens, but the order-book table overflows horizontally and there is no
|
|
||||||
mobile/PWA metadata. We want to demo and monitor the engine from a phone —
|
|
||||||
installable to the home screen, full-window, and touch-friendly — while the app
|
|
||||||
runs 24/7 on the VPS behind HTTPS (`arbpulse.wayool.com`).
|
|
||||||
|
|
||||||
## What Changes
|
|
||||||
|
|
||||||
- Make the dashboard fully responsive on phones (~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.
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
## ADDED Requirements
|
|
||||||
|
|
||||||
### Requirement: Installable to the home screen
|
|
||||||
The dashboard SHALL be installable to a device home screen via a Web App Manifest
|
|
||||||
served over HTTPS, without requiring a service worker.
|
|
||||||
|
|
||||||
#### Scenario: Manifest is present and valid
|
|
||||||
- **WHEN** the dashboard is loaded over HTTPS
|
|
||||||
- **THEN** a linked `manifest.webmanifest` is served that includes `name`,
|
|
||||||
`short_name`, `start_url` in scope, `display: standalone`, `theme_color`,
|
|
||||||
`background_color`, and at least 192px and 512px PNG icons
|
|
||||||
|
|
||||||
#### Scenario: User installs from the browser
|
|
||||||
- **WHEN** a user chooses "Install app" / "Add to Home Screen" from the browser menu
|
|
||||||
- **THEN** the app is added with the ArbPulse icon and launches in a standalone,
|
|
||||||
full-window mode at `start_url`
|
|
||||||
|
|
||||||
### Requirement: No offline mode and no stale data
|
|
||||||
The app SHALL NOT register a service worker and SHALL NOT cache market data, so it
|
|
||||||
never presents stale or fake data (real-time honesty rule).
|
|
||||||
|
|
||||||
#### Scenario: No service worker is registered
|
|
||||||
- **WHEN** the dashboard runs in any environment
|
|
||||||
- **THEN** no service worker is registered and no application cache stores `/api`
|
|
||||||
responses or the SSE stream
|
|
||||||
|
|
||||||
#### Scenario: Disconnected device shows a clear non-data state
|
|
||||||
- **WHEN** the device has no working connection to the engine
|
|
||||||
- **THEN** the UI shows its reconnecting/offline indicator and does not display
|
|
||||||
cached market figures as if they were live
|
|
||||||
|
|
||||||
### Requirement: Responsive mobile layout
|
|
||||||
The dashboard SHALL be usable on phone-sized viewports (~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
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
## 1. Icons
|
|
||||||
|
|
||||||
- [x] 1.1 Finalize the ArbPulse master icon and derive `icon-192.png`,
|
|
||||||
`icon-512.png`, `icon-maskable-512.png` (glyph within ~80% safe zone), and
|
|
||||||
`apple-touch-icon.png` (180px)
|
|
||||||
- [x] 1.2 Place all icons under `web/public/`
|
|
||||||
|
|
||||||
## 2. Manifest
|
|
||||||
|
|
||||||
- [x] 2.1 Create `web/public/manifest.webmanifest` with `name`, `short_name`,
|
|
||||||
`start_url: "/"`, `scope: "/"`, `display: "standalone"`,
|
|
||||||
`theme_color`/`background_color` `#0a0e14`, and icon entries (192, 512, and
|
|
||||||
512 maskable with `"purpose": "maskable"`)
|
|
||||||
|
|
||||||
## 3. HTML head metadata
|
|
||||||
|
|
||||||
- [x] 3.1 In `web/index.html`, add `viewport-fit=cover` to the viewport meta and a
|
|
||||||
`theme-color` meta (`#0a0e14`)
|
|
||||||
- [x] 3.2 Add `<link rel="manifest">`, `<link rel="apple-touch-icon">`, and the
|
|
||||||
`apple-mobile-web-app-capable` / `apple-mobile-web-app-status-bar-style` /
|
|
||||||
`apple-mobile-web-app-title` tags
|
|
||||||
|
|
||||||
## 4. Responsive layout
|
|
||||||
|
|
||||||
- [x] 4.1 `PriceMatrix.tsx`: render per-venue stacked cards (exchange + bid/ask/qty/
|
|
||||||
spread as 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
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
schema: spec-driven
|
|
||||||
created: 2026-07-16
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
## Context
|
|
||||||
|
|
||||||
`src/instrumentation/otel.ts` builds a tracer that, when `SENTRY_DSN` is set, registers a `NodeTracerProvider` with `SentrySpanProcessor`/`SentryPropagator`, so every `withSpan(...)` call on the hot path creates and exports a span to Sentry. `src/instrumentation/sentry.ts` sets `tracesSampleRate` unconditionally. Running 24/7 on a VPS multiplies span volume against Sentry's free-tier quota (5M spans/month), while error events stay small. We want tracing to remain a debugging option but be off by default.
|
|
||||||
|
|
||||||
## Goals / Non-Goals
|
|
||||||
|
|
||||||
**Goals:**
|
|
||||||
- Add a single boolean env var (`SENTRY_TRACING`, default `false`) that turns span creation/export on or off.
|
|
||||||
- Default behavior emits zero spans and zero hot-path span allocation.
|
|
||||||
- Preserve error monitoring exactly as-is regardless of the flag.
|
|
||||||
- No dependency changes; no API/wire changes.
|
|
||||||
|
|
||||||
**Non-Goals:**
|
|
||||||
- Removing OpenTelemetry or the `withSpan` abstraction.
|
|
||||||
- Making `tracesSampleRate` itself env-configurable (kept as current 0.1 prod / 1 dev when enabled).
|
|
||||||
- Changing which operations are wrapped.
|
|
||||||
|
|
||||||
## Decisions
|
|
||||||
|
|
||||||
- **Boolean gate over sample-rate gate.** A boolean `SENTRY_TRACING` that makes the tracer a no-op avoids hot-path span allocation entirely when off (aligns with the "no unnecessary alloc on the hot path" convention). A `tracesSampleRate=0` approach would still register the provider and create spans that are dropped later — more overhead. Alternative considered and rejected.
|
|
||||||
- **Gate lives in `otel.ts` init.** `initOpenTelemetry()` returns the no-op `trace.getTracer("arbpulse")` unless both `SENTRY_DSN` and `SENTRY_TRACING` are truthy. This keeps `withSpan`'s signature and all call sites unchanged; its `catch` still calls `Sentry.captureException`, so errors are captured even with a no-op span.
|
|
||||||
- **Conditional `tracesSampleRate` in `sentry.ts`.** Only set `tracesSampleRate` when tracing is enabled; omit it otherwise so Sentry performance is fully off. A shared helper `isTracingEnabled()` reads the flag so both files agree.
|
|
||||||
- **Parsing.** Reuse the existing truthy convention (`"1"` or `"true"`, case-insensitive) used by `config.ts`'s `bool()` for consistency; implement locally in the instrumentation module since it initializes before `config` import.
|
|
||||||
|
|
||||||
## Risks / Trade-offs
|
|
||||||
|
|
||||||
- [Someone enables tracing in prod and hits the quota] → Documented in `.env.example`/README that it is off by default and why; enabling is an explicit opt-in.
|
|
||||||
- [Flag read in two files could drift] → Centralize in one `isTracingEnabled()` helper exported from the instrumentation layer.
|
|
||||||
- [No-op tracer still wraps hot path in a promise] → Accepted: `withSpan` remains async as today; the change only removes span export, matching prior behavior when `SENTRY_DSN` was unset.
|
|
||||||
|
|
||||||
## Migration Plan
|
|
||||||
|
|
||||||
- Deploy with `SENTRY_TRACING` unset → tracing off (safe default), errors still reported.
|
|
||||||
- To debug, set `SENTRY_TRACING=true` (with `SENTRY_DSN`) temporarily; unset to roll back. No data migration.
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
## Why
|
|
||||||
|
|
||||||
Arb Pulse is moving to a 24/7 VPS (Hetzner), where the engine runs continuously and emits OpenTelemetry spans on the hot path (`ws.message` → `orderbook.process` → `arbitrage.evaluate` → `sse.broadcast`). At that volume the app can approach or exceed Sentry's free-tier span quota (5M spans/month), while error monitoring stays well within limits. We want to keep tracing available for debugging but off by default so continuous operation is free-tier safe.
|
|
||||||
|
|
||||||
## What Changes
|
|
||||||
|
|
||||||
- Introduce an environment variable (`SENTRY_TRACING`, boolean, default `false`) that gates OpenTelemetry span creation and export to Sentry.
|
|
||||||
- When `SENTRY_TRACING` is disabled (default), the tracer is a no-op: no spans are created or exported, and no hot-path span allocation occurs. Error capture (`Sentry.captureException`, `unhandledRejection`, `uncaughtException`) remains fully active.
|
|
||||||
- When `SENTRY_TRACING` is enabled **and** `SENTRY_DSN` is set, spans are created and exported to Sentry as today, and `tracesSampleRate` is applied.
|
|
||||||
- Document the new variable in `.env.example` and `README.md`.
|
|
||||||
|
|
||||||
## Capabilities
|
|
||||||
|
|
||||||
### New Capabilities
|
|
||||||
- `observability`: error monitoring and optional distributed tracing behavior — how Sentry error capture is always on, and how OpenTelemetry span emission is conditioned on an environment flag.
|
|
||||||
|
|
||||||
### Modified Capabilities
|
|
||||||
<!-- None: no existing specs under openspec/specs/. -->
|
|
||||||
|
|
||||||
## Impact
|
|
||||||
|
|
||||||
- Code: `src/instrumentation/otel.ts` (gate span provider/export), `src/instrumentation/sentry.ts` (conditional `tracesSampleRate`), possibly `src/instrumentation/index.ts`.
|
|
||||||
- Config/docs: `.env.example`, `README.md` (observability section + env var table).
|
|
||||||
- Dependencies: none added or removed (OTel packages stay for the enabled path).
|
|
||||||
- Behavior: default runtime emits zero spans; error monitoring unchanged. No API or wire-format changes.
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
## ADDED Requirements
|
|
||||||
|
|
||||||
### Requirement: Error monitoring is always active
|
|
||||||
|
|
||||||
The system SHALL report runtime errors to Sentry whenever `SENTRY_DSN` is configured, independent of any tracing configuration. This includes REST, WebSocket feed, and SSE errors captured via `Sentry.captureException`, plus process-level `unhandledRejection` and `uncaughtException` handlers.
|
|
||||||
|
|
||||||
#### Scenario: Error captured with DSN set
|
|
||||||
|
|
||||||
- **WHEN** `SENTRY_DSN` is set and a handled error occurs on the WebSocket, REST, or SSE path
|
|
||||||
- **THEN** the error is sent to Sentry as an error event
|
|
||||||
|
|
||||||
#### Scenario: No DSN configured
|
|
||||||
|
|
||||||
- **WHEN** `SENTRY_DSN` is empty or unset
|
|
||||||
- **THEN** Sentry is not initialized and no events are sent, and the application continues running normally
|
|
||||||
|
|
||||||
### Requirement: Tracing spans are gated by an environment flag
|
|
||||||
|
|
||||||
The system SHALL only create and export OpenTelemetry spans when the `SENTRY_TRACING` environment variable is enabled AND `SENTRY_DSN` is set. The flag SHALL default to disabled so that continuous 24/7 operation emits zero spans by default.
|
|
||||||
|
|
||||||
#### Scenario: Tracing disabled by default
|
|
||||||
|
|
||||||
- **WHEN** `SENTRY_TRACING` is unset or falsy (default)
|
|
||||||
- **THEN** the tracer is a no-op, no spans are created on the hot path, and no spans are exported to Sentry
|
|
||||||
|
|
||||||
#### Scenario: Tracing enabled with DSN
|
|
||||||
|
|
||||||
- **WHEN** `SENTRY_TRACING` is truthy and `SENTRY_DSN` is set
|
|
||||||
- **THEN** hot-path spans (`ws.message`, `orderbook.process`, `arbitrage.evaluate`, `sse.broadcast`) are created and exported to Sentry, and `tracesSampleRate` is applied
|
|
||||||
|
|
||||||
#### Scenario: Tracing enabled without DSN
|
|
||||||
|
|
||||||
- **WHEN** `SENTRY_TRACING` is truthy but `SENTRY_DSN` is empty or unset
|
|
||||||
- **THEN** the tracer is a no-op and no spans are exported, since export requires a DSN
|
|
||||||
|
|
||||||
### Requirement: Error capture is preserved when tracing is disabled
|
|
||||||
|
|
||||||
The system SHALL continue to report exceptions thrown inside hot-path work wrappers to Sentry even when tracing is disabled, so disabling spans never reduces error visibility.
|
|
||||||
|
|
||||||
#### Scenario: Exception in hot-path wrapper with tracing off
|
|
||||||
|
|
||||||
- **WHEN** `SENTRY_TRACING` is disabled and code wrapped by the hot-path span helper throws
|
|
||||||
- **THEN** the exception is still captured and reported to Sentry (as an error), and re-thrown to preserve existing control flow
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
## 1. Tracing gate
|
|
||||||
|
|
||||||
- [x] 1.1 Add `isTracingEnabled()` helper (reads `SENTRY_TRACING`, truthy = `"1"`/`"true"` case-insensitive) exported from the instrumentation layer
|
|
||||||
- [x] 1.2 In `src/instrumentation/otel.ts`, return the no-op tracer unless both `SENTRY_DSN` and `SENTRY_TRACING` are truthy (keep `withSpan` signature and its `Sentry.captureException` in catch)
|
|
||||||
- [x] 1.3 In `src/instrumentation/sentry.ts`, only set `tracesSampleRate` when tracing is enabled (omit it otherwise)
|
|
||||||
|
|
||||||
## 2. Documentation
|
|
||||||
|
|
||||||
- [x] 2.1 Add `SENTRY_TRACING` (default `false`) to `.env.example` with a note it gates spans for free-tier safety
|
|
||||||
- [x] 2.2 Update the README observability section to describe the flag and default-off behavior
|
|
||||||
|
|
||||||
## 3. Verification
|
|
||||||
|
|
||||||
- [x] 3.1 `npm run typecheck` passes
|
|
||||||
- [x] 3.2 `npm test` passes
|
|
||||||
- [x] 3.3 Manually confirm: with `SENTRY_TRACING` unset, no spans are emitted; error capture path unaffected
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
schema: spec-driven
|
|
||||||
created: 2026-07-19
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
# Design — fix-kraken-phantom-book-levels
|
|
||||||
|
|
||||||
## Context
|
|
||||||
|
|
||||||
Los conectores WS mantienen un `LocalBook` (Map precio→qty por lado) que se
|
|
||||||
actualiza con snapshots + deltas. Kraken v2 (`book`, depth 10) solo gestiona la
|
|
||||||
ventana top-10: cuando un nivel sale de la ventana porque entran precios
|
|
||||||
mejores, **no envía delete** — el cliente debe truncar su copia local tras cada
|
|
||||||
update (documentado en Kraken WS v2). Hoy `BookSide.apply()` solo borra con
|
|
||||||
`qty <= 0`, y `toArray()` capea el *display* a `depth` pero el Map conserva los
|
|
||||||
niveles huérfanos. Como bids se ordenan desc y asks asc, un bid fantasma alto o
|
|
||||||
un ask fantasma bajo queda **siempre** en el tope del array emitido. Resultado
|
|
||||||
observado en prod: libro de Kraken cruzado (bid > ask) durante horas y $62.9M
|
|
||||||
de P&L ficticio.
|
|
||||||
|
|
||||||
Agravante: `ExchangeConnector.depth = 15` mientras Kraken se suscribe con
|
|
||||||
`depth: 10` — hasta el cap de display admite 5 niveles que Kraken jamás va a
|
|
||||||
actualizar.
|
|
||||||
|
|
||||||
## Goals / Non-Goals
|
|
||||||
|
|
||||||
**Goals:**
|
|
||||||
|
|
||||||
- El libro local de Kraken refleja fielmente la ventana top-10 del exchange.
|
|
||||||
- Un libro internamente cruzado nunca llega al `ArbitrageEngine`.
|
|
||||||
- Recuperación automática ante corrupción (re-sync), sin intervención manual.
|
|
||||||
- Tests unitarios que cubran truncado y guard.
|
|
||||||
|
|
||||||
**Non-Goals:**
|
|
||||||
|
|
||||||
- Validación del checksum CRC32 de Kraken (mejora futura; el truncado +
|
|
||||||
guard cubren el fallo observado con mucho menos código).
|
|
||||||
- Cambios en Bybit/OKX/Binance (OKX y Binance reemplazan el libro completo por
|
|
||||||
mensaje; Bybit manda deletes explícitos para su ventana de 50).
|
|
||||||
- Cambios de API REST, SSE o frontend.
|
|
||||||
|
|
||||||
## Decisions
|
|
||||||
|
|
||||||
1. **`BookSide.truncate()` borra del Map, no solo del display.**
|
|
||||||
Tras aplicar los updates de un mensaje, se ordena por mejor precio y se
|
|
||||||
eliminan los niveles más allá de `depth`. Alternativa considerada: pasar de
|
|
||||||
Map a array ordenado permanente — descartada, complica `apply()` O(1) y el
|
|
||||||
hot path no lo necesita (depth ≤ 50, truncar tras cada mensaje es barato).
|
|
||||||
|
|
||||||
2. **El truncado se invoca desde el conector de Kraken con su depth real (10).**
|
|
||||||
Es un requisito del protocolo de Kraken, no un comportamiento universal:
|
|
||||||
Bybit mantiene ventana 50 con deletes explícitos, OKX/Binance resetean por
|
|
||||||
mensaje. Alternativa: truncar siempre en `emit()` de la base — descartada
|
|
||||||
porque mezclaría semánticas distintas por exchange y ocultaría el contrato.
|
|
||||||
|
|
||||||
3. **Depth por conector.** `ExchangeConnector.depth` pasa a ser sobreescribible
|
|
||||||
y Kraken lo fija en 10, igual a su suscripción. Se elimina el mismatch 15/10.
|
|
||||||
|
|
||||||
4. **Guard de libro cruzado en `emit()` de la base, con auto-recovery.**
|
|
||||||
Si `bids[0].price >= asks[0].price`: no se emite, se loguea `warn` y se
|
|
||||||
fuerza re-sync cerrando el socket (`ws.close()` → el reconnect existente
|
|
||||||
con backoff re-suscribe y Kraken re-manda snapshot). Alternativa: solo
|
|
||||||
descartar la emisión — descartada porque el libro seguiría corrupto y el
|
|
||||||
quote se volvería stale silenciosamente; reconectar restaura el dato.
|
|
||||||
El guard vive en la base porque protege a *todos* los conectores (defensa
|
|
||||||
en profundidad) y su costo es una comparación por emit.
|
|
||||||
|
|
||||||
5. **El estado corrupto acumulado (P&L ficticio) no se migra.** El estado es
|
|
||||||
in-memory: el redeploy lo limpia automáticamente.
|
|
||||||
|
|
||||||
## Risks / Trade-offs
|
|
||||||
|
|
||||||
- [Reconexión en bucle si un exchange emitiera libros cruzados legítimos] →
|
|
||||||
imposible en spot con un libro bien sincronizado; si ocurriera, el backoff
|
|
||||||
exponencial existente (cap 30s) limita el impacto y el log `warn` lo hace
|
|
||||||
visible.
|
|
||||||
- [Truncar en cada mensaje añade un sort O(n log n)] → n ≤ ~20 niveles en
|
|
||||||
Kraken; despreciable frente al parse JSON del propio mensaje.
|
|
||||||
- [Sin checksum, otros desyncs sutiles (qty desactualizada dentro de la
|
|
||||||
ventana) no se detectan] → aceptado; el guard de cruce ataja el caso dañino
|
|
||||||
y el checksum queda como mejora futura documentada.
|
|
||||||
|
|
||||||
## Migration Plan
|
|
||||||
|
|
||||||
1. Merge a `dev` → PR → `main`.
|
|
||||||
2. Deploy a la VPS (redeploy limpia el estado in-memory, P&L vuelve a 0).
|
|
||||||
3. Verificar en prod: `/api/state` con los 4 exchanges `live`, libro de Kraken
|
|
||||||
no cruzado, y P&L creciendo de forma realista (mayormente rechazos por fees).
|
|
||||||
|
|
||||||
Rollback: revertir el commit; no hay migración de datos.
|
|
||||||
|
|
||||||
## Open Questions
|
|
||||||
|
|
||||||
- Ninguna bloqueante. Checksum CRC32 de Kraken queda anotado como follow-up.
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
# Fix: niveles fantasma en el order book de Kraken
|
|
||||||
|
|
||||||
> Issue: [WAY-77](https://linear.app/wayool/issue/WAY-77/arb-niveles-fantasma-en-el-order-book-de-kraken-inflan-el-pandl) — `[ARB] Niveles fantasma en el order book de Kraken inflan el P&L ($62.9M ficticios)`
|
|
||||||
|
|
||||||
## Why
|
|
||||||
|
|
||||||
En producción el P&L realizado llegó a $62.9M ficticios: el libro local de Kraken quedó **cruzado** (bid 64,925.90 > ask 64,316.20, spread -609) con niveles viejos de hace horas (coinciden con el high/low de 24h de Kraken). El motor vio un arbitraje permanente de ~0.5% vendiendo en Kraken y ejecutó ~674k trades falsos. La causa: el conector de Kraken v2 nunca trunca el libro local al depth suscrito, y el protocolo de Kraken **no envía deletes** para niveles que salen de la ventana top-N — exige que el cliente trunque tras cada update.
|
|
||||||
|
|
||||||
## What Changes
|
|
||||||
|
|
||||||
- `BookSide` (`src/infrastructure/exchanges/local-book.ts`) gana un método `truncate()` que elimina del Map los niveles fuera de los mejores `depth` precios (no solo en el display).
|
|
||||||
- El conector de Kraken (`src/infrastructure/exchanges/kraken.ts`) trunca ambos lados tras aplicar cada update, usando el depth suscrito (10).
|
|
||||||
- Se corrige el mismatch de depth: el conector de Kraken suscribe y mantiene el mismo depth (hoy: base mantiene 15, suscripción pide 10).
|
|
||||||
- Guard de libro cruzado en `ExchangeConnector.emit()`: si `bids[0].price >= asks[0].price`, no se emite el libro, se loguea y se resetea el libro local para forzar re-sincronización (Kraken re-manda snapshot al reconectar/resuscribir).
|
|
||||||
- Documentación actualizada: skill `exchange-ws` (nota de truncado obligatorio en Kraken v2) y README si aplica.
|
|
||||||
|
|
||||||
## Capabilities
|
|
||||||
|
|
||||||
### New Capabilities
|
|
||||||
|
|
||||||
- `order-book-integrity`: mantenimiento correcto del libro local por exchange — truncado al depth suscrito en feeds delta (Kraken), detección de libro cruzado como señal de corrupción, y re-sincronización en lugar de emitir datos corruptos al motor.
|
|
||||||
|
|
||||||
### Modified Capabilities
|
|
||||||
|
|
||||||
<!-- ninguna: `observability` no cambia a nivel de requisitos -->
|
|
||||||
|
|
||||||
## Impact
|
|
||||||
|
|
||||||
- **Código:** `src/infrastructure/exchanges/local-book.ts`, `src/infrastructure/exchanges/kraken.ts`, `src/infrastructure/exchanges/base.ts`.
|
|
||||||
- **Tests:** nuevos unit tests de `BookSide.truncate` y del guard de libro cruzado.
|
|
||||||
- **Comportamiento:** el motor deja de recibir libros corruptos; el P&L vuelve a ser realista (mayormente `rejected · fees`, que es lo correcto en mercados eficientes).
|
|
||||||
- **Sin cambios de API/contrato REST ni de frontend.** Tras el deploy se requiere un Reset manual del estado para limpiar el P&L ficticio acumulado (estado in-memory: el redeploy ya lo limpia solo).
|
|
||||||
-58
@@ -1,58 +0,0 @@
|
|||||||
# order-book-integrity
|
|
||||||
|
|
||||||
## ADDED Requirements
|
|
||||||
|
|
||||||
### Requirement: Truncado del libro local al depth suscrito en feeds delta
|
|
||||||
|
|
||||||
`BookSide` SHALL exponer una operación `truncate()` que elimine del estado
|
|
||||||
interno (no solo de la salida) todos los niveles de precio más allá de los
|
|
||||||
mejores `depth` niveles del lado (bids: precios más altos; asks: precios más
|
|
||||||
bajos). El conector de Kraken SHALL invocar el truncado en ambos lados tras
|
|
||||||
aplicar cada mensaje `update`, usando el mismo depth con el que se suscribió
|
|
||||||
al canal `book`.
|
|
||||||
|
|
||||||
#### Scenario: Nivel que sale de la ventana top-N se elimina
|
|
||||||
|
|
||||||
- **WHEN** el libro local de bids contiene `depth` niveles y un update añade un
|
|
||||||
bid con precio mejor que todos los existentes
|
|
||||||
- **THEN** tras el truncado el nivel con peor precio ya no existe en el estado
|
|
||||||
interno del `BookSide` y el tamaño del lado es exactamente `depth`
|
|
||||||
|
|
||||||
#### Scenario: Bid fantasma no sobrevive al movimiento del mercado
|
|
||||||
|
|
||||||
- **WHEN** el precio de mercado baja y sucesivos updates llenan la ventana
|
|
||||||
top-N con precios inferiores a un bid antiguo que Kraken ya no reporta
|
|
||||||
- **THEN** el bid antiguo es eliminado por truncado y el mejor bid emitido
|
|
||||||
refleja la ventana real del exchange
|
|
||||||
|
|
||||||
### Requirement: Depth del conector consistente con la suscripción
|
|
||||||
|
|
||||||
Cada conector SHALL mantener su libro local con el mismo depth que solicita en
|
|
||||||
su suscripción. El conector de Kraken SHALL usar depth 10 tanto en el mensaje
|
|
||||||
de suscripción como en su `LocalBook`.
|
|
||||||
|
|
||||||
#### Scenario: Sin niveles residuales por mismatch de depth
|
|
||||||
|
|
||||||
- **WHEN** el conector de Kraken arranca y se suscribe al canal `book`
|
|
||||||
- **THEN** el depth del `LocalBook` es igual al depth de la suscripción (10)
|
|
||||||
|
|
||||||
### Requirement: Guard de libro cruzado con re-sincronización
|
|
||||||
|
|
||||||
`ExchangeConnector` SHALL detectar antes de emitir cuando el libro normalizado
|
|
||||||
está internamente cruzado (`bids[0].price >= asks[0].price`). En ese caso el
|
|
||||||
conector MUST NOT emitir el libro a los listeners, SHALL registrar el evento en
|
|
||||||
el log, y SHALL forzar una re-sincronización (reset del libro local y
|
|
||||||
reconexión del WebSocket para recibir un snapshot fresco).
|
|
||||||
|
|
||||||
#### Scenario: Libro cruzado no llega al motor
|
|
||||||
|
|
||||||
- **WHEN** el libro local de un exchange queda con mejor bid ≥ mejor ask
|
|
||||||
- **THEN** no se emite ningún `OrderBook` a los listeners y el
|
|
||||||
`ArbitrageEngine` no evalúa ese libro
|
|
||||||
|
|
||||||
#### Scenario: Recuperación automática tras corrupción
|
|
||||||
|
|
||||||
- **WHEN** se detecta un libro cruzado
|
|
||||||
- **THEN** el conector resetea su libro local y fuerza reconexión, y tras el
|
|
||||||
snapshot de re-suscripción vuelve a emitir libros consistentes sin
|
|
||||||
intervención manual
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
# Tasks — fix-kraken-phantom-book-levels
|
|
||||||
|
|
||||||
## 1. LocalBook: truncado real
|
|
||||||
|
|
||||||
- [x] Añadir `BookSide.truncate()` que elimine del Map los niveles fuera de los mejores `depth` precios del lado
|
|
||||||
- [x] Unit tests de `truncate()`: elimina el peor nivel al exceder depth, no toca nada si size <= depth, y el bid fantasma desaparece tras updates sucesivos
|
|
||||||
|
|
||||||
## 2. Conector Kraken
|
|
||||||
|
|
||||||
- [x] Hacer `ExchangeConnector.depth` sobreescribible por subclase e inicializar `LocalBook` con el depth del conector
|
|
||||||
- [x] Fijar depth 10 en `KrakenConnector` (igual a la suscripción) y truncar ambos lados tras aplicar cada update
|
|
||||||
|
|
||||||
## 3. Guard de libro cruzado
|
|
||||||
|
|
||||||
- [x] En `ExchangeConnector.emit()`: si `bids[0].price >= asks[0].price`, no emitir, log warn, reset del libro y reconexión para re-sync
|
|
||||||
- [x] Unit test del guard: libro cruzado no se emite a listeners y dispara re-sync
|
|
||||||
|
|
||||||
## 4. Documentación
|
|
||||||
|
|
||||||
- [x] Actualizar skill `exchange-ws` (truncado obligatorio en Kraken v2, guard de cruce en la base, depth por conector)
|
|
||||||
- [x] Revisar README/AGENTS por menciones al manejo del libro que queden desactualizadas
|
|
||||||
|
|
||||||
## 5. Verificación
|
|
||||||
|
|
||||||
- [x] `npm run typecheck` + `npm test` en verde
|
|
||||||
- [x] Arrancar en local con feeds reales y verificar via `/api/state` que Kraken emite libro no cruzado y quotes coherentes con el mercado (matar el proceso al terminar)
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
schema: spec-driven
|
|
||||||
|
|
||||||
# Project context (optional)
|
|
||||||
# This is shown to AI when creating artifacts.
|
|
||||||
# Add your tech stack, conventions, style guides, domain knowledge, etc.
|
|
||||||
# Example:
|
|
||||||
# context: |
|
|
||||||
# Tech stack: TypeScript, React, Node.js
|
|
||||||
# We use conventional commits
|
|
||||||
# Domain: e-commerce platform
|
|
||||||
|
|
||||||
# Per-artifact rules (optional)
|
|
||||||
# Add custom rules for specific artifacts.
|
|
||||||
# Example:
|
|
||||||
# rules:
|
|
||||||
# proposal:
|
|
||||||
# - Keep proposals under 500 words
|
|
||||||
# - Always include a "Non-goals" section
|
|
||||||
# tasks:
|
|
||||||
# - Break tasks into chunks of max 2 hours
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
# observability Specification
|
|
||||||
|
|
||||||
## Purpose
|
|
||||||
TBD - created by archiving change gate-sentry-spans. Update Purpose after archive.
|
|
||||||
## Requirements
|
|
||||||
### Requirement: Error monitoring is always active
|
|
||||||
|
|
||||||
The system SHALL report runtime errors to Sentry whenever `SENTRY_DSN` is configured, independent of any tracing configuration. This includes REST, WebSocket feed, and SSE errors captured via `Sentry.captureException`, plus process-level `unhandledRejection` and `uncaughtException` handlers.
|
|
||||||
|
|
||||||
#### Scenario: Error captured with DSN set
|
|
||||||
|
|
||||||
- **WHEN** `SENTRY_DSN` is set and a handled error occurs on the WebSocket, REST, or SSE path
|
|
||||||
- **THEN** the error is sent to Sentry as an error event
|
|
||||||
|
|
||||||
#### Scenario: No DSN configured
|
|
||||||
|
|
||||||
- **WHEN** `SENTRY_DSN` is empty or unset
|
|
||||||
- **THEN** Sentry is not initialized and no events are sent, and the application continues running normally
|
|
||||||
|
|
||||||
### Requirement: Tracing spans are gated by an environment flag
|
|
||||||
|
|
||||||
The system SHALL only create and export OpenTelemetry spans when the `SENTRY_TRACING` environment variable is enabled AND `SENTRY_DSN` is set. The flag SHALL default to disabled so that continuous 24/7 operation emits zero spans by default.
|
|
||||||
|
|
||||||
#### Scenario: Tracing disabled by default
|
|
||||||
|
|
||||||
- **WHEN** `SENTRY_TRACING` is unset or falsy (default)
|
|
||||||
- **THEN** the tracer is a no-op, no spans are created on the hot path, and no spans are exported to Sentry
|
|
||||||
|
|
||||||
#### Scenario: Tracing enabled with DSN
|
|
||||||
|
|
||||||
- **WHEN** `SENTRY_TRACING` is truthy and `SENTRY_DSN` is set
|
|
||||||
- **THEN** hot-path spans (`ws.message`, `orderbook.process`, `arbitrage.evaluate`, `sse.broadcast`) are created and exported to Sentry, and `tracesSampleRate` is applied
|
|
||||||
|
|
||||||
#### Scenario: Tracing enabled without DSN
|
|
||||||
|
|
||||||
- **WHEN** `SENTRY_TRACING` is truthy but `SENTRY_DSN` is empty or unset
|
|
||||||
- **THEN** the tracer is a no-op and no spans are exported, since export requires a DSN
|
|
||||||
|
|
||||||
### Requirement: Error capture is preserved when tracing is disabled
|
|
||||||
|
|
||||||
The system SHALL continue to report exceptions thrown inside hot-path work wrappers to Sentry even when tracing is disabled, so disabling spans never reduces error visibility.
|
|
||||||
|
|
||||||
#### Scenario: Exception in hot-path wrapper with tracing off
|
|
||||||
|
|
||||||
- **WHEN** `SENTRY_TRACING` is disabled and code wrapped by the hot-path span helper throws
|
|
||||||
- **THEN** the exception is still captured and reported to Sentry (as an error), and re-thrown to preserve existing control flow
|
|
||||||
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
# order-book-integrity
|
|
||||||
|
|
||||||
## Purpose
|
|
||||||
|
|
||||||
Garantizar que el libro local de cada exchange refleje fielmente el estado real
|
|
||||||
del venue: truncado al depth suscrito en feeds delta con ventana top-N,
|
|
||||||
detección de libros internamente cruzados como señal de corrupción, y
|
|
||||||
re-sincronización automática en lugar de emitir datos corruptos al motor de
|
|
||||||
arbitraje.
|
|
||||||
|
|
||||||
Origen: incidente de niveles fantasma en Kraken (Linear WAY-77, change
|
|
||||||
`fix-kraken-phantom-book-levels`).
|
|
||||||
|
|
||||||
## Requirements
|
|
||||||
|
|
||||||
### Requirement: Truncado del libro local al depth suscrito en feeds delta
|
|
||||||
|
|
||||||
`BookSide` SHALL exponer una operación `truncate()` que elimine del estado
|
|
||||||
interno (no solo de la salida) todos los niveles de precio más allá de los
|
|
||||||
mejores `depth` niveles del lado (bids: precios más altos; asks: precios más
|
|
||||||
bajos). El conector de Kraken SHALL invocar el truncado en ambos lados tras
|
|
||||||
aplicar cada mensaje `update`, usando el mismo depth con el que se suscribió
|
|
||||||
al canal `book`.
|
|
||||||
|
|
||||||
#### Scenario: Nivel que sale de la ventana top-N se elimina
|
|
||||||
|
|
||||||
- **WHEN** el libro local de bids contiene `depth` niveles y un update añade un
|
|
||||||
bid con precio mejor que todos los existentes
|
|
||||||
- **THEN** tras el truncado el nivel con peor precio ya no existe en el estado
|
|
||||||
interno del `BookSide` y el tamaño del lado es exactamente `depth`
|
|
||||||
|
|
||||||
#### Scenario: Bid fantasma no sobrevive al movimiento del mercado
|
|
||||||
|
|
||||||
- **WHEN** el precio de mercado baja y sucesivos updates llenan la ventana
|
|
||||||
top-N con precios inferiores a un bid antiguo que Kraken ya no reporta
|
|
||||||
- **THEN** el bid antiguo es eliminado por truncado y el mejor bid emitido
|
|
||||||
refleja la ventana real del exchange
|
|
||||||
|
|
||||||
### Requirement: Depth del conector consistente con la suscripción
|
|
||||||
|
|
||||||
Cada conector SHALL mantener su libro local con el mismo depth que solicita en
|
|
||||||
su suscripción. El conector de Kraken SHALL usar depth 10 tanto en el mensaje
|
|
||||||
de suscripción como en su `LocalBook`.
|
|
||||||
|
|
||||||
#### Scenario: Sin niveles residuales por mismatch de depth
|
|
||||||
|
|
||||||
- **WHEN** el conector de Kraken arranca y se suscribe al canal `book`
|
|
||||||
- **THEN** el depth del `LocalBook` es igual al depth de la suscripción (10)
|
|
||||||
|
|
||||||
### Requirement: Guard de libro cruzado con re-sincronización
|
|
||||||
|
|
||||||
`ExchangeConnector` SHALL detectar antes de emitir cuando el libro normalizado
|
|
||||||
está internamente cruzado (`bids[0].price >= asks[0].price`). En ese caso el
|
|
||||||
conector MUST NOT emitir el libro a los listeners, SHALL registrar el evento en
|
|
||||||
el log, y SHALL forzar una re-sincronización (reset del libro local y
|
|
||||||
reconexión del WebSocket para recibir un snapshot fresco).
|
|
||||||
|
|
||||||
#### Scenario: Libro cruzado no llega al motor
|
|
||||||
|
|
||||||
- **WHEN** el libro local de un exchange queda con mejor bid ≥ mejor ask
|
|
||||||
- **THEN** no se emite ningún `OrderBook` a los listeners y el
|
|
||||||
`ArbitrageEngine` no evalúa ese libro
|
|
||||||
|
|
||||||
#### Scenario: Recuperación automática tras corrupción
|
|
||||||
|
|
||||||
- **WHEN** se detecta un libro cruzado
|
|
||||||
- **THEN** el conector resetea su libro local y fuerza reconexión, y tras el
|
|
||||||
snapshot de re-suscripción vuelve a emitir libros consistentes sin
|
|
||||||
intervención manual
|
|
||||||
Generated
+42
-2910
File diff suppressed because it is too large
Load Diff
+6
-38
@@ -12,52 +12,20 @@
|
|||||||
"start": "tsx src/index.ts",
|
"start": "tsx src/index.ts",
|
||||||
"build": "npm --prefix web install && npm --prefix web run build",
|
"build": "npm --prefix web install && npm --prefix web run build",
|
||||||
"typecheck": "tsc --noEmit && npm --prefix web run typecheck",
|
"typecheck": "tsc --noEmit && npm --prefix web run typecheck",
|
||||||
"test": "node scripts/run-tests.mjs",
|
"test": "node scripts/run-tests.mjs"
|
||||||
"test:rate-limit": "node --import tsx --test src/interfaces/http/rate-limit.test.ts",
|
|
||||||
"test:e2e": "playwright test",
|
|
||||||
"test:observability": "playwright test observability.spec.ts",
|
|
||||||
"lint": "eslint .",
|
|
||||||
"prepare": "husky || true"
|
|
||||||
},
|
|
||||||
"lint-staged": {
|
|
||||||
"src/**/*.ts": [
|
|
||||||
"eslint --fix",
|
|
||||||
"prettier --write"
|
|
||||||
],
|
|
||||||
"web/src/**/*.{ts,tsx}": [
|
|
||||||
"eslint --fix",
|
|
||||||
"prettier --write"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@asteasolutions/zod-to-openapi": "^8.5.0",
|
|
||||||
"@opentelemetry/api": "^1.9.1",
|
|
||||||
"@opentelemetry/sdk-trace-node": "^2.7.1",
|
|
||||||
"@scalar/express-api-reference": "^0.8.48",
|
|
||||||
"@sentry/node": "^10.57.0",
|
|
||||||
"@sentry/opentelemetry": "^10.57.0",
|
|
||||||
"@upstash/ratelimit": "^2.0.6",
|
|
||||||
"@upstash/redis": "^1.35.3",
|
|
||||||
"express": "^4.21.2",
|
"express": "^4.21.2",
|
||||||
"pino": "^10.3.1",
|
"swagger-ui-express": "^5.0.1",
|
||||||
"pino-pretty": "^13.1.3",
|
|
||||||
"tsx": "^4.19.2",
|
"tsx": "^4.19.2",
|
||||||
"ws": "^8.18.0",
|
"ws": "^8.18.0"
|
||||||
"zod": "^4.4.3"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.18.0",
|
|
||||||
"@playwright/test": "^1.51.1",
|
|
||||||
"@types/express": "^4.17.21",
|
"@types/express": "^4.17.21",
|
||||||
"@types/node": "^22.10.5",
|
"@types/node": "^22.10.5",
|
||||||
|
"@types/swagger-ui-express": "^4.1.8",
|
||||||
"@types/ws": "^8.5.13",
|
"@types/ws": "^8.5.13",
|
||||||
"eslint": "^9.18.0",
|
"openapi-types": "^12.1.3",
|
||||||
"eslint-plugin-react-hooks": "^5.1.0",
|
"typescript": "^5.7.3"
|
||||||
"globals": "^15.14.0",
|
|
||||||
"husky": "^9.1.7",
|
|
||||||
"lint-staged": "^16.4.0",
|
|
||||||
"prettier": "^3.8.3",
|
|
||||||
"typescript": "^5.7.3",
|
|
||||||
"typescript-eslint": "^8.26.0"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
import { defineConfig, devices } from "@playwright/test";
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
testDir: "e2e",
|
|
||||||
fullyParallel: false,
|
|
||||||
forbidOnly: !!process.env.CI,
|
|
||||||
retries: process.env.CI ? 2 : 0,
|
|
||||||
workers: 1,
|
|
||||||
timeout: 60_000,
|
|
||||||
reporter: process.env.CI ? "github" : "html",
|
|
||||||
use: {
|
|
||||||
baseURL: "http://localhost:8080",
|
|
||||||
trace: "on-first-retry",
|
|
||||||
},
|
|
||||||
projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }],
|
|
||||||
...(!process.env.CI && {
|
|
||||||
webServer: {
|
|
||||||
command: "npm start",
|
|
||||||
url: "http://localhost:8080/api/health",
|
|
||||||
reuseExistingServer: !process.env.PW_FRESH_SERVER,
|
|
||||||
env: { DEMO_MODE: "true" },
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 824 KiB |
@@ -1,44 +0,0 @@
|
|||||||
# One-off ArbPulse PWA icon generator.
|
|
||||||
# Resizes the AI-generated master icon into the sizes referenced by the
|
|
||||||
# Web App Manifest and index.html. Uses only Windows System.Drawing (no deps).
|
|
||||||
param(
|
|
||||||
[string]$Master = (Join-Path $PSScriptRoot "arbpulse-icon-master.png"),
|
|
||||||
[string]$OutDir = (Join-Path $PSScriptRoot "..\web\public")
|
|
||||||
)
|
|
||||||
|
|
||||||
Add-Type -AssemblyName System.Drawing
|
|
||||||
|
|
||||||
$bg = [System.Drawing.ColorTranslator]::FromHtml("#0a0e14")
|
|
||||||
|
|
||||||
function Save-Resized {
|
|
||||||
param([System.Drawing.Image]$Src, [int]$Size, [string]$Path, [double]$Scale = 1.0)
|
|
||||||
|
|
||||||
$bmp = New-Object System.Drawing.Bitmap($Size, $Size)
|
|
||||||
$g = [System.Drawing.Graphics]::FromImage($bmp)
|
|
||||||
$g.InterpolationMode = [System.Drawing.Drawing2D.InterpolationMode]::HighQualityBicubic
|
|
||||||
$g.SmoothingMode = [System.Drawing.Drawing2D.SmoothingMode]::HighQuality
|
|
||||||
$g.PixelOffsetMode = [System.Drawing.Drawing2D.PixelOffsetMode]::HighQuality
|
|
||||||
$g.Clear($bg)
|
|
||||||
|
|
||||||
$target = [int]([math]::Round($Size * $Scale))
|
|
||||||
$offset = [int]([math]::Round(($Size - $target) / 2))
|
|
||||||
$rect = New-Object System.Drawing.Rectangle($offset, $offset, $target, $target)
|
|
||||||
$g.DrawImage($Src, $rect)
|
|
||||||
|
|
||||||
$g.Dispose()
|
|
||||||
$bmp.Save($Path, [System.Drawing.Imaging.ImageFormat]::Png)
|
|
||||||
$bmp.Dispose()
|
|
||||||
Write-Output "wrote $Path ($Size x $Size, scale $Scale)"
|
|
||||||
}
|
|
||||||
|
|
||||||
$src = [System.Drawing.Image]::FromFile($Master)
|
|
||||||
|
|
||||||
Save-Resized -Src $src -Size 192 -Path (Join-Path $OutDir "icon-192.png")
|
|
||||||
Save-Resized -Src $src -Size 512 -Path (Join-Path $OutDir "icon-512.png")
|
|
||||||
Save-Resized -Src $src -Size 180 -Path (Join-Path $OutDir "apple-touch-icon.png")
|
|
||||||
# Maskable: full-bleed on the master's own background. The glyph's built-in
|
|
||||||
# padding already keeps it inside the ~80% safe zone, and full-bleed avoids a
|
|
||||||
# visible seam from mismatched flat padding vs. the master's subtle glow.
|
|
||||||
Save-Resized -Src $src -Size 512 -Path (Join-Path $OutDir "icon-maskable-512.png") -Scale 1.0
|
|
||||||
|
|
||||||
$src.Dispose()
|
|
||||||
@@ -1,10 +1,5 @@
|
|||||||
import type { OrderBook } from "../../domain/entities/index.js";
|
import type { OrderBook } from "../../domain/entities/index.js";
|
||||||
import type { ArbitrageEngine } from "../../domain/services/arbitrage-engine.js";
|
import type { ArbitrageEngine } from "../../domain/services/arbitrage-engine.js";
|
||||||
import { withSpan } from "../../instrumentation/otel.js";
|
|
||||||
import {
|
|
||||||
newCorrelationId,
|
|
||||||
runWithCorrelation,
|
|
||||||
} from "../../infrastructure/logging/correlation.js";
|
|
||||||
|
|
||||||
export interface FeedRecorderPort {
|
export interface FeedRecorderPort {
|
||||||
record(book: OrderBook): void;
|
record(book: OrderBook): void;
|
||||||
@@ -15,30 +10,12 @@ export class ProcessOrderBookUpdate {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly engine: ArbitrageEngine,
|
private readonly engine: ArbitrageEngine,
|
||||||
private readonly recorder: FeedRecorderPort,
|
private readonly recorder: FeedRecorderPort,
|
||||||
private readonly isExchangeActive: (
|
private readonly isExchangeActive: (exchange: OrderBook["exchange"]) => boolean,
|
||||||
exchange: OrderBook["exchange"],
|
|
||||||
) => boolean,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
run(book: OrderBook): void {
|
run(book: OrderBook): void {
|
||||||
if (!this.isExchangeActive(book.exchange)) return;
|
if (!this.isExchangeActive(book.exchange)) return;
|
||||||
|
|
||||||
const correlationId = newCorrelationId("book");
|
|
||||||
runWithCorrelation({ correlationId, exchange: book.exchange }, () => {
|
|
||||||
void withSpan(
|
|
||||||
"orderbook.process",
|
|
||||||
{ exchange: book.exchange, correlationId },
|
|
||||||
async () => {
|
|
||||||
this.recorder.record(book);
|
this.recorder.record(book);
|
||||||
await withSpan(
|
|
||||||
"arbitrage.evaluate",
|
|
||||||
{ exchange: book.exchange, correlationId },
|
|
||||||
async () => {
|
|
||||||
this.engine.onBook(book);
|
this.engine.onBook(book);
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-19
@@ -1,11 +1,5 @@
|
|||||||
import "./load-env.js";
|
|
||||||
import { initInstrumentation } from "./instrumentation/index.js";
|
|
||||||
|
|
||||||
initInstrumentation();
|
|
||||||
|
|
||||||
import express from "express";
|
import express from "express";
|
||||||
import * as Sentry from "@sentry/node";
|
import swaggerUi from "swagger-ui-express";
|
||||||
import { apiReference } from "@scalar/express-api-reference";
|
|
||||||
import { existsSync } from "node:fs";
|
import { existsSync } from "node:fs";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import { dirname, join } from "node:path";
|
import { dirname, join } from "node:path";
|
||||||
@@ -14,7 +8,7 @@ import { bootstrap, config } from "./composition/bootstrap.js";
|
|||||||
import { runtime } from "./infrastructure/config/runtime.js";
|
import { runtime } from "./infrastructure/config/runtime.js";
|
||||||
import { SseHub } from "./interfaces/sse/sse.js";
|
import { SseHub } from "./interfaces/sse/sse.js";
|
||||||
import { createRouter } from "./interfaces/http/routes.js";
|
import { createRouter } from "./interfaces/http/routes.js";
|
||||||
import { openapiDocument } from "./interfaces/http/openapi.js";
|
import { openapiSpec } from "./interfaces/http/openapi.js";
|
||||||
|
|
||||||
const log = createLogger("server");
|
const log = createLogger("server");
|
||||||
|
|
||||||
@@ -31,15 +25,11 @@ function main(): void {
|
|||||||
const server = express();
|
const server = express();
|
||||||
server.use(express.json());
|
server.use(express.json());
|
||||||
server.use("/api", createRouter(ctx.application, sse));
|
server.use("/api", createRouter(ctx.application, sse));
|
||||||
Sentry.setupExpressErrorHandler(server);
|
|
||||||
|
|
||||||
server.use(
|
server.use("/api-docs", swaggerUi.serve, swaggerUi.setup(openapiSpec, {
|
||||||
"/api-docs",
|
customSiteTitle: "Arb Pulse — API Docs",
|
||||||
apiReference({
|
swaggerOptions: { defaultModelsExpandDepth: 2, defaultModelExpandDepth: 2 },
|
||||||
content: openapiDocument,
|
}));
|
||||||
pageTitle: "Arb Pulse — API Docs",
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (existsSync(webDist)) {
|
if (existsSync(webDist)) {
|
||||||
server.use(express.static(webDist));
|
server.use(express.static(webDist));
|
||||||
@@ -50,9 +40,7 @@ function main(): void {
|
|||||||
server.get("/", (_req, res) => {
|
server.get("/", (_req, res) => {
|
||||||
res
|
res
|
||||||
.status(200)
|
.status(200)
|
||||||
.send(
|
.send("Backend running. Frontend not built yet — run `npm run build` (or `npm run dev:web`).");
|
||||||
"Backend running. Frontend not built yet — run `npm run build` (or `npm run dev:web`).",
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Vendored
-48
@@ -1,48 +0,0 @@
|
|||||||
import { Redis } from "@upstash/redis";
|
|
||||||
|
|
||||||
const SNAPSHOT_KEY = "arbpulse:snapshot";
|
|
||||||
const SNAPSHOT_TTL_SEC = 1;
|
|
||||||
|
|
||||||
let redis: Redis | null | undefined;
|
|
||||||
|
|
||||||
export function isUpstashConfigured(): boolean {
|
|
||||||
return Boolean(
|
|
||||||
process.env.UPSTASH_REDIS_REST_URL?.trim() &&
|
|
||||||
process.env.UPSTASH_REDIS_REST_TOKEN?.trim(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getRedis(): Redis | null {
|
|
||||||
if (redis !== undefined) {
|
|
||||||
return redis;
|
|
||||||
}
|
|
||||||
const url = process.env.UPSTASH_REDIS_REST_URL?.trim();
|
|
||||||
const token = process.env.UPSTASH_REDIS_REST_TOKEN?.trim();
|
|
||||||
if (!url || !token) {
|
|
||||||
redis = null;
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
redis = new Redis({ url, token });
|
|
||||||
return redis;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getCachedSnapshot<T>(): Promise<T | null> {
|
|
||||||
const client = getRedis();
|
|
||||||
if (!client) return null;
|
|
||||||
try {
|
|
||||||
return (await client.get<T>(SNAPSHOT_KEY)) ?? null;
|
|
||||||
} catch (err) {
|
|
||||||
console.warn("[upstash] get snapshot failed", err);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function setCachedSnapshot<T>(snapshot: T): Promise<void> {
|
|
||||||
const client = getRedis();
|
|
||||||
if (!client) return;
|
|
||||||
try {
|
|
||||||
await client.set(SNAPSHOT_KEY, snapshot, { ex: SNAPSHOT_TTL_SEC });
|
|
||||||
} catch (err) {
|
|
||||||
console.warn("[upstash] set snapshot failed", err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
import { test } from "node:test";
|
|
||||||
import assert from "node:assert/strict";
|
|
||||||
import type { ExchangeId, OrderBook } from "../../domain/entities/index.js";
|
|
||||||
import { ExchangeConnector } from "./base.js";
|
|
||||||
|
|
||||||
/** Minimal connector for exercising emit() without a real WebSocket. */
|
|
||||||
class TestConnector extends ExchangeConnector {
|
|
||||||
readonly id: ExchangeId = "kraken";
|
|
||||||
protected readonly url = "ws://unused";
|
|
||||||
resyncCount = 0;
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
super(5);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected subscribeMessage(): unknown {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
|
|
||||||
protected handleMessage(): void {}
|
|
||||||
|
|
||||||
protected override resync(): void {
|
|
||||||
this.resyncCount += 1;
|
|
||||||
super.resync();
|
|
||||||
}
|
|
||||||
|
|
||||||
applyLevels(bids: [number, number][], asks: [number, number][]): void {
|
|
||||||
for (const [price, qty] of bids) this.book.bids.apply(price, qty);
|
|
||||||
for (const [price, qty] of asks) this.book.asks.apply(price, qty);
|
|
||||||
}
|
|
||||||
|
|
||||||
emitNow(): void {
|
|
||||||
this.emit(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
get bookSize(): number {
|
|
||||||
return this.book.bids.size + this.book.asks.size;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
test("normal book is emitted to listeners", () => {
|
|
||||||
const c = new TestConnector();
|
|
||||||
const received: OrderBook[] = [];
|
|
||||||
c.onBook((b) => received.push(b));
|
|
||||||
|
|
||||||
c.applyLevels([[64560, 1]], [[64561, 1]]);
|
|
||||||
c.emitNow();
|
|
||||||
|
|
||||||
assert.equal(received.length, 1);
|
|
||||||
assert.equal(received[0]?.bids[0]?.price, 64560);
|
|
||||||
assert.equal(c.resyncCount, 0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("crossed book is not emitted and triggers resync", () => {
|
|
||||||
const c = new TestConnector();
|
|
||||||
const received: OrderBook[] = [];
|
|
||||||
c.onBook((b) => received.push(b));
|
|
||||||
|
|
||||||
// Phantom bid above the real ask: corrupted local book.
|
|
||||||
c.applyLevels(
|
|
||||||
[
|
|
||||||
[64925.9, 0.15],
|
|
||||||
[64560, 1],
|
|
||||||
],
|
|
||||||
[[64561, 1]],
|
|
||||||
);
|
|
||||||
c.emitNow();
|
|
||||||
|
|
||||||
assert.equal(received.length, 0, "corrupted book must not reach listeners");
|
|
||||||
assert.equal(c.resyncCount, 1);
|
|
||||||
assert.equal(c.bookSize, 0, "local book is reset for a fresh snapshot");
|
|
||||||
});
|
|
||||||
@@ -1,7 +1,5 @@
|
|||||||
import WebSocket from "ws";
|
import WebSocket from "ws";
|
||||||
import * as Sentry from "@sentry/node";
|
|
||||||
import type { ExchangeId, OrderBook } from "../../domain/entities/index.js";
|
import type { ExchangeId, OrderBook } from "../../domain/entities/index.js";
|
||||||
import { withSpan } from "../../instrumentation/otel.js";
|
|
||||||
import { createLogger, type Logger } from "../logging/logger.js";
|
import { createLogger, type Logger } from "../logging/logger.js";
|
||||||
import type { MarketDataFeed } from "../../domain/ports/ports.js";
|
import type { MarketDataFeed } from "../../domain/ports/ports.js";
|
||||||
import { LocalBook } from "./local-book.js";
|
import { LocalBook } from "./local-book.js";
|
||||||
@@ -16,7 +14,7 @@ export type BookListener = (book: OrderBook) => void;
|
|||||||
export abstract class ExchangeConnector implements MarketDataFeed {
|
export abstract class ExchangeConnector implements MarketDataFeed {
|
||||||
abstract readonly id: ExchangeId;
|
abstract readonly id: ExchangeId;
|
||||||
protected abstract readonly url: string;
|
protected abstract readonly url: string;
|
||||||
protected readonly depth: number;
|
protected readonly depth = 15;
|
||||||
|
|
||||||
protected ws: WebSocket | null = null;
|
protected ws: WebSocket | null = null;
|
||||||
protected book: LocalBook;
|
protected book: LocalBook;
|
||||||
@@ -29,10 +27,8 @@ export abstract class ExchangeConnector implements MarketDataFeed {
|
|||||||
protected closed = false;
|
protected closed = false;
|
||||||
private lastEmitTs = 0;
|
private lastEmitTs = 0;
|
||||||
|
|
||||||
/** `depth` must match the depth the connector subscribes with. */
|
constructor() {
|
||||||
constructor(depth = 15) {
|
this.book = new LocalBook(this.depth);
|
||||||
this.depth = depth;
|
|
||||||
this.book = new LocalBook(depth);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onBook(listener: BookListener): void {
|
onBook(listener: BookListener): void {
|
||||||
@@ -60,16 +56,12 @@ export abstract class ExchangeConnector implements MarketDataFeed {
|
|||||||
ws.on("open", () => {
|
ws.on("open", () => {
|
||||||
this.reconnectAttempts = 0;
|
this.reconnectAttempts = 0;
|
||||||
this.book.reset();
|
this.book.reset();
|
||||||
this.log.info(
|
this.log.info(this.skipSubscribe() ? "connected" : "connected, subscribing");
|
||||||
this.skipSubscribe() ? "connected" : "connected, subscribing",
|
|
||||||
);
|
|
||||||
if (!this.skipSubscribe()) {
|
if (!this.skipSubscribe()) {
|
||||||
try {
|
try {
|
||||||
ws.send(JSON.stringify(this.subscribeMessage()));
|
ws.send(JSON.stringify(this.subscribeMessage()));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.log.error("subscribe send failed", {
|
this.log.error("subscribe send failed", err);
|
||||||
error: err instanceof Error ? err.message : String(err),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.startPing();
|
this.startPing();
|
||||||
@@ -80,27 +72,15 @@ export abstract class ExchangeConnector implements MarketDataFeed {
|
|||||||
const text = data.toString();
|
const text = data.toString();
|
||||||
// Some exchanges (OKX) reply to app-level pings with a plain "pong" frame.
|
// Some exchanges (OKX) reply to app-level pings with a plain "pong" frame.
|
||||||
if (text === "pong" || text === "ping") return;
|
if (text === "pong" || text === "ping") return;
|
||||||
void withSpan(
|
|
||||||
"ws.message",
|
|
||||||
{ exchange: this.id, bytes: text.length },
|
|
||||||
async () => {
|
|
||||||
try {
|
try {
|
||||||
this.handleMessage(JSON.parse(text));
|
this.handleMessage(JSON.parse(text));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.log.warn("failed to parse message", {
|
this.log.warn("failed to parse message", err);
|
||||||
error: err instanceof Error ? err.message : String(err),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
ws.on("error", (err) => {
|
ws.on("error", (err) => {
|
||||||
const error = err instanceof Error ? err : new Error(String(err));
|
this.log.error("socket error", err instanceof Error ? err.message : err);
|
||||||
this.log.error("socket error", { error: error.message });
|
|
||||||
Sentry.captureException(error, {
|
|
||||||
tags: { exchange: this.id, component: "ws" },
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
ws.on("close", () => {
|
ws.on("close", () => {
|
||||||
@@ -117,13 +97,8 @@ export abstract class ExchangeConnector implements MarketDataFeed {
|
|||||||
|
|
||||||
private scheduleReconnect(): void {
|
private scheduleReconnect(): void {
|
||||||
this.reconnectAttempts += 1;
|
this.reconnectAttempts += 1;
|
||||||
const delay = Math.min(
|
const delay = Math.min(30_000, 500 * 2 ** Math.min(this.reconnectAttempts, 6));
|
||||||
30_000,
|
this.log.warn(`disconnected, reconnecting in ${delay}ms (attempt ${this.reconnectAttempts})`);
|
||||||
500 * 2 ** Math.min(this.reconnectAttempts, 6),
|
|
||||||
);
|
|
||||||
this.log.warn(
|
|
||||||
`disconnected, reconnecting in ${delay}ms (attempt ${this.reconnectAttempts})`,
|
|
||||||
);
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (!this.closed) this.connect();
|
if (!this.closed) this.connect();
|
||||||
}, delay);
|
}, delay);
|
||||||
@@ -167,31 +142,9 @@ export abstract class ExchangeConnector implements MarketDataFeed {
|
|||||||
exchangeTs,
|
exchangeTs,
|
||||||
};
|
};
|
||||||
if (book.bids.length === 0 || book.asks.length === 0) return;
|
if (book.bids.length === 0 || book.asks.length === 0) return;
|
||||||
|
|
||||||
// A crossed book (best bid >= best ask) is impossible on a spot venue:
|
|
||||||
// it means our local copy is corrupted (e.g. phantom levels). Never feed
|
|
||||||
// it to the engine — drop it and force a fresh snapshot via reconnect.
|
|
||||||
const bestBid = book.bids[0]!;
|
|
||||||
const bestAsk = book.asks[0]!;
|
|
||||||
if (bestBid.price >= bestAsk.price) {
|
|
||||||
this.log.warn("crossed local book detected, forcing resync", {
|
|
||||||
bid: bestBid.price,
|
|
||||||
ask: bestAsk.price,
|
|
||||||
});
|
|
||||||
this.resync();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const listener of this.listeners) listener(book);
|
for (const listener of this.listeners) listener(book);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Drop the corrupted local book and reconnect to receive a fresh snapshot. */
|
|
||||||
protected resync(): void {
|
|
||||||
this.book.reset();
|
|
||||||
// close() triggers the existing reconnect-with-backoff path (unless stopped).
|
|
||||||
this.ws?.close();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Combined-stream URLs (e.g. Binance) set this to skip the subscribe send. */
|
/** Combined-stream URLs (e.g. Binance) set this to skip the subscribe send. */
|
||||||
protected skipSubscribe(): boolean {
|
protected skipSubscribe(): boolean {
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -19,29 +19,20 @@ interface KrakenMessage {
|
|||||||
data?: KrakenBookData[];
|
data?: KrakenBookData[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const KRAKEN_BOOK_DEPTH = 10;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Kraken WebSocket v2 — `book` channel.
|
* Kraken WebSocket v2 — `book` channel.
|
||||||
* Docs: https://docs.kraken.com/websockets-v2/
|
* Docs: https://docs.kraken.com/websockets-v2/
|
||||||
* Snapshot replaces the book; updates patch individual price levels (qty 0 = remove).
|
* Snapshot replaces the book; updates patch individual price levels (qty 0 = remove).
|
||||||
* Kraken does NOT send deletes for levels evicted from the top-N window: the
|
|
||||||
* client must truncate its local book to the subscribed depth after every
|
|
||||||
* update, or evicted levels linger forever as phantom quotes.
|
|
||||||
*/
|
*/
|
||||||
export class KrakenConnector extends ExchangeConnector {
|
export class KrakenConnector extends ExchangeConnector {
|
||||||
readonly id: ExchangeId = "kraken";
|
readonly id: ExchangeId = "kraken";
|
||||||
protected readonly url = "wss://ws.kraken.com/v2";
|
protected readonly url = "wss://ws.kraken.com/v2";
|
||||||
private readonly symbol = "BTC/USDT";
|
private readonly symbol = "BTC/USDT";
|
||||||
|
|
||||||
constructor() {
|
|
||||||
super(KRAKEN_BOOK_DEPTH);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected subscribeMessage(): unknown {
|
protected subscribeMessage(): unknown {
|
||||||
return {
|
return {
|
||||||
method: "subscribe",
|
method: "subscribe",
|
||||||
params: { channel: "book", symbol: [this.symbol], depth: this.depth },
|
params: { channel: "book", symbol: [this.symbol], depth: 10 },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,7 +49,6 @@ export class KrakenConnector extends ExchangeConnector {
|
|||||||
|
|
||||||
for (const lvl of data.bids ?? []) this.book.bids.apply(lvl.price, lvl.qty);
|
for (const lvl of data.bids ?? []) this.book.bids.apply(lvl.price, lvl.qty);
|
||||||
for (const lvl of data.asks ?? []) this.book.asks.apply(lvl.price, lvl.qty);
|
for (const lvl of data.asks ?? []) this.book.asks.apply(lvl.price, lvl.qty);
|
||||||
this.book.truncate();
|
|
||||||
|
|
||||||
const exchangeTs = data.timestamp ? Date.parse(data.timestamp) : null;
|
const exchangeTs = data.timestamp ? Date.parse(data.timestamp) : null;
|
||||||
this.emit(Number.isFinite(exchangeTs) ? exchangeTs : null);
|
this.emit(Number.isFinite(exchangeTs) ? exchangeTs : null);
|
||||||
|
|||||||
@@ -1,82 +0,0 @@
|
|||||||
import { test } from "node:test";
|
|
||||||
import assert from "node:assert/strict";
|
|
||||||
import { BookSide, LocalBook } from "./local-book.js";
|
|
||||||
|
|
||||||
test("truncate removes worst bid levels beyond depth", () => {
|
|
||||||
const bids = new BookSide("bid", 3);
|
|
||||||
for (const price of [100, 101, 102]) bids.apply(price, 1);
|
|
||||||
// A better bid arrives; Kraken sends no delete for the evicted 100.
|
|
||||||
bids.apply(103, 1);
|
|
||||||
bids.truncate();
|
|
||||||
|
|
||||||
assert.equal(bids.size, 3);
|
|
||||||
assert.deepEqual(
|
|
||||||
bids.toArray().map((l) => l.price),
|
|
||||||
[103, 102, 101],
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("truncate removes worst ask levels beyond depth", () => {
|
|
||||||
const asks = new BookSide("ask", 3);
|
|
||||||
for (const price of [100, 101, 102]) asks.apply(price, 1);
|
|
||||||
asks.apply(99, 1);
|
|
||||||
asks.truncate();
|
|
||||||
|
|
||||||
assert.equal(asks.size, 3);
|
|
||||||
assert.deepEqual(
|
|
||||||
asks.toArray().map((l) => l.price),
|
|
||||||
[99, 100, 101],
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("truncate is a no-op when size <= depth", () => {
|
|
||||||
const bids = new BookSide("bid", 5);
|
|
||||||
bids.apply(100, 1);
|
|
||||||
bids.apply(101, 2);
|
|
||||||
bids.truncate();
|
|
||||||
|
|
||||||
assert.equal(bids.size, 2);
|
|
||||||
assert.deepEqual(
|
|
||||||
bids.toArray().map((l) => l.price),
|
|
||||||
[101, 100],
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("phantom high bid disappears across a rally-then-drop (Kraken window)", () => {
|
|
||||||
const depth = 3;
|
|
||||||
const book = new LocalBook(depth);
|
|
||||||
|
|
||||||
// Snapshot during a rally.
|
|
||||||
book.bids.apply(64920, 1);
|
|
||||||
book.bids.apply(64921, 1);
|
|
||||||
book.bids.apply(64922, 1);
|
|
||||||
book.truncate();
|
|
||||||
|
|
||||||
// Higher bids push the low ones out of Kraken's top-3 window. Kraken sends
|
|
||||||
// NO delete for the evicted 64920/64921 — only the client-side truncate
|
|
||||||
// removes them. Without it they linger as phantoms.
|
|
||||||
book.bids.apply(64924, 1);
|
|
||||||
book.bids.apply(64925.9, 0.15);
|
|
||||||
book.truncate();
|
|
||||||
|
|
||||||
// Market drops: in-window levels get explicit qty-0 deletes and lower bids
|
|
||||||
// enter the window.
|
|
||||||
book.bids.apply(64925.9, 0);
|
|
||||||
book.bids.apply(64924, 0);
|
|
||||||
book.bids.apply(64922, 0);
|
|
||||||
book.bids.apply(64564, 1);
|
|
||||||
book.bids.apply(64563, 1);
|
|
||||||
book.bids.apply(64562, 1);
|
|
||||||
book.truncate();
|
|
||||||
|
|
||||||
const top = book.bids.toArray();
|
|
||||||
assert.equal(top.length, depth);
|
|
||||||
assert.deepEqual(
|
|
||||||
top.map((l) => l.price),
|
|
||||||
[64564, 64563, 64562],
|
|
||||||
);
|
|
||||||
assert.ok(
|
|
||||||
top.every((l) => l.price < 64900),
|
|
||||||
"no phantom bid survives",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
@@ -8,10 +8,7 @@ import type { Level } from "../../domain/entities/index.js";
|
|||||||
export class BookSide {
|
export class BookSide {
|
||||||
private levels = new Map<number, number>();
|
private levels = new Map<number, number>();
|
||||||
|
|
||||||
constructor(
|
constructor(private readonly side: "bid" | "ask", private readonly depth: number) {}
|
||||||
private readonly side: "bid" | "ask",
|
|
||||||
private readonly depth: number,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
clear(): void {
|
clear(): void {
|
||||||
this.levels.clear();
|
this.levels.clear();
|
||||||
@@ -25,27 +22,11 @@ export class BookSide {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Remove levels beyond the best `depth` prices from the internal map.
|
|
||||||
* Required by delta feeds that do NOT send deletes for levels evicted from
|
|
||||||
* their top-N window (e.g. Kraken v2 `book`): without this, evicted levels
|
|
||||||
* linger forever as phantom quotes.
|
|
||||||
*/
|
|
||||||
truncate(): void {
|
|
||||||
if (this.levels.size <= this.depth) return;
|
|
||||||
const prices = [...this.levels.keys()].sort((a, b) =>
|
|
||||||
this.side === "bid" ? b - a : a - b,
|
|
||||||
);
|
|
||||||
for (const price of prices.slice(this.depth)) this.levels.delete(price);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Sorted (bids desc, asks asc) and capped to `depth` levels. */
|
/** Sorted (bids desc, asks asc) and capped to `depth` levels. */
|
||||||
toArray(): Level[] {
|
toArray(): Level[] {
|
||||||
const arr: Level[] = [];
|
const arr: Level[] = [];
|
||||||
for (const [price, qty] of this.levels) arr.push({ price, qty });
|
for (const [price, qty] of this.levels) arr.push({ price, qty });
|
||||||
arr.sort((a, b) =>
|
arr.sort((a, b) => (this.side === "bid" ? b.price - a.price : a.price - b.price));
|
||||||
this.side === "bid" ? b.price - a.price : a.price - b.price,
|
|
||||||
);
|
|
||||||
return arr.length > this.depth ? arr.slice(0, this.depth) : arr;
|
return arr.length > this.depth ? arr.slice(0, this.depth) : arr;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,10 +48,4 @@ export class LocalBook {
|
|||||||
this.bids.clear();
|
this.bids.clear();
|
||||||
this.asks.clear();
|
this.asks.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Truncate both sides to their depth (see BookSide.truncate). */
|
|
||||||
truncate(): void {
|
|
||||||
this.bids.truncate();
|
|
||||||
this.asks.truncate();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,39 +0,0 @@
|
|||||||
import { AsyncLocalStorage } from "node:async_hooks";
|
|
||||||
import { randomUUID } from "node:crypto";
|
|
||||||
|
|
||||||
export interface CorrelationContext {
|
|
||||||
correlationId: string;
|
|
||||||
exchange?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const storage = new AsyncLocalStorage<CorrelationContext>();
|
|
||||||
|
|
||||||
export function getCorrelationContext(): CorrelationContext | undefined {
|
|
||||||
return storage.getStore();
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getCorrelationId(): string | undefined {
|
|
||||||
return storage.getStore()?.correlationId;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function runWithCorrelation<T>(
|
|
||||||
context: CorrelationContext,
|
|
||||||
fn: () => T,
|
|
||||||
): T {
|
|
||||||
return storage.run(context, fn);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function newCorrelationId(prefix = "tick"): string {
|
|
||||||
return `${prefix}_${randomUUID().slice(0, 8)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function bindRequestCorrelation(
|
|
||||||
headerValue: string | string[] | undefined,
|
|
||||||
): CorrelationContext {
|
|
||||||
const raw = Array.isArray(headerValue) ? headerValue[0] : headerValue;
|
|
||||||
const correlationId =
|
|
||||||
typeof raw === "string" && raw.trim()
|
|
||||||
? raw.trim()
|
|
||||||
: newCorrelationId("req");
|
|
||||||
return { correlationId };
|
|
||||||
}
|
|
||||||
@@ -1,38 +1,22 @@
|
|||||||
import pino from "pino";
|
type Level = "info" | "warn" | "error";
|
||||||
import { getCorrelationContext } from "./correlation.js";
|
|
||||||
|
|
||||||
const root = pino({
|
function emit(level: Level, scope: string, msg: string, extra?: unknown): void {
|
||||||
level: process.env.LOG_LEVEL ?? "info",
|
const ts = new Date().toISOString();
|
||||||
base: { service: "arbpulse" },
|
const line = `${ts} [${level.toUpperCase()}] (${scope}) ${msg}`;
|
||||||
timestamp: pino.stdTimeFunctions.isoTime,
|
if (level === "error") {
|
||||||
...(process.env.NODE_ENV !== "production"
|
console.error(line, extra ?? "");
|
||||||
? {
|
} else if (level === "warn") {
|
||||||
transport: {
|
console.warn(line, extra ?? "");
|
||||||
target: "pino-pretty",
|
} else {
|
||||||
options: { colorize: true, singleLine: true },
|
console.log(line, extra ?? "");
|
||||||
},
|
}
|
||||||
}
|
}
|
||||||
: {}),
|
|
||||||
});
|
|
||||||
|
|
||||||
export function createLogger(scope: string) {
|
export function createLogger(scope: string) {
|
||||||
const child = root.child({ scope });
|
|
||||||
return {
|
return {
|
||||||
info: (msg: string, extra?: Record<string, unknown>) =>
|
info: (msg: string, extra?: unknown) => emit("info", scope, msg, extra),
|
||||||
child.info({ ...bindings(), ...extra }, msg),
|
warn: (msg: string, extra?: unknown) => emit("warn", scope, msg, extra),
|
||||||
warn: (msg: string, extra?: Record<string, unknown>) =>
|
error: (msg: string, extra?: unknown) => emit("error", scope, msg, extra),
|
||||||
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 } : {}),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
import { initOpenTelemetry } from "./otel.js";
|
|
||||||
import { initSentry } from "./sentry.js";
|
|
||||||
|
|
||||||
export function initInstrumentation(): void {
|
|
||||||
initSentry();
|
|
||||||
initOpenTelemetry();
|
|
||||||
}
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
import * as Sentry from "@sentry/node";
|
|
||||||
import { trace, type Span, type Tracer } from "@opentelemetry/api";
|
|
||||||
import { NodeTracerProvider } from "@opentelemetry/sdk-trace-node";
|
|
||||||
import { SentrySpanProcessor, SentryPropagator } from "@sentry/opentelemetry";
|
|
||||||
import { isTracingEnabled } from "./tracing.js";
|
|
||||||
|
|
||||||
let tracer: Tracer | null = null;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* OpenTelemetry spans export to Sentry via @sentry/opentelemetry only when
|
|
||||||
* SENTRY_DSN is set AND SENTRY_TRACING is enabled. Otherwise spans are no-ops
|
|
||||||
* (default; safe for 24/7 operation, local dev, and tests). Error capture in
|
|
||||||
* `withSpan` is preserved regardless.
|
|
||||||
*/
|
|
||||||
export function initOpenTelemetry(): Tracer {
|
|
||||||
if (tracer) return tracer;
|
|
||||||
|
|
||||||
if (!process.env.SENTRY_DSN?.trim() || !isTracingEnabled()) {
|
|
||||||
tracer = trace.getTracer("arbpulse");
|
|
||||||
return tracer;
|
|
||||||
}
|
|
||||||
|
|
||||||
const provider = new NodeTracerProvider({
|
|
||||||
spanProcessors: [new SentrySpanProcessor()],
|
|
||||||
});
|
|
||||||
provider.register({
|
|
||||||
propagator: new SentryPropagator(),
|
|
||||||
});
|
|
||||||
|
|
||||||
tracer = provider.getTracer("arbpulse");
|
|
||||||
return tracer;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getTracer(): Tracer {
|
|
||||||
return tracer ?? initOpenTelemetry();
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function withSpan<T>(
|
|
||||||
name: string,
|
|
||||||
attributes: Record<string, string | number | boolean>,
|
|
||||||
fn: (span: Span) => T | Promise<T>,
|
|
||||||
): Promise<T> {
|
|
||||||
const activeTracer = getTracer();
|
|
||||||
return activeTracer.startActiveSpan(name, { attributes }, async (span) => {
|
|
||||||
try {
|
|
||||||
return await fn(span);
|
|
||||||
} catch (err) {
|
|
||||||
span.setStatus({ code: 2, message: String(err) });
|
|
||||||
Sentry.captureException(err);
|
|
||||||
throw err;
|
|
||||||
} finally {
|
|
||||||
span.end();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
import * as Sentry from "@sentry/node";
|
|
||||||
import { isTracingEnabled } from "./tracing.js";
|
|
||||||
|
|
||||||
const dsn = process.env.SENTRY_DSN?.trim();
|
|
||||||
|
|
||||||
export function initSentry(): void {
|
|
||||||
if (!dsn) return;
|
|
||||||
|
|
||||||
Sentry.init({
|
|
||||||
dsn,
|
|
||||||
environment: process.env.NODE_ENV ?? "development",
|
|
||||||
integrations: [Sentry.httpIntegration(), Sentry.expressIntegration()],
|
|
||||||
// Tracing/spans are gated by SENTRY_TRACING (default off) to stay within
|
|
||||||
// the Sentry free-tier span quota during 24/7 operation. Error monitoring
|
|
||||||
// is always on.
|
|
||||||
...(isTracingEnabled()
|
|
||||||
? { tracesSampleRate: process.env.NODE_ENV === "production" ? 0.1 : 1 }
|
|
||||||
: {}),
|
|
||||||
});
|
|
||||||
|
|
||||||
process.on("unhandledRejection", (reason) => {
|
|
||||||
Sentry.captureException(
|
|
||||||
reason instanceof Error ? reason : new Error(String(reason)),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
process.on("uncaughtException", (error) => {
|
|
||||||
Sentry.captureException(error);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export { Sentry };
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
/**
|
|
||||||
* Single source of truth for the tracing gate. OpenTelemetry spans are only
|
|
||||||
* created and exported to Sentry when `SENTRY_TRACING` is truthy (and a
|
|
||||||
* `SENTRY_DSN` is set). Defaults to disabled so 24/7 operation stays off the
|
|
||||||
* Sentry free-tier span quota. Error monitoring is unaffected by this flag.
|
|
||||||
*/
|
|
||||||
export function isTracingEnabled(): boolean {
|
|
||||||
const raw = process.env.SENTRY_TRACING;
|
|
||||||
if (raw === undefined) return false;
|
|
||||||
return raw === "1" || raw.toLowerCase() === "true";
|
|
||||||
}
|
|
||||||
+706
-257
File diff suppressed because it is too large
Load Diff
@@ -1,60 +0,0 @@
|
|||||||
import assert from "node:assert/strict";
|
|
||||||
import { randomUUID } from "node:crypto";
|
|
||||||
import type { AddressInfo } from "node:net";
|
|
||||||
import { test } from "node:test";
|
|
||||||
import express, { Router } from "express";
|
|
||||||
import { isUpstashConfigured } from "../../infrastructure/cache/upstash.js";
|
|
||||||
import { loadEnvLocal } from "../../test-support/load-env-local.js";
|
|
||||||
import { createRateLimitMiddleware } from "./rate-limit.js";
|
|
||||||
|
|
||||||
loadEnvLocal();
|
|
||||||
|
|
||||||
const READ_LIMIT_PER_MIN = 60;
|
|
||||||
|
|
||||||
test(
|
|
||||||
"read tier returns 429 with Retry-After after limit",
|
|
||||||
{ skip: !isUpstashConfigured() },
|
|
||||||
async () => {
|
|
||||||
const app = express();
|
|
||||||
const router = Router();
|
|
||||||
router.use(createRateLimitMiddleware());
|
|
||||||
router.get("/state", (_req, res) => {
|
|
||||||
res.json({ success: true, data: { ok: true } });
|
|
||||||
});
|
|
||||||
app.use("/api", router);
|
|
||||||
|
|
||||||
const server = await new Promise<ReturnType<typeof app.listen>>(
|
|
||||||
(resolve) => {
|
|
||||||
const s = app.listen(0, "127.0.0.1", () => resolve(s));
|
|
||||||
},
|
|
||||||
);
|
|
||||||
const port = (server.address() as AddressInfo).port;
|
|
||||||
const url = `http://127.0.0.1:${port}/api/state`;
|
|
||||||
const clientIp = `rate-limit-qa-${randomUUID()}`;
|
|
||||||
|
|
||||||
try {
|
|
||||||
for (let i = 0; i < READ_LIMIT_PER_MIN; i++) {
|
|
||||||
const res = await fetch(url, {
|
|
||||||
headers: { "x-forwarded-for": clientIp },
|
|
||||||
});
|
|
||||||
assert.equal(res.status, 200, `request ${i + 1} should pass`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const blocked = await fetch(url, {
|
|
||||||
headers: { "x-forwarded-for": clientIp },
|
|
||||||
});
|
|
||||||
assert.equal(blocked.status, 429);
|
|
||||||
assert.ok(blocked.headers.get("retry-after"));
|
|
||||||
const body = (await blocked.json()) as { error?: string };
|
|
||||||
assert.equal(body.error, "Too many requests");
|
|
||||||
} finally {
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
|
||||||
server.close((err) => (err ? reject(err) : resolve()));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
test("reports whether Upstash REST credentials are set", () => {
|
|
||||||
assert.equal(typeof isUpstashConfigured(), "boolean");
|
|
||||||
});
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
import { Ratelimit } from "@upstash/ratelimit";
|
|
||||||
import { Redis } from "@upstash/redis";
|
|
||||||
import type { NextFunction, Request, Response } from "express";
|
|
||||||
import { isUpstashConfigured } from "../../infrastructure/cache/upstash.js";
|
|
||||||
|
|
||||||
type RouteTier = "read" | "stream" | "write" | "health";
|
|
||||||
|
|
||||||
const LIMITS: Record<
|
|
||||||
Exclude<RouteTier, "health">,
|
|
||||||
{ requests: number; window: `${number} s` | `${number} m` }
|
|
||||||
> = {
|
|
||||||
read: { requests: 60, window: "1 m" },
|
|
||||||
stream: { requests: 10, window: "1 m" },
|
|
||||||
write: { requests: 30, window: "1 m" },
|
|
||||||
};
|
|
||||||
|
|
||||||
const limiters = new Map<Exclude<RouteTier, "health">, Ratelimit>();
|
|
||||||
|
|
||||||
function getLimiter(tier: Exclude<RouteTier, "health">): Ratelimit | null {
|
|
||||||
if (!isUpstashConfigured()) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
let limiter = limiters.get(tier);
|
|
||||||
if (limiter) {
|
|
||||||
return limiter;
|
|
||||||
}
|
|
||||||
const url = process.env.UPSTASH_REDIS_REST_URL!.trim();
|
|
||||||
const token = process.env.UPSTASH_REDIS_REST_TOKEN!.trim();
|
|
||||||
const redis = new Redis({ url, token });
|
|
||||||
const cfg = LIMITS[tier];
|
|
||||||
limiter = new Ratelimit({
|
|
||||||
redis,
|
|
||||||
limiter: Ratelimit.slidingWindow(cfg.requests, cfg.window),
|
|
||||||
prefix: `arbpulse:${tier}`,
|
|
||||||
});
|
|
||||||
limiters.set(tier, limiter);
|
|
||||||
return limiter;
|
|
||||||
}
|
|
||||||
|
|
||||||
function clientKey(req: Request): string {
|
|
||||||
const forwarded = req.header("x-forwarded-for");
|
|
||||||
if (forwarded) {
|
|
||||||
return forwarded.split(",")[0]?.trim() || "unknown";
|
|
||||||
}
|
|
||||||
return req.ip || req.socket.remoteAddress || "unknown";
|
|
||||||
}
|
|
||||||
|
|
||||||
function tierForPath(path: string, method: string): RouteTier {
|
|
||||||
if (path === "/health") return "health";
|
|
||||||
if (path === "/stream") return "stream";
|
|
||||||
if (method === "GET" || method === "HEAD") return "read";
|
|
||||||
return "write";
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createRateLimitMiddleware() {
|
|
||||||
return async (
|
|
||||||
req: Request,
|
|
||||||
res: Response,
|
|
||||||
next: NextFunction,
|
|
||||||
): Promise<void> => {
|
|
||||||
const tier = tierForPath(req.path, req.method);
|
|
||||||
if (tier === "health") {
|
|
||||||
next();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const limiter = getLimiter(tier);
|
|
||||||
if (!limiter) {
|
|
||||||
next();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { success, reset } = await limiter.limit(`${tier}:${clientKey(req)}`);
|
|
||||||
if (success) {
|
|
||||||
next();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const retryAfterSec = Math.max(1, Math.ceil((reset - Date.now()) / 1000));
|
|
||||||
res.setHeader("Retry-After", String(retryAfterSec));
|
|
||||||
res.status(429).json({
|
|
||||||
success: false,
|
|
||||||
error: "Too many requests",
|
|
||||||
});
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,64 +1,16 @@
|
|||||||
import {
|
import { Router, type Request, type Response } from "express";
|
||||||
Router,
|
|
||||||
type NextFunction,
|
|
||||||
type Request,
|
|
||||||
type Response,
|
|
||||||
} from "express";
|
|
||||||
import { Sentry } from "../../instrumentation/sentry.js";
|
|
||||||
import type { ApplicationService } from "../../composition/application-service.js";
|
import type { ApplicationService } from "../../composition/application-service.js";
|
||||||
import {
|
|
||||||
bindRequestCorrelation,
|
|
||||||
runWithCorrelation,
|
|
||||||
} from "../../infrastructure/logging/correlation.js";
|
|
||||||
import {
|
|
||||||
getCachedSnapshot,
|
|
||||||
setCachedSnapshot,
|
|
||||||
} from "../../infrastructure/cache/upstash.js";
|
|
||||||
import type { SseHub } from "../sse/sse.js";
|
import type { SseHub } from "../sse/sse.js";
|
||||||
import { createRateLimitMiddleware } from "./rate-limit.js";
|
|
||||||
import {
|
|
||||||
ConfigPatchSchema,
|
|
||||||
DemoControlBodySchema,
|
|
||||||
MaxTradeControlBodySchema,
|
|
||||||
RecordControlBodySchema,
|
|
||||||
ThresholdControlBodySchema,
|
|
||||||
} from "./schemas/requests.js";
|
|
||||||
import { parseBody } from "./validate.js";
|
|
||||||
|
|
||||||
export function createRouter(app: ApplicationService, sse: SseHub): Router {
|
export function createRouter(app: ApplicationService, sse: SseHub): Router {
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
router.use((req: Request, res: Response, next: NextFunction) => {
|
|
||||||
const ctx = bindRequestCorrelation(req.header("x-request-id"));
|
|
||||||
res.setHeader("x-request-id", ctx.correlationId);
|
|
||||||
runWithCorrelation(ctx, () => next());
|
|
||||||
});
|
|
||||||
|
|
||||||
router.use(createRateLimitMiddleware());
|
|
||||||
|
|
||||||
router.get("/health", (_req: Request, res: Response) => {
|
router.get("/health", (_req: Request, res: Response) => {
|
||||||
res.json({ success: true, data: { status: "ok", ts: Date.now() } });
|
res.json({ success: true, data: { status: "ok", ts: Date.now() } });
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get("/debug/sentry", (_req: Request, res: Response) => {
|
router.get("/state", (_req: Request, res: Response) => {
|
||||||
if (process.env.NODE_ENV === "production") {
|
res.json({ success: true, data: app.getSnapshot() });
|
||||||
res.status(404).json({ error: "Not found" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
Sentry.captureException(new Error("Arb Pulse Sentry test error"));
|
|
||||||
res.json({ ok: true, message: "Test error sent to Sentry" });
|
|
||||||
});
|
|
||||||
|
|
||||||
router.get("/state", async (_req: Request, res: Response) => {
|
|
||||||
const cached =
|
|
||||||
await getCachedSnapshot<ReturnType<ApplicationService["getSnapshot"]>>();
|
|
||||||
if (cached) {
|
|
||||||
res.json({ success: true, data: cached });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const snapshot = app.getSnapshot();
|
|
||||||
void setCachedSnapshot(snapshot);
|
|
||||||
res.json({ success: true, data: snapshot });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get("/stream", (req: Request, res: Response) => {
|
router.get("/stream", (req: Request, res: Response) => {
|
||||||
@@ -70,10 +22,7 @@ export function createRouter(app: ApplicationService, sse: SseHub): Router {
|
|||||||
});
|
});
|
||||||
|
|
||||||
router.patch("/config", (req: Request, res: Response) => {
|
router.patch("/config", (req: Request, res: Response) => {
|
||||||
const patch = parseBody(ConfigPatchSchema, req, res);
|
const error = app.patchConfig(req.body ?? {});
|
||||||
if (patch === null) return;
|
|
||||||
|
|
||||||
const error = app.patchConfig(patch);
|
|
||||||
if (error) {
|
if (error) {
|
||||||
res.status(400).json({ success: false, error });
|
res.status(400).json({ success: false, error });
|
||||||
return;
|
return;
|
||||||
@@ -97,43 +46,51 @@ export function createRouter(app: ApplicationService, sse: SseHub): Router {
|
|||||||
});
|
});
|
||||||
|
|
||||||
router.post("/control/demo", (req: Request, res: Response) => {
|
router.post("/control/demo", (req: Request, res: Response) => {
|
||||||
const body = parseBody(DemoControlBodySchema, req, res);
|
const enabled = req.body?.enabled;
|
||||||
if (body === null) return;
|
if (typeof enabled !== "boolean") {
|
||||||
|
res.status(400).json({ success: false, error: "enabled must be a boolean" });
|
||||||
app.setDemoMode(body.enabled);
|
return;
|
||||||
res.json({ success: true, data: { demoMode: body.enabled } });
|
}
|
||||||
|
app.setDemoMode(enabled);
|
||||||
|
res.json({ success: true, data: { demoMode: enabled } });
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post("/control/record", (req: Request, res: Response) => {
|
router.post("/control/record", (req: Request, res: Response) => {
|
||||||
const body = parseBody(RecordControlBodySchema, req, res);
|
const enabled = req.body?.enabled;
|
||||||
if (body === null) return;
|
if (typeof enabled !== "boolean") {
|
||||||
|
res.status(400).json({ success: false, error: "enabled must be a boolean" });
|
||||||
app.setRecordFeed(body.enabled);
|
return;
|
||||||
res.json({ success: true, data: { recordFeed: body.enabled } });
|
}
|
||||||
|
app.setRecordFeed(enabled);
|
||||||
|
res.json({ success: true, data: { recordFeed: enabled } });
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post("/control/threshold", (req: Request, res: Response) => {
|
router.post("/control/threshold", (req: Request, res: Response) => {
|
||||||
const body = parseBody(ThresholdControlBodySchema, req, res);
|
const pct = req.body?.pct;
|
||||||
if (body === null) return;
|
if (typeof pct !== "number" || !Number.isFinite(pct)) {
|
||||||
|
res.status(400).json({ success: false, error: "pct must be a finite number" });
|
||||||
const error = app.setThreshold(body.pct);
|
return;
|
||||||
|
}
|
||||||
|
const error = app.setThreshold(pct);
|
||||||
if (error) {
|
if (error) {
|
||||||
res.status(400).json({ success: false, error });
|
res.status(400).json({ success: false, error });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
res.json({ success: true, data: { minNetProfitPct: body.pct } });
|
res.json({ success: true, data: { minNetProfitPct: pct } });
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post("/control/max-trade", (req: Request, res: Response) => {
|
router.post("/control/max-trade", (req: Request, res: Response) => {
|
||||||
const body = parseBody(MaxTradeControlBodySchema, req, res);
|
const btc = req.body?.btc;
|
||||||
if (body === null) return;
|
if (typeof btc !== "number" || !Number.isFinite(btc)) {
|
||||||
|
res.status(400).json({ success: false, error: "btc must be a finite number" });
|
||||||
const error = app.setMaxTradeBtc(body.btc);
|
return;
|
||||||
|
}
|
||||||
|
const error = app.setMaxTradeBtc(btc);
|
||||||
if (error) {
|
if (error) {
|
||||||
res.status(400).json({ success: false, error });
|
res.status(400).json({ success: false, error });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
res.json({ success: true, data: { maxTradeBtc: body.btc } });
|
res.json({ success: true, data: { maxTradeBtc: btc } });
|
||||||
});
|
});
|
||||||
|
|
||||||
return router;
|
return router;
|
||||||
|
|||||||
@@ -1,59 +0,0 @@
|
|||||||
import { extendZodWithOpenApi } from "@asteasolutions/zod-to-openapi";
|
|
||||||
import { z } from "zod";
|
|
||||||
|
|
||||||
extendZodWithOpenApi(z);
|
|
||||||
|
|
||||||
export { z };
|
|
||||||
|
|
||||||
export const ExchangeIdSchema = z
|
|
||||||
.enum(["kraken", "bybit", "okx", "binance"])
|
|
||||||
.openapi("ExchangeId");
|
|
||||||
|
|
||||||
export const FeedStatusSchema = z
|
|
||||||
.enum(["connecting", "live", "stale", "down"])
|
|
||||||
.openapi("FeedStatus");
|
|
||||||
|
|
||||||
export const CircuitStateSchema = z
|
|
||||||
.enum(["running", "paused", "tripped"])
|
|
||||||
.openapi("CircuitState");
|
|
||||||
|
|
||||||
export const OpportunityStatusSchema = z
|
|
||||||
.enum([
|
|
||||||
"executed",
|
|
||||||
"executed_partial",
|
|
||||||
"rejected_fees",
|
|
||||||
"rejected_liquidity",
|
|
||||||
"rejected_risk",
|
|
||||||
"rejected_flicker",
|
|
||||||
"rejected_stale",
|
|
||||||
"pending_confirm",
|
|
||||||
])
|
|
||||||
.openapi("OpportunityStatus");
|
|
||||||
|
|
||||||
export const ErrorEnvelopeSchema = z
|
|
||||||
.object({
|
|
||||||
success: z.literal(false),
|
|
||||||
error: z.string(),
|
|
||||||
})
|
|
||||||
.strict()
|
|
||||||
.openapi("ErrorEnvelope");
|
|
||||||
|
|
||||||
export const SuccessEnvelopeNoDataSchema = z
|
|
||||||
.object({
|
|
||||||
success: z.literal(true),
|
|
||||||
})
|
|
||||||
.strict()
|
|
||||||
.openapi("SuccessEnvelopeNoData");
|
|
||||||
|
|
||||||
export function successEnvelope<T extends z.ZodType>(
|
|
||||||
dataSchema: T,
|
|
||||||
name: string,
|
|
||||||
) {
|
|
||||||
return z
|
|
||||||
.object({
|
|
||||||
success: z.literal(true),
|
|
||||||
data: dataSchema,
|
|
||||||
})
|
|
||||||
.strict()
|
|
||||||
.openapi(name);
|
|
||||||
}
|
|
||||||
@@ -1,161 +0,0 @@
|
|||||||
import {
|
|
||||||
CircuitStateSchema,
|
|
||||||
ExchangeIdSchema,
|
|
||||||
FeedStatusSchema,
|
|
||||||
OpportunityStatusSchema,
|
|
||||||
z,
|
|
||||||
} from "./common.js";
|
|
||||||
|
|
||||||
export const BestQuoteSchema = z
|
|
||||||
.object({
|
|
||||||
exchange: ExchangeIdSchema,
|
|
||||||
bid: z.number().nullable(),
|
|
||||||
bidQty: z.number().nullable(),
|
|
||||||
ask: z.number().nullable(),
|
|
||||||
askQty: z.number().nullable(),
|
|
||||||
recvTs: z.number().int().nullable(),
|
|
||||||
status: FeedStatusSchema,
|
|
||||||
ageMs: z.number().int().nullable(),
|
|
||||||
})
|
|
||||||
.strict()
|
|
||||||
.openapi("BestQuote");
|
|
||||||
|
|
||||||
export const WalletSchema = z
|
|
||||||
.object({
|
|
||||||
exchange: ExchangeIdSchema,
|
|
||||||
usdt: z.number(),
|
|
||||||
btc: z.number(),
|
|
||||||
})
|
|
||||||
.strict()
|
|
||||||
.openapi("Wallet");
|
|
||||||
|
|
||||||
export const OpportunitySchema = z
|
|
||||||
.object({
|
|
||||||
id: z.string(),
|
|
||||||
ts: z.number().int(),
|
|
||||||
buyExchange: ExchangeIdSchema,
|
|
||||||
sellExchange: ExchangeIdSchema,
|
|
||||||
topBuyAsk: z.number(),
|
|
||||||
topSellBid: z.number(),
|
|
||||||
volumeBtc: z.number(),
|
|
||||||
buyVwap: z.number(),
|
|
||||||
sellVwap: z.number(),
|
|
||||||
grossSpread: z.number(),
|
|
||||||
grossSpreadPct: z.number(),
|
|
||||||
feeBuy: z.number(),
|
|
||||||
feeSell: z.number(),
|
|
||||||
netProfit: z.number(),
|
|
||||||
netProfitPct: z.number(),
|
|
||||||
status: OpportunityStatusSchema,
|
|
||||||
reason: z.string(),
|
|
||||||
demo: z.boolean(),
|
|
||||||
})
|
|
||||||
.strict()
|
|
||||||
.openapi("Opportunity");
|
|
||||||
|
|
||||||
export const TradeSchema = z
|
|
||||||
.object({
|
|
||||||
id: z.string(),
|
|
||||||
ts: z.number().int(),
|
|
||||||
buyExchange: ExchangeIdSchema,
|
|
||||||
sellExchange: ExchangeIdSchema,
|
|
||||||
volumeBtc: z.number(),
|
|
||||||
requestedBtc: z.number(),
|
|
||||||
buyVwap: z.number(),
|
|
||||||
sellVwap: z.number(),
|
|
||||||
execBuyVwap: z.number(),
|
|
||||||
execSellVwap: z.number(),
|
|
||||||
feeBuy: z.number(),
|
|
||||||
feeSell: z.number(),
|
|
||||||
netProfit: z.number(),
|
|
||||||
netProfitPct: z.number(),
|
|
||||||
partial: z.boolean(),
|
|
||||||
demo: z.boolean(),
|
|
||||||
})
|
|
||||||
.strict()
|
|
||||||
.openapi("Trade");
|
|
||||||
|
|
||||||
export const RebalanceEventSchema = z
|
|
||||||
.object({
|
|
||||||
id: z.string(),
|
|
||||||
ts: z.number().int(),
|
|
||||||
fromExchange: ExchangeIdSchema,
|
|
||||||
toExchange: ExchangeIdSchema,
|
|
||||||
asset: z.enum(["BTC", "USDT"]),
|
|
||||||
amount: z.number(),
|
|
||||||
withdrawalFee: z.number(),
|
|
||||||
reason: z.string(),
|
|
||||||
})
|
|
||||||
.strict()
|
|
||||||
.openapi("RebalanceEvent");
|
|
||||||
|
|
||||||
export const PnlPointSchema = z
|
|
||||||
.object({
|
|
||||||
ts: z.number().int(),
|
|
||||||
pnl: z.number(),
|
|
||||||
})
|
|
||||||
.strict()
|
|
||||||
.openapi("PnlPoint");
|
|
||||||
|
|
||||||
export const EngineStatsSchema = z
|
|
||||||
.object({
|
|
||||||
uptimeMs: z.number().int(),
|
|
||||||
ticksProcessed: z.number().int(),
|
|
||||||
opportunitiesDetected: z.number().int(),
|
|
||||||
tradesExecuted: z.number().int(),
|
|
||||||
tradesRejected: z.number().int(),
|
|
||||||
realizedPnl: z.number(),
|
|
||||||
consecutiveLosses: z.number().int(),
|
|
||||||
circuit: CircuitStateSchema,
|
|
||||||
demoMode: z.boolean(),
|
|
||||||
avgTickMs: z.number(),
|
|
||||||
})
|
|
||||||
.strict()
|
|
||||||
.openapi("EngineStats");
|
|
||||||
|
|
||||||
const ActiveExchangesSchema = z.record(ExchangeIdSchema, z.boolean());
|
|
||||||
|
|
||||||
export const PublicConfigSchema = z
|
|
||||||
.object({
|
|
||||||
minNetProfitPct: z.number(),
|
|
||||||
maxTradeBtc: z.number(),
|
|
||||||
staleMs: z.number().int(),
|
|
||||||
flickerConfirmMs: z.number().int(),
|
|
||||||
latencyMs: z.number().int(),
|
|
||||||
activeExchanges: ActiveExchangesSchema,
|
|
||||||
defaults: z
|
|
||||||
.object({
|
|
||||||
minNetProfitPct: z.number(),
|
|
||||||
maxTradeBtc: z.number(),
|
|
||||||
flickerConfirmMs: z.number().int(),
|
|
||||||
activeExchanges: ActiveExchangesSchema,
|
|
||||||
})
|
|
||||||
.strict(),
|
|
||||||
takerFees: z.record(ExchangeIdSchema, z.number()),
|
|
||||||
withdrawalFeesBtc: z.record(ExchangeIdSchema, z.number()),
|
|
||||||
})
|
|
||||||
.strict()
|
|
||||||
.openapi("PublicConfig");
|
|
||||||
|
|
||||||
export const StateSnapshotSchema = z
|
|
||||||
.object({
|
|
||||||
ts: z.number().int(),
|
|
||||||
quotes: z.array(BestQuoteSchema),
|
|
||||||
wallets: z.array(WalletSchema),
|
|
||||||
stats: EngineStatsSchema,
|
|
||||||
recentOpportunities: z.array(OpportunitySchema),
|
|
||||||
recentTrades: z.array(TradeSchema),
|
|
||||||
rebalances: z.array(RebalanceEventSchema),
|
|
||||||
pnlSeries: z.array(PnlPointSchema),
|
|
||||||
config: PublicConfigSchema,
|
|
||||||
})
|
|
||||||
.strict()
|
|
||||||
.openapi("StateSnapshot");
|
|
||||||
|
|
||||||
export const HealthDataSchema = z
|
|
||||||
.object({
|
|
||||||
status: z.literal("ok"),
|
|
||||||
ts: z.number().int(),
|
|
||||||
})
|
|
||||||
.strict()
|
|
||||||
.openapi("HealthData");
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
import { ExchangeIdSchema, z } from "./common.js";
|
|
||||||
|
|
||||||
const MIN_PROFIT_PCT = 0.0001;
|
|
||||||
const MAX_PROFIT_PCT = 0.01;
|
|
||||||
const MIN_TRADE_BTC = 0.01;
|
|
||||||
const MAX_TRADE_BTC = 1.0;
|
|
||||||
const MAX_FLICKER_MS = 500;
|
|
||||||
|
|
||||||
export const BooleanControlBodySchema = z
|
|
||||||
.object({
|
|
||||||
enabled: z.boolean(),
|
|
||||||
})
|
|
||||||
.strict()
|
|
||||||
.openapi("BooleanControlBody");
|
|
||||||
|
|
||||||
export const DemoControlBodySchema =
|
|
||||||
BooleanControlBodySchema.openapi("DemoControlBody");
|
|
||||||
export const RecordControlBodySchema =
|
|
||||||
BooleanControlBodySchema.openapi("RecordControlBody");
|
|
||||||
|
|
||||||
export const ThresholdControlBodySchema = z
|
|
||||||
.object({
|
|
||||||
pct: z.number().finite().min(MIN_PROFIT_PCT).max(MAX_PROFIT_PCT),
|
|
||||||
})
|
|
||||||
.strict()
|
|
||||||
.openapi("ThresholdControlBody");
|
|
||||||
|
|
||||||
export const MaxTradeControlBodySchema = z
|
|
||||||
.object({
|
|
||||||
btc: z.number().finite().min(MIN_TRADE_BTC).max(MAX_TRADE_BTC),
|
|
||||||
})
|
|
||||||
.strict()
|
|
||||||
.openapi("MaxTradeControlBody");
|
|
||||||
|
|
||||||
export const ConfigPatchSchema = z
|
|
||||||
.object({
|
|
||||||
minNetProfitPct: z
|
|
||||||
.number()
|
|
||||||
.finite()
|
|
||||||
.min(MIN_PROFIT_PCT)
|
|
||||||
.max(MAX_PROFIT_PCT)
|
|
||||||
.optional(),
|
|
||||||
maxTradeBtc: z
|
|
||||||
.number()
|
|
||||||
.finite()
|
|
||||||
.min(MIN_TRADE_BTC)
|
|
||||||
.max(MAX_TRADE_BTC)
|
|
||||||
.optional(),
|
|
||||||
flickerConfirmMs: z.number().int().min(0).max(MAX_FLICKER_MS).optional(),
|
|
||||||
activeExchanges: z.record(ExchangeIdSchema, z.boolean()).optional(),
|
|
||||||
})
|
|
||||||
.strict()
|
|
||||||
.openapi("ConfigPatch");
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
import { successEnvelope, SuccessEnvelopeNoDataSchema } from "./common.js";
|
|
||||||
import {
|
|
||||||
HealthDataSchema,
|
|
||||||
PublicConfigSchema,
|
|
||||||
StateSnapshotSchema,
|
|
||||||
} from "./domain.js";
|
|
||||||
import { z } from "./common.js";
|
|
||||||
|
|
||||||
export const HealthResponseSchema = successEnvelope(
|
|
||||||
HealthDataSchema,
|
|
||||||
"HealthResponse",
|
|
||||||
);
|
|
||||||
export const StateResponseSchema = successEnvelope(
|
|
||||||
StateSnapshotSchema,
|
|
||||||
"StateResponse",
|
|
||||||
);
|
|
||||||
export const ConfigResponseSchema = successEnvelope(
|
|
||||||
PublicConfigSchema,
|
|
||||||
"ConfigResponse",
|
|
||||||
);
|
|
||||||
|
|
||||||
export const DemoModeResponseSchema = successEnvelope(
|
|
||||||
z.object({ demoMode: z.boolean() }).strict(),
|
|
||||||
"DemoModeResponse",
|
|
||||||
);
|
|
||||||
|
|
||||||
export const RecordFeedResponseSchema = successEnvelope(
|
|
||||||
z.object({ recordFeed: z.boolean() }).strict(),
|
|
||||||
"RecordFeedResponse",
|
|
||||||
);
|
|
||||||
|
|
||||||
export const ThresholdResponseSchema = successEnvelope(
|
|
||||||
z.object({ minNetProfitPct: z.number() }).strict(),
|
|
||||||
"ThresholdResponse",
|
|
||||||
);
|
|
||||||
|
|
||||||
export const MaxTradeResponseSchema = successEnvelope(
|
|
||||||
z.object({ maxTradeBtc: z.number() }).strict(),
|
|
||||||
"MaxTradeResponse",
|
|
||||||
);
|
|
||||||
|
|
||||||
export { SuccessEnvelopeNoDataSchema };
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
import type { Request, Response } from "express";
|
|
||||||
import type { ZodType } from "zod";
|
|
||||||
|
|
||||||
export function parseBody<T>(
|
|
||||||
schema: ZodType<T>,
|
|
||||||
req: Request,
|
|
||||||
res: Response,
|
|
||||||
): T | null {
|
|
||||||
const result = schema.safeParse(req.body ?? {});
|
|
||||||
if (!result.success) {
|
|
||||||
const message = result.error.issues
|
|
||||||
.map((issue) => issue.message)
|
|
||||||
.join("; ");
|
|
||||||
res.status(400).json({ success: false, error: message });
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return result.data;
|
|
||||||
}
|
|
||||||
@@ -1,9 +1,6 @@
|
|||||||
import type { Request, Response } from "express";
|
import type { Request, Response } from "express";
|
||||||
import * as Sentry from "@sentry/node";
|
|
||||||
import { config } from "../../infrastructure/config/config.js";
|
import { config } from "../../infrastructure/config/config.js";
|
||||||
import { setCachedSnapshot } from "../../infrastructure/cache/upstash.js";
|
|
||||||
import { createLogger } from "../../infrastructure/logging/logger.js";
|
import { createLogger } from "../../infrastructure/logging/logger.js";
|
||||||
import { withSpan } from "../../instrumentation/otel.js";
|
|
||||||
import type { ApplicationService } from "../../composition/application-service.js";
|
import type { ApplicationService } from "../../composition/application-service.js";
|
||||||
|
|
||||||
const log = createLogger("sse");
|
const log = createLogger("sse");
|
||||||
@@ -25,7 +22,6 @@ export class SseHub {
|
|||||||
}
|
}
|
||||||
|
|
||||||
handle(req: Request, res: Response): void {
|
handle(req: Request, res: Response): void {
|
||||||
try {
|
|
||||||
res.writeHead(200, {
|
res.writeHead(200, {
|
||||||
"Content-Type": "text/event-stream",
|
"Content-Type": "text/event-stream",
|
||||||
"Cache-Control": "no-cache, no-transform",
|
"Cache-Control": "no-cache, no-transform",
|
||||||
@@ -35,48 +31,17 @@ export class SseHub {
|
|||||||
res.write(`retry: 2000\n\n`);
|
res.write(`retry: 2000\n\n`);
|
||||||
this.send(res, this.app.getSnapshot());
|
this.send(res, this.app.getSnapshot());
|
||||||
this.clients.add(res);
|
this.clients.add(res);
|
||||||
log.info(`client connected`, { clientCount: this.clients.size });
|
log.info(`client connected (${this.clients.size} total)`);
|
||||||
|
|
||||||
req.on("close", () => {
|
req.on("close", () => {
|
||||||
this.clients.delete(res);
|
this.clients.delete(res);
|
||||||
});
|
});
|
||||||
|
|
||||||
res.on("error", (err) => {
|
|
||||||
const error = err instanceof Error ? err : new Error(String(err));
|
|
||||||
log.error("sse client error", { error: error.message });
|
|
||||||
Sentry.captureException(error, { tags: { component: "sse" } });
|
|
||||||
this.clients.delete(res);
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
const error = err instanceof Error ? err : new Error(String(err));
|
|
||||||
log.error("sse handle failed", { error: error.message });
|
|
||||||
Sentry.captureException(error, { tags: { component: "sse" } });
|
|
||||||
if (!res.headersSent) {
|
|
||||||
res.status(500).json({ success: false, error: "SSE setup failed" });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private broadcast(): void {
|
private broadcast(): void {
|
||||||
if (this.clients.size === 0) return;
|
if (this.clients.size === 0) return;
|
||||||
void withSpan(
|
|
||||||
"sse.broadcast",
|
|
||||||
{ clientCount: this.clients.size },
|
|
||||||
async () => {
|
|
||||||
const snapshot = this.app.getSnapshot();
|
const snapshot = this.app.getSnapshot();
|
||||||
void setCachedSnapshot(snapshot);
|
for (const res of this.clients) this.send(res, 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 {
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
import { existsSync } from "node:fs";
|
|
||||||
import { loadEnvFile } from "node:process";
|
|
||||||
|
|
||||||
for (const file of [".env.local", ".env"]) {
|
|
||||||
if (existsSync(file)) {
|
|
||||||
loadEnvFile(file);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
import { existsSync, readFileSync } from "node:fs";
|
|
||||||
import { join } from "node:path";
|
|
||||||
|
|
||||||
/** Loads `.env.local` without overriding variables already set in the shell. */
|
|
||||||
export function loadEnvLocal(): void {
|
|
||||||
const file = join(process.cwd(), ".env.local");
|
|
||||||
if (!existsSync(file)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const line of readFileSync(file, "utf8").split(/\r?\n/)) {
|
|
||||||
const trimmed = line.trim();
|
|
||||||
if (!trimmed || trimmed.startsWith("#")) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const eq = trimmed.indexOf("=");
|
|
||||||
if (eq <= 0) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const key = trimmed.slice(0, eq).trim();
|
|
||||||
let value = trimmed.slice(eq + 1).trim();
|
|
||||||
if (
|
|
||||||
(value.startsWith('"') && value.endsWith('"')) ||
|
|
||||||
(value.startsWith("'") && value.endsWith("'"))
|
|
||||||
) {
|
|
||||||
value = value.slice(1, -1);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (process.env[key] === undefined) {
|
|
||||||
process.env[key] = value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+1
-8
@@ -2,16 +2,9 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Arb Pulse — BTC Arbitrage Engine</title>
|
<title>Arb Pulse — BTC Arbitrage Engine</title>
|
||||||
<meta name="description" content="Real-time cross-exchange BTC arbitrage detection & simulation" />
|
<meta name="description" content="Real-time cross-exchange BTC arbitrage detection & simulation" />
|
||||||
<meta name="theme-color" content="#0a0e14" />
|
|
||||||
<link rel="manifest" href="/manifest.webmanifest" />
|
|
||||||
<link rel="icon" type="image/png" sizes="192x192" href="/icon-192.png" />
|
|
||||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
|
||||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
|
||||||
<meta name="apple-mobile-web-app-title" content="Arb Pulse" />
|
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
<link
|
<link
|
||||||
|
|||||||
Generated
+5
-22
@@ -27,35 +27,18 @@
|
|||||||
"name": "arb-pulse",
|
"name": "arb-pulse",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@asteasolutions/zod-to-openapi": "^8.5.0",
|
|
||||||
"@opentelemetry/api": "^1.9.1",
|
|
||||||
"@opentelemetry/sdk-trace-node": "^2.7.1",
|
|
||||||
"@scalar/express-api-reference": "^0.8.48",
|
|
||||||
"@sentry/node": "^10.57.0",
|
|
||||||
"@sentry/opentelemetry": "^10.57.0",
|
|
||||||
"@upstash/ratelimit": "^2.0.6",
|
|
||||||
"@upstash/redis": "^1.35.3",
|
|
||||||
"express": "^4.21.2",
|
"express": "^4.21.2",
|
||||||
"pino": "^10.3.1",
|
"swagger-ui-express": "^5.0.1",
|
||||||
"pino-pretty": "^13.1.3",
|
|
||||||
"tsx": "^4.19.2",
|
"tsx": "^4.19.2",
|
||||||
"ws": "^8.18.0",
|
"ws": "^8.18.0"
|
||||||
"zod": "^4.4.3"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.18.0",
|
|
||||||
"@playwright/test": "^1.51.1",
|
|
||||||
"@types/express": "^4.17.21",
|
"@types/express": "^4.17.21",
|
||||||
"@types/node": "^22.10.5",
|
"@types/node": "^22.10.5",
|
||||||
|
"@types/swagger-ui-express": "^4.1.8",
|
||||||
"@types/ws": "^8.5.13",
|
"@types/ws": "^8.5.13",
|
||||||
"eslint": "^9.18.0",
|
"openapi-types": "^12.1.3",
|
||||||
"eslint-plugin-react-hooks": "^5.1.0",
|
"typescript": "^5.7.3"
|
||||||
"globals": "^15.14.0",
|
|
||||||
"husky": "^9.1.7",
|
|
||||||
"lint-staged": "^16.4.0",
|
|
||||||
"prettier": "^3.8.3",
|
|
||||||
"typescript": "^5.7.3",
|
|
||||||
"typescript-eslint": "^8.26.0"
|
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20"
|
"node": ">=20"
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 28 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 33 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 254 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 254 KiB |
@@ -1,31 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "Arb Pulse — BTC Arbitrage Engine",
|
|
||||||
"short_name": "Arb Pulse",
|
|
||||||
"description": "Real-time cross-exchange BTC arbitrage detection & simulation (Kraken, Bybit, OKX, Binance).",
|
|
||||||
"start_url": "/",
|
|
||||||
"scope": "/",
|
|
||||||
"display": "standalone",
|
|
||||||
"orientation": "any",
|
|
||||||
"theme_color": "#0a0e14",
|
|
||||||
"background_color": "#0a0e14",
|
|
||||||
"icons": [
|
|
||||||
{
|
|
||||||
"src": "/icon-192.png",
|
|
||||||
"sizes": "192x192",
|
|
||||||
"type": "image/png",
|
|
||||||
"purpose": "any"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"src": "/icon-512.png",
|
|
||||||
"sizes": "512x512",
|
|
||||||
"type": "image/png",
|
|
||||||
"purpose": "any"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"src": "/icon-maskable-512.png",
|
|
||||||
"sizes": "512x512",
|
|
||||||
"type": "image/png",
|
|
||||||
"purpose": "maskable"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
+5
-13
@@ -19,16 +19,13 @@ export function App(): JSX.Element {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-[1400px] px-4 pb-6 pt-[max(1.5rem,env(safe-area-inset-top))] lg:px-8">
|
<div className="mx-auto max-w-[1400px] px-4 py-6 lg:px-8">
|
||||||
<header className="mb-6 flex items-end justify-between gap-3">
|
<header className="mb-6 flex items-end justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="font-display text-2xl font-extrabold tracking-tight text-slate-100">
|
<h1 className="font-display text-2xl font-extrabold tracking-tight text-slate-100">
|
||||||
Arb<span className="text-accent">Pulse</span>
|
Arb<span className="text-accent">Pulse</span>
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-sm text-slate-500">
|
<p className="text-sm text-slate-500">Real-time cross-exchange BTC arbitrage engine · Kraken · Bybit · OKX · Binance</p>
|
||||||
Real-time cross-exchange BTC arbitrage engine · Kraken · Bybit · OKX
|
|
||||||
· Binance
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<p className="hidden text-xs text-slate-600 sm:block">
|
<p className="hidden text-xs text-slate-600 sm:block">
|
||||||
BTC/USDT · WebSocket feeds · inventory model
|
BTC/USDT · WebSocket feeds · inventory model
|
||||||
@@ -36,9 +33,7 @@ export function App(): JSX.Element {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
{!snapshot ? (
|
{!snapshot ? (
|
||||||
<div className="flex h-[60vh] items-center justify-center text-slate-500">
|
<div className="flex h-[60vh] items-center justify-center text-slate-500">Connecting to engine…</div>
|
||||||
Connecting to engine…
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
<StatsBar stats={snapshot.stats} connected={connected} />
|
<StatsBar stats={snapshot.stats} connected={connected} />
|
||||||
@@ -52,10 +47,7 @@ export function App(): JSX.Element {
|
|||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
<Controls stats={snapshot.stats} config={snapshot.config} />
|
<Controls stats={snapshot.stats} config={snapshot.config} />
|
||||||
<ConfigPanel config={snapshot.config} />
|
<ConfigPanel config={snapshot.config} />
|
||||||
<Wallets
|
<Wallets wallets={snapshot.wallets} rebalances={snapshot.rebalances} />
|
||||||
wallets={snapshot.wallets}
|
|
||||||
rebalances={snapshot.rebalances}
|
|
||||||
/>
|
|
||||||
<OpportunityFeed opportunities={snapshot.recentOpportunities} />
|
<OpportunityFeed opportunities={snapshot.recentOpportunities} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -37,21 +37,12 @@ function Toggle({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => onChange(!on)}
|
onClick={() => onChange(!on)}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"flex min-h-[44px] items-center justify-between gap-3 rounded-xl border px-3 py-2 text-left text-sm transition",
|
"flex items-center justify-between gap-3 rounded-xl border px-3 py-2 text-left text-sm transition",
|
||||||
modified
|
modified ? "border-accent/50 bg-accent/5" : "border-ink-600/50 bg-ink-700/30 hover:border-ink-500",
|
||||||
? "border-accent/50 bg-accent/5"
|
|
||||||
: "border-ink-600/50 bg-ink-700/30 hover:border-ink-500",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span className={clsx(modified ? "text-accent" : "text-slate-300")}>
|
|
||||||
{label}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
className={clsx(
|
|
||||||
"relative h-5 w-9 rounded-full transition",
|
|
||||||
on ? "bg-accent" : "bg-ink-500",
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
<span className={clsx(modified ? "text-accent" : "text-slate-300")}>{label}</span>
|
||||||
|
<span className={clsx("relative h-5 w-9 rounded-full transition", on ? "bg-accent" : "bg-ink-500")}>
|
||||||
<span
|
<span
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"absolute top-0.5 h-4 w-4 rounded-full bg-white transition-all",
|
"absolute top-0.5 h-4 w-4 rounded-full bg-white transition-all",
|
||||||
@@ -86,15 +77,11 @@ function SliderRow({
|
|||||||
<div
|
<div
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"rounded-xl border px-3 py-2",
|
"rounded-xl border px-3 py-2",
|
||||||
modified
|
modified ? "border-accent/50 bg-accent/5" : "border-ink-600/50 bg-ink-700/30",
|
||||||
? "border-accent/50 bg-accent/5"
|
|
||||||
: "border-ink-600/50 bg-ink-700/30",
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="mb-1 flex items-center justify-between text-sm">
|
<div className="mb-1 flex items-center justify-between text-sm">
|
||||||
<span className={clsx(modified ? "text-accent" : "text-slate-300")}>
|
<span className={clsx(modified ? "text-accent" : "text-slate-300")}>{label}</span>
|
||||||
{label}
|
|
||||||
</span>
|
|
||||||
<span className="font-mono text-accent">{valueLabel}</span>
|
<span className="font-mono text-accent">{valueLabel}</span>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
@@ -192,9 +179,7 @@ export function ConfigPanel({ config }: Props): JSX.Element {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<p className="text-xs font-medium uppercase tracking-wider text-slate-500">
|
<p className="text-xs font-medium uppercase tracking-wider text-slate-500">Active exchanges</p>
|
||||||
Active exchanges
|
|
||||||
</p>
|
|
||||||
{EXCHANGES.map(({ id, label }) => (
|
{EXCHANGES.map(({ id, label }) => (
|
||||||
<Toggle
|
<Toggle
|
||||||
key={id}
|
key={id}
|
||||||
|
|||||||
@@ -9,28 +9,15 @@ interface Props {
|
|||||||
config: PublicConfig;
|
config: PublicConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
function Toggle({
|
function Toggle({ label, on, onChange }: { label: string; on: boolean; onChange: (v: boolean) => void }): JSX.Element {
|
||||||
label,
|
|
||||||
on,
|
|
||||||
onChange,
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
on: boolean;
|
|
||||||
onChange: (v: boolean) => void;
|
|
||||||
}): JSX.Element {
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onChange(!on)}
|
onClick={() => onChange(!on)}
|
||||||
className="flex min-h-[44px] items-center justify-between gap-3 rounded-xl border border-ink-600/50 bg-ink-700/30 px-3 py-2 text-left text-sm transition hover:border-ink-500"
|
className="flex items-center justify-between gap-3 rounded-xl border border-ink-600/50 bg-ink-700/30 px-3 py-2 text-left text-sm transition hover:border-ink-500"
|
||||||
>
|
>
|
||||||
<span className="text-slate-300">{label}</span>
|
<span className="text-slate-300">{label}</span>
|
||||||
<span
|
<span className={clsx("relative h-5 w-9 rounded-full transition", on ? "bg-accent" : "bg-ink-500")}>
|
||||||
className={clsx(
|
|
||||||
"relative h-5 w-9 rounded-full transition",
|
|
||||||
on ? "bg-accent" : "bg-ink-500",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span
|
<span
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"absolute top-0.5 h-4 w-4 rounded-full bg-white transition-all",
|
"absolute top-0.5 h-4 w-4 rounded-full bg-white transition-all",
|
||||||
@@ -54,7 +41,7 @@ export function Controls({ stats, config }: Props): JSX.Element {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => control.resume()}
|
onClick={() => control.resume()}
|
||||||
className="min-h-[44px] rounded-xl border border-profit/40 bg-profit/10 px-3 py-2 text-sm font-semibold text-profit transition hover:bg-profit/20"
|
className="rounded-xl border border-profit/40 bg-profit/10 px-3 py-2 text-sm font-semibold text-profit transition hover:bg-profit/20"
|
||||||
>
|
>
|
||||||
Resume
|
Resume
|
||||||
</button>
|
</button>
|
||||||
@@ -62,7 +49,7 @@ export function Controls({ stats, config }: Props): JSX.Element {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => control.pause()}
|
onClick={() => control.pause()}
|
||||||
className="min-h-[44px] rounded-xl border border-warn/40 bg-warn/10 px-3 py-2 text-sm font-semibold text-warn transition hover:bg-warn/20"
|
className="rounded-xl border border-warn/40 bg-warn/10 px-3 py-2 text-sm font-semibold text-warn transition hover:bg-warn/20"
|
||||||
>
|
>
|
||||||
Pause
|
Pause
|
||||||
</button>
|
</button>
|
||||||
@@ -70,17 +57,13 @@ export function Controls({ stats, config }: Props): JSX.Element {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => control.reset()}
|
onClick={() => control.reset()}
|
||||||
className="min-h-[44px] rounded-xl border border-loss/40 bg-loss/10 px-3 py-2 text-sm font-semibold text-loss transition hover:bg-loss/20"
|
className="rounded-xl border border-loss/40 bg-loss/10 px-3 py-2 text-sm font-semibold text-loss transition hover:bg-loss/20"
|
||||||
>
|
>
|
||||||
Reset
|
Reset
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Toggle
|
<Toggle label="Demo mode (synthetic feed)" on={stats.demoMode} onChange={(v) => control.setDemo(v)} />
|
||||||
label="Demo mode (synthetic feed)"
|
|
||||||
on={stats.demoMode}
|
|
||||||
onChange={(v) => control.setDemo(v)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="rounded-xl border border-ink-600/50 bg-ink-700/30 px-3 py-2">
|
<div className="rounded-xl border border-ink-600/50 bg-ink-700/30 px-3 py-2">
|
||||||
<div className="mb-1 flex items-center justify-between text-sm">
|
<div className="mb-1 flex items-center justify-between text-sm">
|
||||||
@@ -106,10 +89,7 @@ export function Controls({ stats, config }: Props): JSX.Element {
|
|||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span>Taker fees</span>
|
<span>Taker fees</span>
|
||||||
<span className="font-mono">
|
<span className="font-mono">
|
||||||
K {pct(config.takerFees.kraken, 2)} · By{" "}
|
K {pct(config.takerFees.kraken, 2)} · By {pct(config.takerFees.bybit, 2)} · O {pct(config.takerFees.okx, 2)} · Bn {pct(config.takerFees.binance, 2)}
|
||||||
{pct(config.takerFees.bybit, 2)} · O{" "}
|
|
||||||
{pct(config.takerFees.okx, 2)} · Bn{" "}
|
|
||||||
{pct(config.takerFees.binance, 2)}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 flex justify-between">
|
<div className="mt-1 flex justify-between">
|
||||||
|
|||||||
@@ -50,83 +50,7 @@ export function PriceMatrix({ quotes }: Props): JSX.Element {
|
|||||||
) : undefined
|
) : undefined
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{/* Mobile: per-venue stacked cards (no horizontal overflow). */}
|
<div className="overflow-hidden rounded-xl border border-ink-600/50">
|
||||||
<div className="grid grid-cols-1 gap-3 sm:hidden">
|
|
||||||
{quotes.map((q) => {
|
|
||||||
const spread =
|
|
||||||
q.ask !== null && q.bid !== null ? q.ask - q.bid : null;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={q.exchange}
|
|
||||||
className="rounded-xl border border-ink-600/50 bg-ink-700/30 p-3"
|
|
||||||
>
|
|
||||||
<div className="mb-2 flex items-center gap-2 font-display text-sm">
|
|
||||||
<span
|
|
||||||
className={clsx("h-2 w-2 rounded-full", statusDot(q.status))}
|
|
||||||
/>
|
|
||||||
{LABELS[q.exchange]}
|
|
||||||
</div>
|
|
||||||
<dl className="grid grid-cols-2 gap-x-4 gap-y-1.5 font-mono text-sm tabular-nums">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<dt className="text-xs uppercase tracking-wider text-slate-500">
|
|
||||||
Bid
|
|
||||||
</dt>
|
|
||||||
<dd
|
|
||||||
className={clsx(
|
|
||||||
q.bid !== null && q.bid === maxBid
|
|
||||||
? "font-semibold text-profit"
|
|
||||||
: "text-slate-300",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{q.bid !== null ? usd(q.bid) : "—"}
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<dt className="text-xs uppercase tracking-wider text-slate-500">
|
|
||||||
Bid Qty
|
|
||||||
</dt>
|
|
||||||
<dd className="text-slate-500">
|
|
||||||
{q.bidQty?.toFixed(3) ?? "—"}
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<dt className="text-xs uppercase tracking-wider text-slate-500">
|
|
||||||
Ask
|
|
||||||
</dt>
|
|
||||||
<dd
|
|
||||||
className={clsx(
|
|
||||||
q.ask !== null && q.ask === minAsk
|
|
||||||
? "font-semibold text-accent"
|
|
||||||
: "text-slate-300",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{q.ask !== null ? usd(q.ask) : "—"}
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<dt className="text-xs uppercase tracking-wider text-slate-500">
|
|
||||||
Ask Qty
|
|
||||||
</dt>
|
|
||||||
<dd className="text-slate-500">
|
|
||||||
{q.askQty?.toFixed(3) ?? "—"}
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<dt className="text-xs uppercase tracking-wider text-slate-500">
|
|
||||||
Spread
|
|
||||||
</dt>
|
|
||||||
<dd className="text-slate-400">
|
|
||||||
{spread !== null ? usd(spread) : "—"}
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
</dl>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Desktop / tablet: full table from the sm breakpoint up. */}
|
|
||||||
<div className="hidden overflow-hidden rounded-xl border border-ink-600/50 sm:block">
|
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="bg-ink-700/50 text-left text-xs uppercase tracking-wider text-slate-400">
|
<tr className="bg-ink-700/50 text-left text-xs uppercase tracking-wider text-slate-400">
|
||||||
@@ -140,50 +64,34 @@ export function PriceMatrix({ quotes }: Props): JSX.Element {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody className="font-mono tabular-nums">
|
<tbody className="font-mono tabular-nums">
|
||||||
{quotes.map((q) => {
|
{quotes.map((q) => {
|
||||||
const spread =
|
const spread = q.ask !== null && q.bid !== null ? q.ask - q.bid : null;
|
||||||
q.ask !== null && q.bid !== null ? q.ask - q.bid : null;
|
|
||||||
return (
|
return (
|
||||||
<tr key={q.exchange} className="border-t border-ink-600/40">
|
<tr key={q.exchange} className="border-t border-ink-600/40">
|
||||||
<td className="px-4 py-2.5">
|
<td className="px-4 py-2.5">
|
||||||
<span className="flex items-center gap-2 font-display text-sm">
|
<span className="flex items-center gap-2 font-display text-sm">
|
||||||
<span
|
<span className={clsx("h-2 w-2 rounded-full", statusDot(q.status))} />
|
||||||
className={clsx(
|
|
||||||
"h-2 w-2 rounded-full",
|
|
||||||
statusDot(q.status),
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
{LABELS[q.exchange]}
|
{LABELS[q.exchange]}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td
|
<td
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"px-4 py-2.5 text-right",
|
"px-4 py-2.5 text-right",
|
||||||
q.bid !== null && q.bid === maxBid
|
q.bid !== null && q.bid === maxBid ? "font-semibold text-profit" : "text-slate-300",
|
||||||
? "font-semibold text-profit"
|
|
||||||
: "text-slate-300",
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{q.bid !== null ? usd(q.bid) : "—"}
|
{q.bid !== null ? usd(q.bid) : "—"}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-2.5 text-right text-slate-500">
|
<td className="px-4 py-2.5 text-right text-slate-500">{q.bidQty?.toFixed(3) ?? "—"}</td>
|
||||||
{q.bidQty?.toFixed(3) ?? "—"}
|
|
||||||
</td>
|
|
||||||
<td
|
<td
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"px-4 py-2.5 text-right",
|
"px-4 py-2.5 text-right",
|
||||||
q.ask !== null && q.ask === minAsk
|
q.ask !== null && q.ask === minAsk ? "font-semibold text-accent" : "text-slate-300",
|
||||||
? "font-semibold text-accent"
|
|
||||||
: "text-slate-300",
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{q.ask !== null ? usd(q.ask) : "—"}
|
{q.ask !== null ? usd(q.ask) : "—"}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-2.5 text-right text-slate-500">
|
<td className="px-4 py-2.5 text-right text-slate-500">{q.askQty?.toFixed(3) ?? "—"}</td>
|
||||||
{q.askQty?.toFixed(3) ?? "—"}
|
<td className="px-4 py-2.5 text-right text-slate-400">{spread !== null ? usd(spread) : "—"}</td>
|
||||||
</td>
|
|
||||||
<td className="px-4 py-2.5 text-right text-slate-400">
|
|
||||||
{spread !== null ? usd(spread) : "—"}
|
|
||||||
</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -6,20 +6,10 @@ interface Props {
|
|||||||
connected: boolean;
|
connected: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function Stat({
|
function Stat({ label, value, tone }: { label: string; value: string; tone?: "profit" | "loss" | "warn" }): JSX.Element {
|
||||||
label,
|
|
||||||
value,
|
|
||||||
tone,
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
value: string;
|
|
||||||
tone?: "profit" | "loss" | "warn";
|
|
||||||
}): JSX.Element {
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span className="text-[10px] uppercase tracking-widest text-slate-500">
|
<span className="text-[10px] uppercase tracking-widest text-slate-500">{label}</span>
|
||||||
{label}
|
|
||||||
</span>
|
|
||||||
<span
|
<span
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"font-mono text-lg font-semibold tabular-nums",
|
"font-mono text-lg font-semibold tabular-nums",
|
||||||
@@ -35,23 +25,14 @@ function Stat({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function circuitTone(c: EngineStats["circuit"]): {
|
function circuitTone(c: EngineStats["circuit"]): { label: string; cls: string } {
|
||||||
label: string;
|
|
||||||
cls: string;
|
|
||||||
} {
|
|
||||||
switch (c) {
|
switch (c) {
|
||||||
case "running":
|
case "running":
|
||||||
return {
|
return { label: "RUNNING", cls: "bg-profit/15 text-profit border-profit/40" };
|
||||||
label: "RUNNING",
|
|
||||||
cls: "bg-profit/15 text-profit border-profit/40",
|
|
||||||
};
|
|
||||||
case "paused":
|
case "paused":
|
||||||
return { label: "PAUSED", cls: "bg-warn/15 text-warn border-warn/40" };
|
return { label: "PAUSED", cls: "bg-warn/15 text-warn border-warn/40" };
|
||||||
case "tripped":
|
case "tripped":
|
||||||
return {
|
return { label: "BREAKER TRIPPED", cls: "bg-loss/15 text-loss border-loss/40" };
|
||||||
label: "BREAKER TRIPPED",
|
|
||||||
cls: "bg-loss/15 text-loss border-loss/40",
|
|
||||||
};
|
|
||||||
default: {
|
default: {
|
||||||
const _exhaustive: never = c;
|
const _exhaustive: never = c;
|
||||||
return { label: _exhaustive, cls: "" };
|
return { label: _exhaustive, cls: "" };
|
||||||
@@ -64,45 +45,29 @@ export function StatsBar({ stats, connected }: Props): JSX.Element {
|
|||||||
const pnlTone = stats.realizedPnl >= 0 ? "profit" : "loss";
|
const pnlTone = stats.realizedPnl >= 0 ? "profit" : "loss";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-wrap items-center gap-x-6 gap-y-4 rounded-2xl border border-ink-600/60 bg-ink-800/60 px-4 py-4 backdrop-blur-sm sm:gap-x-8 sm:px-5">
|
<div className="flex flex-wrap items-center gap-x-8 gap-y-4 rounded-2xl border border-ink-600/60 bg-ink-800/60 px-5 py-4 backdrop-blur-sm">
|
||||||
<Stat
|
<Stat label="Realized P&L" value={`$${usd(stats.realizedPnl)}`} tone={pnlTone} />
|
||||||
label="Realized P&L"
|
|
||||||
value={`$${usd(stats.realizedPnl)}`}
|
|
||||||
tone={pnlTone}
|
|
||||||
/>
|
|
||||||
<Stat label="Trades" value={String(stats.tradesExecuted)} />
|
<Stat label="Trades" value={String(stats.tradesExecuted)} />
|
||||||
<Stat label="Opportunities" value={String(stats.opportunitiesDetected)} />
|
<Stat label="Opportunities" value={String(stats.opportunitiesDetected)} />
|
||||||
<Stat label="Rejected" value={String(stats.tradesRejected)} tone="warn" />
|
<Stat label="Rejected" value={String(stats.tradesRejected)} tone="warn" />
|
||||||
<Stat label="Ticks" value={stats.ticksProcessed.toLocaleString()} />
|
<Stat label="Ticks" value={stats.ticksProcessed.toLocaleString()} />
|
||||||
<Stat label="Engine /tick" value={`${stats.avgTickMs.toFixed(3)}ms`} />
|
<Stat label="Engine /tick" value={`${stats.avgTickMs.toFixed(3)}ms`} />
|
||||||
<div className="flex w-full flex-wrap items-center gap-2 sm:ml-auto sm:w-auto sm:gap-3">
|
<div className="ml-auto flex items-center gap-3">
|
||||||
{stats.demoMode && (
|
{stats.demoMode && (
|
||||||
<span className="rounded-full border border-accent/40 bg-accent/15 px-3 py-1 text-xs font-semibold uppercase tracking-wider text-accent">
|
<span className="rounded-full border border-accent/40 bg-accent/15 px-3 py-1 text-xs font-semibold uppercase tracking-wider text-accent">
|
||||||
Demo / Simulated Feed
|
Demo / Simulated Feed
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<span
|
<span className={clsx("rounded-full border px-3 py-1 text-xs font-semibold tracking-wider", circuit.cls)}>
|
||||||
className={clsx(
|
|
||||||
"rounded-full border px-3 py-1 text-xs font-semibold tracking-wider",
|
|
||||||
circuit.cls,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{circuit.label}
|
{circuit.label}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"flex items-center gap-2 rounded-full border px-3 py-1 text-xs font-semibold tracking-wider",
|
"flex items-center gap-2 rounded-full border px-3 py-1 text-xs font-semibold tracking-wider",
|
||||||
connected
|
connected ? "border-profit/40 bg-profit/10 text-profit" : "border-loss/40 bg-loss/10 text-loss",
|
||||||
? "border-profit/40 bg-profit/10 text-profit"
|
|
||||||
: "border-loss/40 bg-loss/10 text-loss",
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span
|
<span className={clsx("h-2 w-2 rounded-full", connected ? "bg-profit" : "bg-loss")} />
|
||||||
className={clsx(
|
|
||||||
"h-2 w-2 rounded-full",
|
|
||||||
connected ? "bg-profit" : "bg-loss",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
{connected ? "LIVE" : "OFFLINE"}
|
{connected ? "LIVE" : "OFFLINE"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user