Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b7e5396690 | |||
| 5f8da3487f | |||
| 71c45dca9a | |||
| 2ca2e25b23 | |||
| bbe4188119 | |||
| 407005b507 | |||
| f6108d8f8f | |||
| bf27435e30 | |||
| 4ffce9a59e | |||
| 14ea7e3fbd | |||
| 5d84df3e25 | |||
| 4cdfac5ff5 | |||
| 4e8b113b4e | |||
| 27d259a881 | |||
| d7e04a99dd | |||
| 94072b668c | |||
| 80413a3af4 | |||
| 948e87beee | |||
| 3c7c3593ba | |||
| 70b4aa4e92 |
@@ -0,0 +1,95 @@
|
||||
# Production deploy: build the image in CI, publish to GHCR, then SSH into the
|
||||
# Hetzner VPS to pull + restart. The VPS never builds — it only pulls the image
|
||||
# that passed CI. Rollback = re-run this workflow from an older commit, or
|
||||
# `docker compose pull` a previous sha-tag on the VPS.
|
||||
#
|
||||
# Secrets: VPS_SSH_KEY (dedicated deploy key), VPS_HOST, VPS_USER.
|
||||
|
||||
name: VPS Deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: vps-deploy
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
IMAGE: ghcr.io/${{ github.repository }}
|
||||
|
||||
jobs:
|
||||
build-push:
|
||||
name: Build & push to GHCR
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
|
||||
- uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- uses: docker/metadata-action@v5
|
||||
id: meta
|
||||
with:
|
||||
images: ${{ env.IMAGE }}
|
||||
tags: |
|
||||
type=raw,value=latest
|
||||
type=sha,format=long
|
||||
|
||||
- uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
deploy:
|
||||
name: Deploy to VPS
|
||||
needs: build-push
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Install SSH key
|
||||
uses: webfactory/ssh-agent@v0.9.1
|
||||
with:
|
||||
ssh-private-key: ${{ secrets.VPS_SSH_KEY }}
|
||||
|
||||
- name: Trust VPS host key
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
ssh-keyscan -H "${{ secrets.VPS_HOST }}" >> ~/.ssh/known_hosts
|
||||
|
||||
- name: Pull image and restart app
|
||||
run: |
|
||||
ssh "${{ secrets.VPS_USER }}@${{ secrets.VPS_HOST }}" bash -s <<'EOF'
|
||||
set -euo pipefail
|
||||
cd /root/projects/arbpulse
|
||||
git fetch origin main
|
||||
git checkout main
|
||||
git reset --hard origin/main
|
||||
cd deploy
|
||||
docker compose pull app
|
||||
docker compose up -d app
|
||||
echo "waiting for health..."
|
||||
for i in $(seq 1 12); do
|
||||
if curl -fsS http://127.0.0.1:8080/api/health >/dev/null 2>&1; then
|
||||
echo "app healthy"
|
||||
docker image prune -f >/dev/null
|
||||
exit 0
|
||||
fi
|
||||
sleep 5
|
||||
done
|
||||
echo "app did not become healthy in time" >&2
|
||||
docker compose logs --tail 50 app >&2
|
||||
exit 1
|
||||
EOF
|
||||
@@ -41,7 +41,7 @@ Capturas completas (scroll): [`dashboard-live.png`](docs/screenshots/dashboard-l
|
||||
| Feeds | WebSocket nativo (`ws`) — APIs públicas |
|
||||
| Frontend | React 18, Vite, Tailwind CSS v3 |
|
||||
| Estado | In-memory (sin base de datos) |
|
||||
| Deploy | [Fly.io](https://fly.io) — Docker multi-stage (`Dockerfile` + `fly.toml`) |
|
||||
| Deploy | VPS Hetzner — Docker Compose + GHCR (CI/CD en `vps-deploy.yml`); Fly.io como alternativa manual |
|
||||
|
||||
No se requieren API keys: los feeds de mercado son públicos.
|
||||
|
||||
@@ -164,6 +164,7 @@ Consecuencia esperada en feed **real**: la mayoría de divergencias brutas salen
|
||||
### Robustez en el hot path
|
||||
|
||||
- **One trade per tick** — si varios pares confirman en el mismo tick, solo se ejecuta el de mayor `netProfit` (desempate por `netProfitPct` y par lexicográfico).
|
||||
- **Integridad del libro local** — los feeds delta con ventana top-N (Kraken v2) se truncan al depth suscrito tras cada update (Kraken no manda deletes para niveles expulsados de la ventana); un libro internamente cruzado (bid ≥ ask) nunca se emite al engine: se descarta, se loguea y se fuerza re-sync vía reconexión.
|
||||
- **Staleness** — quotes más viejos que `STALE_MS` no disparan ejecución.
|
||||
- **Anti-flicker** — la divergencia debe persistir `FLICKER_CONFIRM_MS` antes de actuar (filtra artefactos de latencia).
|
||||
- **Partial fills** — volumen limitado por profundidad del libro e inventario de wallet.
|
||||
@@ -307,21 +308,19 @@ Contrato generado desde Zod (`src/interfaces/http/schemas/`) con `@asteasolution
|
||||
|
||||
---
|
||||
|
||||
## Deploy en Fly.io
|
||||
## Deploy (producción: VPS Hetzner + GHCR)
|
||||
|
||||
El repo incluye `Dockerfile` (multi-stage: build Vite + runtime Node), `fly.toml` y el workflow `.github/workflows/fly-deploy.yml`.
|
||||
Producción corre en una **VPS de Hetzner** (`https://arbpulse.wayool.com`) con Docker Compose detrás de nginx. El deploy es **automático**:
|
||||
|
||||
1. Instala [flyctl](https://fly.io/docs/flyctl/install/) e inicia sesión: `fly auth login`.
|
||||
2. Desde la raíz del repo: `fly launch` (primera vez) o `fly deploy`.
|
||||
3. **Región primaria:** `sin` (Singapore) — menor latencia a matching engines de Binance, Bybit y OKX.
|
||||
4. Fly expone el servicio en el puerto interno **8080** con healthcheck `GET /api/health`.
|
||||
5. Variables de entorno: opcionales; `fly.toml` incluye defaults razonables (`STALE_MS`, inventario inicial, etc.).
|
||||
6. **CI/CD:** cada push a `main` dispara deploy automático vía GitHub Actions (`fly-deploy.yml`; requiere secret `FLY_API_TOKEN`). Los PRs pasan `CI / quality` (typecheck, test, build) sin desplegar.
|
||||
7. Abre la URL pública: badge **LIVE**, cuatro venues en la price matrix, y —en mercado real— sobre todo oportunidades rechazadas por fees (esperado).
|
||||
1. Push/merge a `main` dispara `.github/workflows/vps-deploy.yml`.
|
||||
2. GitHub Actions buildea la imagen (`Dockerfile` multi-stage) y la publica en **GHCR** (`ghcr.io/mauricioabh/arbpulse`, tags `latest` + `sha-<commit>`).
|
||||
3. El workflow entra por SSH a la VPS, hace `docker compose pull` + `up -d` y espera el health check (`GET /api/health`).
|
||||
|
||||
> Producción usa **Fly.io**. `railway.json` es un artefacto legacy del deploy anterior; no lo uses.
|
||||
Detalles, bootstrap manual y rollback: [`deploy/README.md`](deploy/README.md).
|
||||
|
||||
Para una demo con actividad visible en pocos segundos, define `DEMO_MODE=true` en Fly (`fly secrets set` / `[env]` en `fly.toml`) o actívalo desde Controls.
|
||||
> Fly.io queda como opción alternativa manual (`fly-deploy.yml` vía workflow_dispatch, requiere `FLY_API_TOKEN`; región recomendada `sin`). `railway.json` es un artefacto legacy; no lo uses.
|
||||
|
||||
Para una demo con actividad visible en pocos segundos, define `DEMO_MODE=true` en `deploy/.env` de la VPS o actívalo desde Controls.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
# Copy to ./.env on the VPS and fill in. Never commit the real .env.
|
||||
|
||||
# Domain for Caddy automatic HTTPS. Its Cloudflare DNS A record must point to this
|
||||
# VPS IP as "DNS only" (grey cloud) so Caddy can issue the Let's Encrypt cert and
|
||||
# SSE (/api/stream) is not buffered by Cloudflare's proxy.
|
||||
DOMAIN=arbpulse.wayool.com
|
||||
|
||||
NODE_ENV=production
|
||||
PORT=8080
|
||||
|
||||
# Engine tuning (mirrors render.yaml production defaults)
|
||||
STALE_MS=3000
|
||||
FLICKER_CONFIRM_MS=150
|
||||
MIN_NET_PROFIT_PCT=0.0005
|
||||
MAX_TRADE_BTC=0.25
|
||||
LATENCY_MS=120
|
||||
LATENCY_SLIPPAGE_BPS=2
|
||||
CIRCUIT_BREAKER_LOSSES=5
|
||||
CIRCUIT_BREAKER_COOLDOWN_MS=15000
|
||||
INITIAL_USDT=50000
|
||||
INITIAL_BTC=0.5
|
||||
DEMO_MODE=false
|
||||
RECORD_FEED=false
|
||||
|
||||
# Observability. Error monitoring is on when SENTRY_DSN is set.
|
||||
# Leave SENTRY_TRACING=false for 24/7 (keeps spans off the free-tier quota).
|
||||
SENTRY_DSN=
|
||||
SENTRY_TRACING=false
|
||||
LOG_LEVEL=info
|
||||
|
||||
# Upstash (optional): rate limiting on /api/* + short-TTL snapshot cache.
|
||||
# Leave empty to disable both features.
|
||||
UPSTASH_REDIS_REST_URL=
|
||||
UPSTASH_REDIS_REST_TOKEN=
|
||||
@@ -0,0 +1,9 @@
|
||||
# Automatic HTTPS via Let's Encrypt. DOMAIN comes from ./.env (docker compose
|
||||
# substitutes ${DOMAIN}). Point the domain's DNS A record to this VPS first.
|
||||
{$DOMAIN} {
|
||||
# flush_interval -1 disables response buffering so SSE (/api/stream) streams
|
||||
# in real time. Do NOT gzip event-stream responses (would buffer them).
|
||||
reverse_proxy app:8080 {
|
||||
flush_interval -1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
# Deploy en VPS (Hetzner) — Arb Pulse
|
||||
|
||||
Despliegue con Docker Compose para correr 24/7 desde la rama `main`. El contenedor
|
||||
`app` (Node + tsx) queda en `127.0.0.1:8080` y un **reverse proxy del host** expone
|
||||
HTTPS.
|
||||
|
||||
## Deploy automático (CI/CD con GHCR) — flujo normal
|
||||
|
||||
Cada push/merge a `main` dispara `.github/workflows/vps-deploy.yml`:
|
||||
|
||||
1. **Build en GitHub Actions** → imagen publicada en `ghcr.io/mauricioabh/arbpulse`
|
||||
con tags `latest` + `sha-<commit>` (la VPS nunca buildea).
|
||||
2. **Deploy por SSH** → en la VPS: `git reset --hard origin/main` (en
|
||||
`/root/projects/arbpulse`), `docker compose pull app`, `docker compose up -d app`
|
||||
y espera del health check.
|
||||
|
||||
Secrets del repo: `VPS_SSH_KEY` (deploy key dedicada, solo para esto),
|
||||
`VPS_HOST`, `VPS_USER`. Rollback: re-ejecutar el workflow desde un commit
|
||||
anterior (`workflow_dispatch`) o en la VPS hacer `docker compose pull` de un
|
||||
tag `sha-<commit>` previo.
|
||||
|
||||
> No editar archivos del repo directamente en la VPS: el deploy hace
|
||||
> `git reset --hard` y los pisará. La config local vive solo en `deploy/.env`
|
||||
> (no trackeado).
|
||||
|
||||
Lo que sigue abajo es el **camino manual/bootstrap** (primera instalación o
|
||||
fallback). Dos modos:
|
||||
|
||||
- **Opción A — detrás de nginx existente (recomendado en este VPS).** El servidor ya
|
||||
corre nginx en 80/443 con certbot para otras apps (p. ej. `consumet.wayool.com`,
|
||||
`openclaw.wayool.com`). Se agrega un vhost para `arbpulse.wayool.com` y punto.
|
||||
- **Opción B — Caddy bundleado.** Solo para un server **fresco** sin nada en 80/443.
|
||||
|
||||
## Prerrequisitos
|
||||
|
||||
1. VPS Ubuntu/Debian (recomendado 2 vCPU / 2 GB RAM — p. ej. Hetzner CX22).
|
||||
2. Subdominio `arbpulse.wayool.com` con registro **A → IP del VPS** (ya creado en
|
||||
Cloudflare, ver abajo).
|
||||
3. Docker + plugin compose (el `deploy.sh` los instala si faltan).
|
||||
|
||||
## DNS en Cloudflare (zona wayool.com)
|
||||
|
||||
- Registro **A**, nombre `arbpulse`, contenido = IP del VPS, **"DNS only" (nube gris)**.
|
||||
- Motivo: certbot/Caddy emiten el cert vía HTTP-01 (reto directo al origen) y el
|
||||
SSE de `/api/stream` fluye sin buffering del proxy de Cloudflare.
|
||||
- Verificar: `dig +short arbpulse.wayool.com` debe devolver la IP del VPS.
|
||||
|
||||
---
|
||||
|
||||
## Opción A — detrás del nginx existente (recomendado)
|
||||
|
||||
No toca tus otras apps ni Caddy. Solo levanta el contenedor `app` en loopback y le
|
||||
pone un vhost de nginx con certbot (mismo patrón que consumet/openclaw).
|
||||
|
||||
```bash
|
||||
# 1) Traer y correr el script (1ra vez: crea .env y se detiene). App-only por default.
|
||||
curl -fsSL https://raw.githubusercontent.com/mauricioabh/arbpulse/main/deploy/deploy.sh -o /tmp/arbpulse-deploy.sh
|
||||
bash /tmp/arbpulse-deploy.sh
|
||||
|
||||
# 2) Editar el .env (DOMAIN + secretos opcionales)
|
||||
nano /root/projects/arbpulse/deploy/.env # DOMAIN=arbpulse.wayool.com ; SENTRY_TRACING=false ; ...
|
||||
|
||||
# 3) Volver a correr: build + up del contenedor app (127.0.0.1:8080)
|
||||
bash /tmp/arbpulse-deploy.sh
|
||||
|
||||
# 4) Instalar el vhost de nginx y emitir el cert
|
||||
sudo cp /root/projects/arbpulse/deploy/nginx/arbpulse.wayool.com.conf /etc/nginx/sites-available/arbpulse.wayool.com
|
||||
sudo ln -s /etc/nginx/sites-available/arbpulse.wayool.com /etc/nginx/sites-enabled/
|
||||
sudo nginx -t && sudo systemctl reload nginx
|
||||
sudo certbot --nginx -d arbpulse.wayool.com
|
||||
```
|
||||
|
||||
El vhost (`deploy/nginx/arbpulse.wayool.com.conf`) trae `proxy_buffering off` y
|
||||
timeouts largos para que el SSE funcione. certbot copia el bloque `location` al
|
||||
server TLS que genera, así que los ajustes SSE se mantienen en HTTPS.
|
||||
|
||||
## Opción B — Caddy bundleado (server fresco, sin nginx)
|
||||
|
||||
Solo si nada más usa 80/443:
|
||||
|
||||
```bash
|
||||
cd /root/projects/arbpulse/deploy
|
||||
cp .env.vps.example .env && nano .env # DOMAIN=arbpulse.wayool.com ...
|
||||
WITH_CADDY=1 bash /tmp/arbpulse-deploy.sh # o: docker compose --profile caddy up -d --build
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Verificar
|
||||
|
||||
```bash
|
||||
docker compose -f /root/projects/arbpulse/deploy/docker-compose.yml ps # app healthy/running
|
||||
curl -s http://127.0.0.1:8080/api/health # local
|
||||
curl -s https://arbpulse.wayool.com/api/health # público (HTTPS)
|
||||
```
|
||||
|
||||
Dashboard: `https://arbpulse.wayool.com` → badge **LIVE** y los 4 exchanges.
|
||||
|
||||
## Variables de entorno
|
||||
|
||||
Ver `.env.vps.example`. Claves:
|
||||
|
||||
- `DOMAIN` — `arbpulse.wayool.com` (usado por Caddy en Opción B; informativo en A).
|
||||
- `SENTRY_DSN` — opcional; activa error monitoring.
|
||||
- `SENTRY_TRACING` — dejar en `false` (default) para no consumir la cuota free-tier
|
||||
de spans en operación 24/7.
|
||||
- `UPSTASH_REDIS_REST_URL` / `UPSTASH_REDIS_REST_TOKEN` — opcionales (rate limit +
|
||||
cache). Vacío = desactivado.
|
||||
|
||||
> Nunca comitees el `.env` real. Solo `.env.vps.example` vive en el repo.
|
||||
|
||||
## Operación
|
||||
|
||||
```bash
|
||||
cd /root/projects/arbpulse/deploy
|
||||
docker compose logs -f app # logs de la app
|
||||
docker compose restart app # reiniciar
|
||||
docker compose down # detener (app; agrega --profile caddy si aplica)
|
||||
bash /tmp/arbpulse-deploy.sh # actualizar manualmente (git pull main + pull GHCR)
|
||||
BUILD=1 bash /tmp/arbpulse-deploy.sh # fallback: build local en la VPS
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
- El puerto 8080 se publica solo en `127.0.0.1` (no expuesto a Internet); el tráfico
|
||||
público entra por el reverse proxy del host (nginx o Caddy).
|
||||
- La app ya envía `X-Accel-Buffering: no` en el SSE, que nginx respeta para no
|
||||
bufferear ese response.
|
||||
- Región: Hetzner no tiene Asia; para menor latencia a Binance/Bybit/OKX evalúa un
|
||||
VPS en Singapur. Para Kraken (EU) Falkenstein/Helsinki va bien.
|
||||
Executable
+96
@@ -0,0 +1,96 @@
|
||||
#!/usr/bin/env bash
|
||||
# Idempotent VPS deploy for Arb Pulse (Ubuntu/Debian).
|
||||
# Safe to re-run: installs Docker if missing, clones or fast-forwards the repo,
|
||||
# pulls the CI-built image from GHCR (or builds locally with BUILD=1) and
|
||||
# restarts the containers. On first run it creates .env from the example and
|
||||
# stops so you can fill in DOMAIN + secrets.
|
||||
#
|
||||
# NOTE: normal production deploys are automatic — push/merge to `main` triggers
|
||||
# .github/workflows/vps-deploy.yml (build in CI -> GHCR -> SSH pull + restart).
|
||||
# This script is the manual/bootstrap path.
|
||||
#
|
||||
# Default: runs ONLY the app on 127.0.0.1:8080 (put it behind your host reverse
|
||||
# proxy — see deploy/nginx/). Set WITH_CADDY=1 to also start the bundled Caddy on
|
||||
# 80/443 (only for a fresh server with nothing else on those ports).
|
||||
set -euo pipefail
|
||||
|
||||
REPO_URL="${REPO_URL:-https://github.com/mauricioabh/arbpulse.git}"
|
||||
APP_DIR="${APP_DIR:-/root/projects/arbpulse}"
|
||||
BRANCH="${BRANCH:-main}"
|
||||
WITH_CADDY="${WITH_CADDY:-0}"
|
||||
# BUILD=1 builds the image locally instead of pulling from GHCR (fallback).
|
||||
BUILD="${BUILD:-0}"
|
||||
|
||||
echo "==> Arb Pulse VPS deploy (branch: $BRANCH, dir: $APP_DIR)"
|
||||
|
||||
# 1) Docker + compose plugin
|
||||
if ! command -v docker >/dev/null 2>&1; then
|
||||
echo "==> Installing Docker Engine..."
|
||||
curl -fsSL https://get.docker.com | sh
|
||||
fi
|
||||
docker --version
|
||||
if ! docker compose version >/dev/null 2>&1; then
|
||||
echo "ERROR: 'docker compose' plugin not available. Install docker-compose-plugin." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 2) Repo (clone or fast-forward)
|
||||
if [ ! -d "$APP_DIR/.git" ]; then
|
||||
echo "==> Cloning $REPO_URL into $APP_DIR"
|
||||
sudo mkdir -p "$APP_DIR"
|
||||
sudo chown "$(id -un)":"$(id -gn)" "$APP_DIR"
|
||||
git clone --branch "$BRANCH" "$REPO_URL" "$APP_DIR"
|
||||
else
|
||||
echo "==> Updating existing repo in $APP_DIR"
|
||||
git -C "$APP_DIR" fetch origin "$BRANCH"
|
||||
git -C "$APP_DIR" checkout "$BRANCH"
|
||||
git -C "$APP_DIR" pull --ff-only origin "$BRANCH"
|
||||
fi
|
||||
|
||||
cd "$APP_DIR/deploy"
|
||||
|
||||
# 3) Environment file
|
||||
if [ ! -f .env ]; then
|
||||
cp .env.vps.example .env
|
||||
echo
|
||||
echo "==> Created $APP_DIR/deploy/.env from the example."
|
||||
echo " Edit it now (set DOMAIN, and optionally SENTRY_DSN / UPSTASH_*),"
|
||||
echo " then run this script again to build and start."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 4) Image: pull from GHCR (default) or build locally (BUILD=1)
|
||||
if [ "$BUILD" = "1" ]; then
|
||||
echo "==> Building image locally..."
|
||||
docker compose build app
|
||||
else
|
||||
echo "==> Pulling image from GHCR..."
|
||||
docker compose pull app
|
||||
fi
|
||||
|
||||
# 5) Run
|
||||
if [ "$WITH_CADDY" = "1" ]; then
|
||||
echo "==> Starting app + bundled Caddy (ports 80/443)..."
|
||||
docker compose --profile caddy up -d
|
||||
else
|
||||
echo "==> Starting app only (127.0.0.1:8080)..."
|
||||
docker compose up -d app
|
||||
fi
|
||||
|
||||
echo "==> Waiting for health..."
|
||||
sleep 8
|
||||
docker compose ps
|
||||
echo
|
||||
if curl -fsS http://127.0.0.1:8080/api/health >/dev/null 2>&1; then
|
||||
echo "==> App healthy on 127.0.0.1:8080."
|
||||
else
|
||||
echo "!! Health check not passing yet. Inspect: docker compose logs -f app"
|
||||
fi
|
||||
|
||||
if [ "$WITH_CADDY" = "1" ]; then
|
||||
echo "==> Bundled Caddy will issue HTTPS once DNS for \$DOMAIN points here."
|
||||
echo "==> Verify: curl -s https://\$DOMAIN/api/health"
|
||||
else
|
||||
echo "==> App is bound to 127.0.0.1:8080. Put it behind your host reverse proxy:"
|
||||
echo " see $APP_DIR/deploy/nginx/arbpulse.wayool.com.conf (nginx + certbot)."
|
||||
fi
|
||||
@@ -0,0 +1,57 @@
|
||||
# Arb Pulse — VPS deployment.
|
||||
#
|
||||
# Default (`docker compose up -d --build`): runs ONLY the app container, bound to
|
||||
# 127.0.0.1:8080. Put it behind your existing reverse proxy (nginx/Caddy/Traefik)
|
||||
# on the host — see deploy/nginx/arbpulse.wayool.com.conf. This is the right mode
|
||||
# when the host already owns ports 80/443 for other apps.
|
||||
#
|
||||
# Optional bundled Caddy (`docker compose --profile caddy up -d --build`): only for
|
||||
# a FRESH server with nothing else on 80/443; Caddy then handles automatic HTTPS.
|
||||
#
|
||||
# Requires a ./.env file (copy from .env.vps.example and fill DOMAIN + secrets).
|
||||
|
||||
services:
|
||||
app:
|
||||
# Normal flow: the image is built by CI (.github/workflows/vps-deploy.yml)
|
||||
# and pulled from GHCR. `build:` remains for local/manual builds only
|
||||
# (docker compose build app).
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: Dockerfile
|
||||
image: ghcr.io/mauricioabh/arbpulse:latest
|
||||
container_name: arbpulse
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- ./.env
|
||||
# Bind to loopback only; the host reverse proxy forwards public traffic here.
|
||||
ports:
|
||||
- "127.0.0.1:8080:8080"
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:8080/api/health"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 15s
|
||||
|
||||
# Opt-in only via `--profile caddy`. Do NOT enable if the host already runs
|
||||
# nginx/another proxy on 80/443 — the ports would collide.
|
||||
caddy:
|
||||
image: caddy:2-alpine
|
||||
container_name: arbpulse-caddy
|
||||
restart: unless-stopped
|
||||
profiles: ["caddy"]
|
||||
depends_on:
|
||||
- app
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
environment:
|
||||
- DOMAIN=${DOMAIN}
|
||||
volumes:
|
||||
- ./Caddyfile:/etc/caddy/Caddyfile:ro
|
||||
- caddy_data:/data
|
||||
- caddy_config:/config
|
||||
|
||||
volumes:
|
||||
caddy_data:
|
||||
caddy_config:
|
||||
@@ -0,0 +1,35 @@
|
||||
# Arb Pulse — nginx vhost (host-level), to sit alongside consumet/openclaw.
|
||||
#
|
||||
# Install:
|
||||
# sudo cp deploy/nginx/arbpulse.wayool.com.conf /etc/nginx/sites-available/arbpulse.wayool.com
|
||||
# sudo ln -s /etc/nginx/sites-available/arbpulse.wayool.com /etc/nginx/sites-enabled/
|
||||
# sudo nginx -t && sudo systemctl reload nginx
|
||||
# sudo certbot --nginx -d arbpulse.wayool.com # issues cert + adds the 443 block + HTTP->HTTPS redirect
|
||||
#
|
||||
# certbot copies the location block into the TLS server it generates, so the
|
||||
# SSE-friendly proxy settings below carry over to HTTPS.
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name arbpulse.wayool.com;
|
||||
|
||||
# Proxy everything to the app container (loopback-published by docker compose).
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:8080;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# SSE (/api/stream): stream in real time, never buffer, keep long-lived.
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
proxy_set_header Connection "";
|
||||
proxy_read_timeout 3600s;
|
||||
proxy_send_timeout 3600s;
|
||||
chunked_transfer_encoding off;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
## Context
|
||||
|
||||
The dashboard (`web/`, React 18 + Vite 6 + Tailwind v3) is served by Express as
|
||||
static files from `web/dist` (same origin as `/api` + SSE). `App.tsx` already uses a
|
||||
`grid-cols-1 lg:grid-cols-3` layout, so it collapses to a single column on mobile,
|
||||
but two things are missing for a good phone experience: (1) the `PriceMatrix` order
|
||||
book is a 6-column `<table>` that overflows horizontally on ~360–430px screens, and
|
||||
(2) there is no install/mobile metadata (manifest, theme-color, apple-touch-icon).
|
||||
|
||||
The app is real-time: the frontend consumes `/api/state` over SSE and shows a
|
||||
`connected` badge. A domain rule (honesty) forbids ever presenting stale or fake
|
||||
market data. The target runtime is the 24/7 VPS behind HTTPS at
|
||||
`arbpulse.wayool.com` (nginx + certbot, Cloudflare DNS-only).
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- Installable to the phone home screen (standalone, branded icon).
|
||||
- Responsive, touch-friendly dashboard on phones without regressing desktop.
|
||||
- Zero risk of showing cached/stale market data.
|
||||
- No new runtime dependencies; no change to build/deploy steps.
|
||||
|
||||
**Non-Goals:**
|
||||
- Offline support of any kind (no cached app shell, no cached data).
|
||||
- Push notifications / background sync.
|
||||
- A native app or app-store packaging.
|
||||
- Redesigning the dashboard; this is layout + metadata only.
|
||||
|
||||
## Decisions
|
||||
|
||||
### D1: No service worker (installable via manifest + HTTPS only)
|
||||
Since Chrome 108 (mobile) / 112 (desktop), a service worker is no longer required to
|
||||
install from the browser menu / "Add to Home Screen"; iOS Safari never required one.
|
||||
No-op `fetch` handlers are now actively skipped and warned about by Chrome. Because
|
||||
this app must never serve cached data, we ship **no service worker at all**.
|
||||
- **Chosen:** manifest + icons + HTTPS → installable; SSE always fetches live data.
|
||||
- **Alternatives:** `vite-plugin-pwa`/Workbox with a shell precache (rejected: its
|
||||
value is offline/precaching, which conflicts with the no-stale-data rule and adds
|
||||
a dependency); a minimal passthrough SW just to get the auto-install banner
|
||||
(rejected: Chrome ignores no-op fetch handlers, and it adds a moving part for a
|
||||
banner we don't need — manual install from the menu is sufficient).
|
||||
- **Trade-off:** no automatic install prompt on Android; users install from the
|
||||
browser menu (the only path on iOS anyway). Acceptable.
|
||||
|
||||
### D2: PriceMatrix responsive strategy — stacked cards below `sm`
|
||||
Render per-venue cards (exchange + bid/ask/qty/spread as label–value rows) on small
|
||||
screens and keep the existing `<table>` from `sm` up.
|
||||
- **Chosen:** Tailwind responsive classes to swap layouts (`block sm:table` pattern
|
||||
or two branches gated by breakpoint) — no JS, no `matchMedia`, purely CSS.
|
||||
- **Alternative:** wrap the table in `overflow-x-auto` (rejected as primary: a
|
||||
6-column price table sideways-scrolling on a phone is poor UX for the primary
|
||||
widget; horizontal scroll can remain as a defensive fallback).
|
||||
|
||||
### D3: Icons derived from one master
|
||||
Generate a single ArbPulse master icon (pulse line → Bitcoin "B", blue→green on
|
||||
`#0a0e14`) and derive `icon-192.png`, `icon-512.png`, `icon-maskable-512.png`
|
||||
(with safe padding), and `apple-touch-icon.png` (180). Store under `web/public/` so
|
||||
Vite copies them verbatim to `web/dist`.
|
||||
- **theme_color/background_color:** `#0a0e14` (matches the dashboard background).
|
||||
|
||||
### D4: Manifest + head metadata, same-origin
|
||||
`manifest.webmanifest` uses `start_url: "/"`, `scope: "/"`, `display: "standalone"`.
|
||||
`index.html` adds `theme-color`, `apple-mobile-web-app-capable`,
|
||||
`apple-mobile-web-app-status-bar-style`, `apple-mobile-web-app-title`,
|
||||
`viewport-fit=cover` (already has `width=device-width, initial-scale=1`), and links
|
||||
to the manifest + apple-touch-icon. Everything is same-origin, so no CORS/proxy
|
||||
concerns and the existing Express static + SPA fallback serve it unchanged.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- [Stale data if a SW is ever added later] → Documented no-SW decision; if a SW is
|
||||
introduced in the future it MUST deny-list `/api` and never cache the SSE stream.
|
||||
- [iOS quirks: no auto-prompt, status-bar/notch rendering] → Provide
|
||||
`apple-touch-icon`, `apple-mobile-web-app-*`, and `viewport-fit=cover`; verify on a
|
||||
real iPhone (Add to Home Screen), not just Android.
|
||||
- [PriceMatrix layout duplication risk] → Keep a single data mapping and switch only
|
||||
presentation via breakpoints to avoid divergent desktop/mobile logic.
|
||||
- [Maskable icon getting cropped] → Keep the glyph within the ~80% safe zone in the
|
||||
maskable variant.
|
||||
@@ -0,0 +1,48 @@
|
||||
## Why
|
||||
|
||||
The Arb Pulse dashboard is desktop-first: the 3-column grid collapses acceptably on
|
||||
narrow screens, but the order-book table overflows horizontally and there is no
|
||||
mobile/PWA metadata. We want to demo and monitor the engine from a phone —
|
||||
installable to the home screen, full-window, and touch-friendly — while the app
|
||||
runs 24/7 on the VPS behind HTTPS (`arbpulse.wayool.com`).
|
||||
|
||||
## What Changes
|
||||
|
||||
- Make the dashboard fully responsive on phones (~360–430px): stack the
|
||||
`PriceMatrix` order book as per-venue cards below `sm`, reflow the `StatsBar`
|
||||
badges, and ensure touch targets are at least ~44px in `Controls`/`ConfigPanel`.
|
||||
- Add a Web App Manifest (`web/public/manifest.webmanifest`) so the dashboard is
|
||||
installable to the home screen (name/short_name, `start_url`, `display: standalone`,
|
||||
theme/background color, icons).
|
||||
- Add ArbPulse app icons (192, 512, a maskable variant, and a 180px
|
||||
`apple-touch-icon`) under `web/public/`.
|
||||
- Add mobile/install metadata to `web/index.html`: `theme-color`,
|
||||
`apple-mobile-web-app-*`, `viewport-fit=cover`, and links to the manifest and
|
||||
apple-touch-icon.
|
||||
- **No service worker and no offline support.** The app is real-time (SSE); it must
|
||||
never serve cached/stale market data. Installability comes from the manifest +
|
||||
HTTPS (menu / "Add to Home Screen"), not from a cached shell. When disconnected
|
||||
the UI shows its existing "reconnecting/offline" state, never fake data.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
- `pwa`: installability and mobile-web behavior of the dashboard — how the app is
|
||||
installable to the home screen via manifest + HTTPS with no service worker, how
|
||||
it stays responsive on small screens, and the explicit no-offline / no-stale-data
|
||||
guarantee for this real-time app.
|
||||
|
||||
### Modified Capabilities
|
||||
<!-- None: responsiveness and install metadata are new behavior; no existing spec's requirements change. -->
|
||||
|
||||
## Impact
|
||||
|
||||
- Frontend: `web/index.html` (head metadata), `web/public/` (new manifest + icons),
|
||||
`web/src/components/PriceMatrix.tsx` and `StatsBar.tsx` (responsive layout),
|
||||
minor touch-target tweaks in `Controls.tsx`/`ConfigPanel.tsx`.
|
||||
- Build/serve: none — `npm run build` copies `web/public/` into `web/dist`, already
|
||||
served by Express static; the Docker image bakes it in. No new deploy steps.
|
||||
- Dependencies: none added (no `vite-plugin-pwa`/Workbox, since there is no SW).
|
||||
- Behavior: no API, wire-format, or engine changes. Desktop layout unchanged.
|
||||
- Testing: verified on a real phone at `https://arbpulse.wayool.com` after the
|
||||
normal `dev → PR → main → deploy.sh` flow.
|
||||
@@ -0,0 +1,58 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Installable to the home screen
|
||||
The dashboard SHALL be installable to a device home screen via a Web App Manifest
|
||||
served over HTTPS, without requiring a service worker.
|
||||
|
||||
#### Scenario: Manifest is present and valid
|
||||
- **WHEN** the dashboard is loaded over HTTPS
|
||||
- **THEN** a linked `manifest.webmanifest` is served that includes `name`,
|
||||
`short_name`, `start_url` in scope, `display: standalone`, `theme_color`,
|
||||
`background_color`, and at least 192px and 512px PNG icons
|
||||
|
||||
#### Scenario: User installs from the browser
|
||||
- **WHEN** a user chooses "Install app" / "Add to Home Screen" from the browser menu
|
||||
- **THEN** the app is added with the ArbPulse icon and launches in a standalone,
|
||||
full-window mode at `start_url`
|
||||
|
||||
### Requirement: No offline mode and no stale data
|
||||
The app SHALL NOT register a service worker and SHALL NOT cache market data, so it
|
||||
never presents stale or fake data (real-time honesty rule).
|
||||
|
||||
#### Scenario: No service worker is registered
|
||||
- **WHEN** the dashboard runs in any environment
|
||||
- **THEN** no service worker is registered and no application cache stores `/api`
|
||||
responses or the SSE stream
|
||||
|
||||
#### Scenario: Disconnected device shows a clear non-data state
|
||||
- **WHEN** the device has no working connection to the engine
|
||||
- **THEN** the UI shows its reconnecting/offline indicator and does not display
|
||||
cached market figures as if they were live
|
||||
|
||||
### Requirement: Responsive mobile layout
|
||||
The dashboard SHALL be usable on phone-sized viewports (~360–430px wide) without
|
||||
horizontal overflow of primary widgets, while the desktop layout is unchanged.
|
||||
|
||||
#### Scenario: Order book fits a phone screen
|
||||
- **WHEN** the viewport is narrower than the `sm` breakpoint
|
||||
- **THEN** the order-book (`PriceMatrix`) is presented as per-venue stacked cards
|
||||
showing bid, ask, quantities, and spread without horizontal scrolling
|
||||
|
||||
#### Scenario: Stats and controls remain readable and tappable
|
||||
- **WHEN** the dashboard is viewed on a phone
|
||||
- **THEN** the stats bar reflows without clipping and interactive controls have
|
||||
touch targets of at least ~44px
|
||||
|
||||
#### Scenario: Desktop layout is preserved
|
||||
- **WHEN** the viewport is at the `lg` breakpoint or wider
|
||||
- **THEN** the existing multi-column layout and table order book render as before
|
||||
|
||||
### Requirement: Mobile and install metadata
|
||||
The `index.html` head SHALL include the metadata needed for correct mobile rendering
|
||||
and home-screen installation on Android and iOS.
|
||||
|
||||
#### Scenario: Head includes mobile/install tags
|
||||
- **WHEN** the page is served
|
||||
- **THEN** the head contains a `theme-color`, `viewport-fit=cover` in the viewport
|
||||
meta, a link to the manifest, an `apple-touch-icon`, and the
|
||||
`apple-mobile-web-app-*` tags for standalone iOS launch
|
||||
@@ -0,0 +1,39 @@
|
||||
## 1. Icons
|
||||
|
||||
- [x] 1.1 Finalize the ArbPulse master icon and derive `icon-192.png`,
|
||||
`icon-512.png`, `icon-maskable-512.png` (glyph within ~80% safe zone), and
|
||||
`apple-touch-icon.png` (180px)
|
||||
- [x] 1.2 Place all icons under `web/public/`
|
||||
|
||||
## 2. Manifest
|
||||
|
||||
- [x] 2.1 Create `web/public/manifest.webmanifest` with `name`, `short_name`,
|
||||
`start_url: "/"`, `scope: "/"`, `display: "standalone"`,
|
||||
`theme_color`/`background_color` `#0a0e14`, and icon entries (192, 512, and
|
||||
512 maskable with `"purpose": "maskable"`)
|
||||
|
||||
## 3. HTML head metadata
|
||||
|
||||
- [x] 3.1 In `web/index.html`, add `viewport-fit=cover` to the viewport meta and a
|
||||
`theme-color` meta (`#0a0e14`)
|
||||
- [x] 3.2 Add `<link rel="manifest">`, `<link rel="apple-touch-icon">`, and the
|
||||
`apple-mobile-web-app-capable` / `apple-mobile-web-app-status-bar-style` /
|
||||
`apple-mobile-web-app-title` tags
|
||||
|
||||
## 4. Responsive layout
|
||||
|
||||
- [x] 4.1 `PriceMatrix.tsx`: render per-venue stacked cards (exchange + bid/ask/qty/
|
||||
spread as label–value rows) below `sm`; keep the `<table>` from `sm` up, with
|
||||
no horizontal overflow on phones
|
||||
- [x] 4.2 `StatsBar.tsx`: reflow stats and badges on mobile without clipping
|
||||
- [x] 4.3 Ensure touch targets in `Controls.tsx` and `ConfigPanel.tsx` are ~44px min
|
||||
- [x] 4.4 Sanity-check `App.tsx` spacing/padding on narrow viewports
|
||||
|
||||
## 5. Verify
|
||||
|
||||
- [x] 5.1 `npm run typecheck` and `npm test` pass
|
||||
- [x] 5.2 `npm run build` succeeds and `web/dist` contains the manifest + icons
|
||||
- [x] 5.3 DevTools device emulation: no horizontal overflow at ~375px; Application →
|
||||
Manifest shows no errors; confirm no service worker is registered
|
||||
- [ ] 5.4 After `dev → PR → main → deploy.sh`, install on a real phone from
|
||||
`https://arbpulse.wayool.com` and confirm standalone launch + live SSE data
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-07-16
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-07-19
|
||||
@@ -0,0 +1,89 @@
|
||||
# Design — fix-kraken-phantom-book-levels
|
||||
|
||||
## Context
|
||||
|
||||
Los conectores WS mantienen un `LocalBook` (Map precio→qty por lado) que se
|
||||
actualiza con snapshots + deltas. Kraken v2 (`book`, depth 10) solo gestiona la
|
||||
ventana top-10: cuando un nivel sale de la ventana porque entran precios
|
||||
mejores, **no envía delete** — el cliente debe truncar su copia local tras cada
|
||||
update (documentado en Kraken WS v2). Hoy `BookSide.apply()` solo borra con
|
||||
`qty <= 0`, y `toArray()` capea el *display* a `depth` pero el Map conserva los
|
||||
niveles huérfanos. Como bids se ordenan desc y asks asc, un bid fantasma alto o
|
||||
un ask fantasma bajo queda **siempre** en el tope del array emitido. Resultado
|
||||
observado en prod: libro de Kraken cruzado (bid > ask) durante horas y $62.9M
|
||||
de P&L ficticio.
|
||||
|
||||
Agravante: `ExchangeConnector.depth = 15` mientras Kraken se suscribe con
|
||||
`depth: 10` — hasta el cap de display admite 5 niveles que Kraken jamás va a
|
||||
actualizar.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
|
||||
- El libro local de Kraken refleja fielmente la ventana top-10 del exchange.
|
||||
- Un libro internamente cruzado nunca llega al `ArbitrageEngine`.
|
||||
- Recuperación automática ante corrupción (re-sync), sin intervención manual.
|
||||
- Tests unitarios que cubran truncado y guard.
|
||||
|
||||
**Non-Goals:**
|
||||
|
||||
- Validación del checksum CRC32 de Kraken (mejora futura; el truncado +
|
||||
guard cubren el fallo observado con mucho menos código).
|
||||
- Cambios en Bybit/OKX/Binance (OKX y Binance reemplazan el libro completo por
|
||||
mensaje; Bybit manda deletes explícitos para su ventana de 50).
|
||||
- Cambios de API REST, SSE o frontend.
|
||||
|
||||
## Decisions
|
||||
|
||||
1. **`BookSide.truncate()` borra del Map, no solo del display.**
|
||||
Tras aplicar los updates de un mensaje, se ordena por mejor precio y se
|
||||
eliminan los niveles más allá de `depth`. Alternativa considerada: pasar de
|
||||
Map a array ordenado permanente — descartada, complica `apply()` O(1) y el
|
||||
hot path no lo necesita (depth ≤ 50, truncar tras cada mensaje es barato).
|
||||
|
||||
2. **El truncado se invoca desde el conector de Kraken con su depth real (10).**
|
||||
Es un requisito del protocolo de Kraken, no un comportamiento universal:
|
||||
Bybit mantiene ventana 50 con deletes explícitos, OKX/Binance resetean por
|
||||
mensaje. Alternativa: truncar siempre en `emit()` de la base — descartada
|
||||
porque mezclaría semánticas distintas por exchange y ocultaría el contrato.
|
||||
|
||||
3. **Depth por conector.** `ExchangeConnector.depth` pasa a ser sobreescribible
|
||||
y Kraken lo fija en 10, igual a su suscripción. Se elimina el mismatch 15/10.
|
||||
|
||||
4. **Guard de libro cruzado en `emit()` de la base, con auto-recovery.**
|
||||
Si `bids[0].price >= asks[0].price`: no se emite, se loguea `warn` y se
|
||||
fuerza re-sync cerrando el socket (`ws.close()` → el reconnect existente
|
||||
con backoff re-suscribe y Kraken re-manda snapshot). Alternativa: solo
|
||||
descartar la emisión — descartada porque el libro seguiría corrupto y el
|
||||
quote se volvería stale silenciosamente; reconectar restaura el dato.
|
||||
El guard vive en la base porque protege a *todos* los conectores (defensa
|
||||
en profundidad) y su costo es una comparación por emit.
|
||||
|
||||
5. **El estado corrupto acumulado (P&L ficticio) no se migra.** El estado es
|
||||
in-memory: el redeploy lo limpia automáticamente.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- [Reconexión en bucle si un exchange emitiera libros cruzados legítimos] →
|
||||
imposible en spot con un libro bien sincronizado; si ocurriera, el backoff
|
||||
exponencial existente (cap 30s) limita el impacto y el log `warn` lo hace
|
||||
visible.
|
||||
- [Truncar en cada mensaje añade un sort O(n log n)] → n ≤ ~20 niveles en
|
||||
Kraken; despreciable frente al parse JSON del propio mensaje.
|
||||
- [Sin checksum, otros desyncs sutiles (qty desactualizada dentro de la
|
||||
ventana) no se detectan] → aceptado; el guard de cruce ataja el caso dañino
|
||||
y el checksum queda como mejora futura documentada.
|
||||
|
||||
## Migration Plan
|
||||
|
||||
1. Merge a `dev` → PR → `main`.
|
||||
2. Deploy a la VPS (redeploy limpia el estado in-memory, P&L vuelve a 0).
|
||||
3. Verificar en prod: `/api/state` con los 4 exchanges `live`, libro de Kraken
|
||||
no cruzado, y P&L creciendo de forma realista (mayormente rechazos por fees).
|
||||
|
||||
Rollback: revertir el commit; no hay migración de datos.
|
||||
|
||||
## Open Questions
|
||||
|
||||
- Ninguna bloqueante. Checksum CRC32 de Kraken queda anotado como follow-up.
|
||||
@@ -0,0 +1,32 @@
|
||||
# Fix: niveles fantasma en el order book de Kraken
|
||||
|
||||
> Issue: [WAY-77](https://linear.app/wayool/issue/WAY-77/arb-niveles-fantasma-en-el-order-book-de-kraken-inflan-el-pandl) — `[ARB] Niveles fantasma en el order book de Kraken inflan el P&L ($62.9M ficticios)`
|
||||
|
||||
## Why
|
||||
|
||||
En producción el P&L realizado llegó a $62.9M ficticios: el libro local de Kraken quedó **cruzado** (bid 64,925.90 > ask 64,316.20, spread -609) con niveles viejos de hace horas (coinciden con el high/low de 24h de Kraken). El motor vio un arbitraje permanente de ~0.5% vendiendo en Kraken y ejecutó ~674k trades falsos. La causa: el conector de Kraken v2 nunca trunca el libro local al depth suscrito, y el protocolo de Kraken **no envía deletes** para niveles que salen de la ventana top-N — exige que el cliente trunque tras cada update.
|
||||
|
||||
## What Changes
|
||||
|
||||
- `BookSide` (`src/infrastructure/exchanges/local-book.ts`) gana un método `truncate()` que elimina del Map los niveles fuera de los mejores `depth` precios (no solo en el display).
|
||||
- El conector de Kraken (`src/infrastructure/exchanges/kraken.ts`) trunca ambos lados tras aplicar cada update, usando el depth suscrito (10).
|
||||
- Se corrige el mismatch de depth: el conector de Kraken suscribe y mantiene el mismo depth (hoy: base mantiene 15, suscripción pide 10).
|
||||
- Guard de libro cruzado en `ExchangeConnector.emit()`: si `bids[0].price >= asks[0].price`, no se emite el libro, se loguea y se resetea el libro local para forzar re-sincronización (Kraken re-manda snapshot al reconectar/resuscribir).
|
||||
- Documentación actualizada: skill `exchange-ws` (nota de truncado obligatorio en Kraken v2) y README si aplica.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
- `order-book-integrity`: mantenimiento correcto del libro local por exchange — truncado al depth suscrito en feeds delta (Kraken), detección de libro cruzado como señal de corrupción, y re-sincronización en lugar de emitir datos corruptos al motor.
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
<!-- ninguna: `observability` no cambia a nivel de requisitos -->
|
||||
|
||||
## Impact
|
||||
|
||||
- **Código:** `src/infrastructure/exchanges/local-book.ts`, `src/infrastructure/exchanges/kraken.ts`, `src/infrastructure/exchanges/base.ts`.
|
||||
- **Tests:** nuevos unit tests de `BookSide.truncate` y del guard de libro cruzado.
|
||||
- **Comportamiento:** el motor deja de recibir libros corruptos; el P&L vuelve a ser realista (mayormente `rejected · fees`, que es lo correcto en mercados eficientes).
|
||||
- **Sin cambios de API/contrato REST ni de frontend.** Tras el deploy se requiere un Reset manual del estado para limpiar el P&L ficticio acumulado (estado in-memory: el redeploy ya lo limpia solo).
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
# order-book-integrity
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Truncado del libro local al depth suscrito en feeds delta
|
||||
|
||||
`BookSide` SHALL exponer una operación `truncate()` que elimine del estado
|
||||
interno (no solo de la salida) todos los niveles de precio más allá de los
|
||||
mejores `depth` niveles del lado (bids: precios más altos; asks: precios más
|
||||
bajos). El conector de Kraken SHALL invocar el truncado en ambos lados tras
|
||||
aplicar cada mensaje `update`, usando el mismo depth con el que se suscribió
|
||||
al canal `book`.
|
||||
|
||||
#### Scenario: Nivel que sale de la ventana top-N se elimina
|
||||
|
||||
- **WHEN** el libro local de bids contiene `depth` niveles y un update añade un
|
||||
bid con precio mejor que todos los existentes
|
||||
- **THEN** tras el truncado el nivel con peor precio ya no existe en el estado
|
||||
interno del `BookSide` y el tamaño del lado es exactamente `depth`
|
||||
|
||||
#### Scenario: Bid fantasma no sobrevive al movimiento del mercado
|
||||
|
||||
- **WHEN** el precio de mercado baja y sucesivos updates llenan la ventana
|
||||
top-N con precios inferiores a un bid antiguo que Kraken ya no reporta
|
||||
- **THEN** el bid antiguo es eliminado por truncado y el mejor bid emitido
|
||||
refleja la ventana real del exchange
|
||||
|
||||
### Requirement: Depth del conector consistente con la suscripción
|
||||
|
||||
Cada conector SHALL mantener su libro local con el mismo depth que solicita en
|
||||
su suscripción. El conector de Kraken SHALL usar depth 10 tanto en el mensaje
|
||||
de suscripción como en su `LocalBook`.
|
||||
|
||||
#### Scenario: Sin niveles residuales por mismatch de depth
|
||||
|
||||
- **WHEN** el conector de Kraken arranca y se suscribe al canal `book`
|
||||
- **THEN** el depth del `LocalBook` es igual al depth de la suscripción (10)
|
||||
|
||||
### Requirement: Guard de libro cruzado con re-sincronización
|
||||
|
||||
`ExchangeConnector` SHALL detectar antes de emitir cuando el libro normalizado
|
||||
está internamente cruzado (`bids[0].price >= asks[0].price`). En ese caso el
|
||||
conector MUST NOT emitir el libro a los listeners, SHALL registrar el evento en
|
||||
el log, y SHALL forzar una re-sincronización (reset del libro local y
|
||||
reconexión del WebSocket para recibir un snapshot fresco).
|
||||
|
||||
#### Scenario: Libro cruzado no llega al motor
|
||||
|
||||
- **WHEN** el libro local de un exchange queda con mejor bid ≥ mejor ask
|
||||
- **THEN** no se emite ningún `OrderBook` a los listeners y el
|
||||
`ArbitrageEngine` no evalúa ese libro
|
||||
|
||||
#### Scenario: Recuperación automática tras corrupción
|
||||
|
||||
- **WHEN** se detecta un libro cruzado
|
||||
- **THEN** el conector resetea su libro local y fuerza reconexión, y tras el
|
||||
snapshot de re-suscripción vuelve a emitir libros consistentes sin
|
||||
intervención manual
|
||||
@@ -0,0 +1,26 @@
|
||||
# Tasks — fix-kraken-phantom-book-levels
|
||||
|
||||
## 1. LocalBook: truncado real
|
||||
|
||||
- [x] Añadir `BookSide.truncate()` que elimine del Map los niveles fuera de los mejores `depth` precios del lado
|
||||
- [x] Unit tests de `truncate()`: elimina el peor nivel al exceder depth, no toca nada si size <= depth, y el bid fantasma desaparece tras updates sucesivos
|
||||
|
||||
## 2. Conector Kraken
|
||||
|
||||
- [x] Hacer `ExchangeConnector.depth` sobreescribible por subclase e inicializar `LocalBook` con el depth del conector
|
||||
- [x] Fijar depth 10 en `KrakenConnector` (igual a la suscripción) y truncar ambos lados tras aplicar cada update
|
||||
|
||||
## 3. Guard de libro cruzado
|
||||
|
||||
- [x] En `ExchangeConnector.emit()`: si `bids[0].price >= asks[0].price`, no emitir, log warn, reset del libro y reconexión para re-sync
|
||||
- [x] Unit test del guard: libro cruzado no se emite a listeners y dispara re-sync
|
||||
|
||||
## 4. Documentación
|
||||
|
||||
- [x] Actualizar skill `exchange-ws` (truncado obligatorio en Kraken v2, guard de cruce en la base, depth por conector)
|
||||
- [x] Revisar README/AGENTS por menciones al manejo del libro que queden desactualizadas
|
||||
|
||||
## 5. Verificación
|
||||
|
||||
- [x] `npm run typecheck` + `npm test` en verde
|
||||
- [x] Arrancar en local con feeds reales y verificar via `/api/state` que Kraken emite libro no cruzado y quotes coherentes con el mercado (matar el proceso al terminar)
|
||||
@@ -0,0 +1,47 @@
|
||||
# observability Specification
|
||||
|
||||
## Purpose
|
||||
TBD - created by archiving change gate-sentry-spans. Update Purpose after archive.
|
||||
## Requirements
|
||||
### Requirement: Error monitoring is always active
|
||||
|
||||
The system SHALL report runtime errors to Sentry whenever `SENTRY_DSN` is configured, independent of any tracing configuration. This includes REST, WebSocket feed, and SSE errors captured via `Sentry.captureException`, plus process-level `unhandledRejection` and `uncaughtException` handlers.
|
||||
|
||||
#### Scenario: Error captured with DSN set
|
||||
|
||||
- **WHEN** `SENTRY_DSN` is set and a handled error occurs on the WebSocket, REST, or SSE path
|
||||
- **THEN** the error is sent to Sentry as an error event
|
||||
|
||||
#### Scenario: No DSN configured
|
||||
|
||||
- **WHEN** `SENTRY_DSN` is empty or unset
|
||||
- **THEN** Sentry is not initialized and no events are sent, and the application continues running normally
|
||||
|
||||
### Requirement: Tracing spans are gated by an environment flag
|
||||
|
||||
The system SHALL only create and export OpenTelemetry spans when the `SENTRY_TRACING` environment variable is enabled AND `SENTRY_DSN` is set. The flag SHALL default to disabled so that continuous 24/7 operation emits zero spans by default.
|
||||
|
||||
#### Scenario: Tracing disabled by default
|
||||
|
||||
- **WHEN** `SENTRY_TRACING` is unset or falsy (default)
|
||||
- **THEN** the tracer is a no-op, no spans are created on the hot path, and no spans are exported to Sentry
|
||||
|
||||
#### Scenario: Tracing enabled with DSN
|
||||
|
||||
- **WHEN** `SENTRY_TRACING` is truthy and `SENTRY_DSN` is set
|
||||
- **THEN** hot-path spans (`ws.message`, `orderbook.process`, `arbitrage.evaluate`, `sse.broadcast`) are created and exported to Sentry, and `tracesSampleRate` is applied
|
||||
|
||||
#### Scenario: Tracing enabled without DSN
|
||||
|
||||
- **WHEN** `SENTRY_TRACING` is truthy but `SENTRY_DSN` is empty or unset
|
||||
- **THEN** the tracer is a no-op and no spans are exported, since export requires a DSN
|
||||
|
||||
### Requirement: Error capture is preserved when tracing is disabled
|
||||
|
||||
The system SHALL continue to report exceptions thrown inside hot-path work wrappers to Sentry even when tracing is disabled, so disabling spans never reduces error visibility.
|
||||
|
||||
#### Scenario: Exception in hot-path wrapper with tracing off
|
||||
|
||||
- **WHEN** `SENTRY_TRACING` is disabled and code wrapped by the hot-path span helper throws
|
||||
- **THEN** the exception is still captured and reported to Sentry (as an error), and re-thrown to preserve existing control flow
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
# order-book-integrity
|
||||
|
||||
## Purpose
|
||||
|
||||
Garantizar que el libro local de cada exchange refleje fielmente el estado real
|
||||
del venue: truncado al depth suscrito en feeds delta con ventana top-N,
|
||||
detección de libros internamente cruzados como señal de corrupción, y
|
||||
re-sincronización automática en lugar de emitir datos corruptos al motor de
|
||||
arbitraje.
|
||||
|
||||
Origen: incidente de niveles fantasma en Kraken (Linear WAY-77, change
|
||||
`fix-kraken-phantom-book-levels`).
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: Truncado del libro local al depth suscrito en feeds delta
|
||||
|
||||
`BookSide` SHALL exponer una operación `truncate()` que elimine del estado
|
||||
interno (no solo de la salida) todos los niveles de precio más allá de los
|
||||
mejores `depth` niveles del lado (bids: precios más altos; asks: precios más
|
||||
bajos). El conector de Kraken SHALL invocar el truncado en ambos lados tras
|
||||
aplicar cada mensaje `update`, usando el mismo depth con el que se suscribió
|
||||
al canal `book`.
|
||||
|
||||
#### Scenario: Nivel que sale de la ventana top-N se elimina
|
||||
|
||||
- **WHEN** el libro local de bids contiene `depth` niveles y un update añade un
|
||||
bid con precio mejor que todos los existentes
|
||||
- **THEN** tras el truncado el nivel con peor precio ya no existe en el estado
|
||||
interno del `BookSide` y el tamaño del lado es exactamente `depth`
|
||||
|
||||
#### Scenario: Bid fantasma no sobrevive al movimiento del mercado
|
||||
|
||||
- **WHEN** el precio de mercado baja y sucesivos updates llenan la ventana
|
||||
top-N con precios inferiores a un bid antiguo que Kraken ya no reporta
|
||||
- **THEN** el bid antiguo es eliminado por truncado y el mejor bid emitido
|
||||
refleja la ventana real del exchange
|
||||
|
||||
### Requirement: Depth del conector consistente con la suscripción
|
||||
|
||||
Cada conector SHALL mantener su libro local con el mismo depth que solicita en
|
||||
su suscripción. El conector de Kraken SHALL usar depth 10 tanto en el mensaje
|
||||
de suscripción como en su `LocalBook`.
|
||||
|
||||
#### Scenario: Sin niveles residuales por mismatch de depth
|
||||
|
||||
- **WHEN** el conector de Kraken arranca y se suscribe al canal `book`
|
||||
- **THEN** el depth del `LocalBook` es igual al depth de la suscripción (10)
|
||||
|
||||
### Requirement: Guard de libro cruzado con re-sincronización
|
||||
|
||||
`ExchangeConnector` SHALL detectar antes de emitir cuando el libro normalizado
|
||||
está internamente cruzado (`bids[0].price >= asks[0].price`). En ese caso el
|
||||
conector MUST NOT emitir el libro a los listeners, SHALL registrar el evento en
|
||||
el log, y SHALL forzar una re-sincronización (reset del libro local y
|
||||
reconexión del WebSocket para recibir un snapshot fresco).
|
||||
|
||||
#### Scenario: Libro cruzado no llega al motor
|
||||
|
||||
- **WHEN** el libro local de un exchange queda con mejor bid ≥ mejor ask
|
||||
- **THEN** no se emite ningún `OrderBook` a los listeners y el
|
||||
`ArbitrageEngine` no evalúa ese libro
|
||||
|
||||
#### Scenario: Recuperación automática tras corrupción
|
||||
|
||||
- **WHEN** se detecta un libro cruzado
|
||||
- **THEN** el conector resetea su libro local y fuerza reconexión, y tras el
|
||||
snapshot de re-suscripción vuelve a emitir libros consistentes sin
|
||||
intervención manual
|
||||
+1
-1
@@ -17,7 +17,7 @@
|
||||
"test:e2e": "playwright test",
|
||||
"test:observability": "playwright test observability.spec.ts",
|
||||
"lint": "eslint .",
|
||||
"prepare": "husky"
|
||||
"prepare": "husky || true"
|
||||
},
|
||||
"lint-staged": {
|
||||
"src/**/*.ts": [
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 824 KiB |
@@ -0,0 +1,44 @@
|
||||
# One-off ArbPulse PWA icon generator.
|
||||
# Resizes the AI-generated master icon into the sizes referenced by the
|
||||
# Web App Manifest and index.html. Uses only Windows System.Drawing (no deps).
|
||||
param(
|
||||
[string]$Master = (Join-Path $PSScriptRoot "arbpulse-icon-master.png"),
|
||||
[string]$OutDir = (Join-Path $PSScriptRoot "..\web\public")
|
||||
)
|
||||
|
||||
Add-Type -AssemblyName System.Drawing
|
||||
|
||||
$bg = [System.Drawing.ColorTranslator]::FromHtml("#0a0e14")
|
||||
|
||||
function Save-Resized {
|
||||
param([System.Drawing.Image]$Src, [int]$Size, [string]$Path, [double]$Scale = 1.0)
|
||||
|
||||
$bmp = New-Object System.Drawing.Bitmap($Size, $Size)
|
||||
$g = [System.Drawing.Graphics]::FromImage($bmp)
|
||||
$g.InterpolationMode = [System.Drawing.Drawing2D.InterpolationMode]::HighQualityBicubic
|
||||
$g.SmoothingMode = [System.Drawing.Drawing2D.SmoothingMode]::HighQuality
|
||||
$g.PixelOffsetMode = [System.Drawing.Drawing2D.PixelOffsetMode]::HighQuality
|
||||
$g.Clear($bg)
|
||||
|
||||
$target = [int]([math]::Round($Size * $Scale))
|
||||
$offset = [int]([math]::Round(($Size - $target) / 2))
|
||||
$rect = New-Object System.Drawing.Rectangle($offset, $offset, $target, $target)
|
||||
$g.DrawImage($Src, $rect)
|
||||
|
||||
$g.Dispose()
|
||||
$bmp.Save($Path, [System.Drawing.Imaging.ImageFormat]::Png)
|
||||
$bmp.Dispose()
|
||||
Write-Output "wrote $Path ($Size x $Size, scale $Scale)"
|
||||
}
|
||||
|
||||
$src = [System.Drawing.Image]::FromFile($Master)
|
||||
|
||||
Save-Resized -Src $src -Size 192 -Path (Join-Path $OutDir "icon-192.png")
|
||||
Save-Resized -Src $src -Size 512 -Path (Join-Path $OutDir "icon-512.png")
|
||||
Save-Resized -Src $src -Size 180 -Path (Join-Path $OutDir "apple-touch-icon.png")
|
||||
# Maskable: full-bleed on the master's own background. The glyph's built-in
|
||||
# padding already keeps it inside the ~80% safe zone, and full-bleed avoids a
|
||||
# visible seam from mismatched flat padding vs. the master's subtle glow.
|
||||
Save-Resized -Src $src -Size 512 -Path (Join-Path $OutDir "icon-maskable-512.png") -Scale 1.0
|
||||
|
||||
$src.Dispose()
|
||||
@@ -0,0 +1,72 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import type { ExchangeId, OrderBook } from "../../domain/entities/index.js";
|
||||
import { ExchangeConnector } from "./base.js";
|
||||
|
||||
/** Minimal connector for exercising emit() without a real WebSocket. */
|
||||
class TestConnector extends ExchangeConnector {
|
||||
readonly id: ExchangeId = "kraken";
|
||||
protected readonly url = "ws://unused";
|
||||
resyncCount = 0;
|
||||
|
||||
constructor() {
|
||||
super(5);
|
||||
}
|
||||
|
||||
protected subscribeMessage(): unknown {
|
||||
return {};
|
||||
}
|
||||
|
||||
protected handleMessage(): void {}
|
||||
|
||||
protected override resync(): void {
|
||||
this.resyncCount += 1;
|
||||
super.resync();
|
||||
}
|
||||
|
||||
applyLevels(bids: [number, number][], asks: [number, number][]): void {
|
||||
for (const [price, qty] of bids) this.book.bids.apply(price, qty);
|
||||
for (const [price, qty] of asks) this.book.asks.apply(price, qty);
|
||||
}
|
||||
|
||||
emitNow(): void {
|
||||
this.emit(null);
|
||||
}
|
||||
|
||||
get bookSize(): number {
|
||||
return this.book.bids.size + this.book.asks.size;
|
||||
}
|
||||
}
|
||||
|
||||
test("normal book is emitted to listeners", () => {
|
||||
const c = new TestConnector();
|
||||
const received: OrderBook[] = [];
|
||||
c.onBook((b) => received.push(b));
|
||||
|
||||
c.applyLevels([[64560, 1]], [[64561, 1]]);
|
||||
c.emitNow();
|
||||
|
||||
assert.equal(received.length, 1);
|
||||
assert.equal(received[0]?.bids[0]?.price, 64560);
|
||||
assert.equal(c.resyncCount, 0);
|
||||
});
|
||||
|
||||
test("crossed book is not emitted and triggers resync", () => {
|
||||
const c = new TestConnector();
|
||||
const received: OrderBook[] = [];
|
||||
c.onBook((b) => received.push(b));
|
||||
|
||||
// Phantom bid above the real ask: corrupted local book.
|
||||
c.applyLevels(
|
||||
[
|
||||
[64925.9, 0.15],
|
||||
[64560, 1],
|
||||
],
|
||||
[[64561, 1]],
|
||||
);
|
||||
c.emitNow();
|
||||
|
||||
assert.equal(received.length, 0, "corrupted book must not reach listeners");
|
||||
assert.equal(c.resyncCount, 1);
|
||||
assert.equal(c.bookSize, 0, "local book is reset for a fresh snapshot");
|
||||
});
|
||||
@@ -16,7 +16,7 @@ export type BookListener = (book: OrderBook) => void;
|
||||
export abstract class ExchangeConnector implements MarketDataFeed {
|
||||
abstract readonly id: ExchangeId;
|
||||
protected abstract readonly url: string;
|
||||
protected readonly depth = 15;
|
||||
protected readonly depth: number;
|
||||
|
||||
protected ws: WebSocket | null = null;
|
||||
protected book: LocalBook;
|
||||
@@ -29,8 +29,10 @@ export abstract class ExchangeConnector implements MarketDataFeed {
|
||||
protected closed = false;
|
||||
private lastEmitTs = 0;
|
||||
|
||||
constructor() {
|
||||
this.book = new LocalBook(this.depth);
|
||||
/** `depth` must match the depth the connector subscribes with. */
|
||||
constructor(depth = 15) {
|
||||
this.depth = depth;
|
||||
this.book = new LocalBook(depth);
|
||||
}
|
||||
|
||||
onBook(listener: BookListener): void {
|
||||
@@ -165,9 +167,31 @@ export abstract class ExchangeConnector implements MarketDataFeed {
|
||||
exchangeTs,
|
||||
};
|
||||
if (book.bids.length === 0 || book.asks.length === 0) return;
|
||||
|
||||
// A crossed book (best bid >= best ask) is impossible on a spot venue:
|
||||
// it means our local copy is corrupted (e.g. phantom levels). Never feed
|
||||
// it to the engine — drop it and force a fresh snapshot via reconnect.
|
||||
const bestBid = book.bids[0]!;
|
||||
const bestAsk = book.asks[0]!;
|
||||
if (bestBid.price >= bestAsk.price) {
|
||||
this.log.warn("crossed local book detected, forcing resync", {
|
||||
bid: bestBid.price,
|
||||
ask: bestAsk.price,
|
||||
});
|
||||
this.resync();
|
||||
return;
|
||||
}
|
||||
|
||||
for (const listener of this.listeners) listener(book);
|
||||
}
|
||||
|
||||
/** Drop the corrupted local book and reconnect to receive a fresh snapshot. */
|
||||
protected resync(): void {
|
||||
this.book.reset();
|
||||
// close() triggers the existing reconnect-with-backoff path (unless stopped).
|
||||
this.ws?.close();
|
||||
}
|
||||
|
||||
/** Combined-stream URLs (e.g. Binance) set this to skip the subscribe send. */
|
||||
protected skipSubscribe(): boolean {
|
||||
return false;
|
||||
|
||||
@@ -19,20 +19,29 @@ interface KrakenMessage {
|
||||
data?: KrakenBookData[];
|
||||
}
|
||||
|
||||
const KRAKEN_BOOK_DEPTH = 10;
|
||||
|
||||
/**
|
||||
* Kraken WebSocket v2 — `book` channel.
|
||||
* Docs: https://docs.kraken.com/websockets-v2/
|
||||
* Snapshot replaces the book; updates patch individual price levels (qty 0 = remove).
|
||||
* Kraken does NOT send deletes for levels evicted from the top-N window: the
|
||||
* client must truncate its local book to the subscribed depth after every
|
||||
* update, or evicted levels linger forever as phantom quotes.
|
||||
*/
|
||||
export class KrakenConnector extends ExchangeConnector {
|
||||
readonly id: ExchangeId = "kraken";
|
||||
protected readonly url = "wss://ws.kraken.com/v2";
|
||||
private readonly symbol = "BTC/USDT";
|
||||
|
||||
constructor() {
|
||||
super(KRAKEN_BOOK_DEPTH);
|
||||
}
|
||||
|
||||
protected subscribeMessage(): unknown {
|
||||
return {
|
||||
method: "subscribe",
|
||||
params: { channel: "book", symbol: [this.symbol], depth: 10 },
|
||||
params: { channel: "book", symbol: [this.symbol], depth: this.depth },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -49,6 +58,7 @@ export class KrakenConnector extends ExchangeConnector {
|
||||
|
||||
for (const lvl of data.bids ?? []) this.book.bids.apply(lvl.price, lvl.qty);
|
||||
for (const lvl of data.asks ?? []) this.book.asks.apply(lvl.price, lvl.qty);
|
||||
this.book.truncate();
|
||||
|
||||
const exchangeTs = data.timestamp ? Date.parse(data.timestamp) : null;
|
||||
this.emit(Number.isFinite(exchangeTs) ? exchangeTs : null);
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { BookSide, LocalBook } from "./local-book.js";
|
||||
|
||||
test("truncate removes worst bid levels beyond depth", () => {
|
||||
const bids = new BookSide("bid", 3);
|
||||
for (const price of [100, 101, 102]) bids.apply(price, 1);
|
||||
// A better bid arrives; Kraken sends no delete for the evicted 100.
|
||||
bids.apply(103, 1);
|
||||
bids.truncate();
|
||||
|
||||
assert.equal(bids.size, 3);
|
||||
assert.deepEqual(
|
||||
bids.toArray().map((l) => l.price),
|
||||
[103, 102, 101],
|
||||
);
|
||||
});
|
||||
|
||||
test("truncate removes worst ask levels beyond depth", () => {
|
||||
const asks = new BookSide("ask", 3);
|
||||
for (const price of [100, 101, 102]) asks.apply(price, 1);
|
||||
asks.apply(99, 1);
|
||||
asks.truncate();
|
||||
|
||||
assert.equal(asks.size, 3);
|
||||
assert.deepEqual(
|
||||
asks.toArray().map((l) => l.price),
|
||||
[99, 100, 101],
|
||||
);
|
||||
});
|
||||
|
||||
test("truncate is a no-op when size <= depth", () => {
|
||||
const bids = new BookSide("bid", 5);
|
||||
bids.apply(100, 1);
|
||||
bids.apply(101, 2);
|
||||
bids.truncate();
|
||||
|
||||
assert.equal(bids.size, 2);
|
||||
assert.deepEqual(
|
||||
bids.toArray().map((l) => l.price),
|
||||
[101, 100],
|
||||
);
|
||||
});
|
||||
|
||||
test("phantom high bid disappears across a rally-then-drop (Kraken window)", () => {
|
||||
const depth = 3;
|
||||
const book = new LocalBook(depth);
|
||||
|
||||
// Snapshot during a rally.
|
||||
book.bids.apply(64920, 1);
|
||||
book.bids.apply(64921, 1);
|
||||
book.bids.apply(64922, 1);
|
||||
book.truncate();
|
||||
|
||||
// Higher bids push the low ones out of Kraken's top-3 window. Kraken sends
|
||||
// NO delete for the evicted 64920/64921 — only the client-side truncate
|
||||
// removes them. Without it they linger as phantoms.
|
||||
book.bids.apply(64924, 1);
|
||||
book.bids.apply(64925.9, 0.15);
|
||||
book.truncate();
|
||||
|
||||
// Market drops: in-window levels get explicit qty-0 deletes and lower bids
|
||||
// enter the window.
|
||||
book.bids.apply(64925.9, 0);
|
||||
book.bids.apply(64924, 0);
|
||||
book.bids.apply(64922, 0);
|
||||
book.bids.apply(64564, 1);
|
||||
book.bids.apply(64563, 1);
|
||||
book.bids.apply(64562, 1);
|
||||
book.truncate();
|
||||
|
||||
const top = book.bids.toArray();
|
||||
assert.equal(top.length, depth);
|
||||
assert.deepEqual(
|
||||
top.map((l) => l.price),
|
||||
[64564, 64563, 64562],
|
||||
);
|
||||
assert.ok(
|
||||
top.every((l) => l.price < 64900),
|
||||
"no phantom bid survives",
|
||||
);
|
||||
});
|
||||
@@ -8,7 +8,10 @@ import type { Level } from "../../domain/entities/index.js";
|
||||
export class BookSide {
|
||||
private levels = new Map<number, number>();
|
||||
|
||||
constructor(private readonly side: "bid" | "ask", private readonly depth: number) {}
|
||||
constructor(
|
||||
private readonly side: "bid" | "ask",
|
||||
private readonly depth: number,
|
||||
) {}
|
||||
|
||||
clear(): void {
|
||||
this.levels.clear();
|
||||
@@ -22,11 +25,27 @@ export class BookSide {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove levels beyond the best `depth` prices from the internal map.
|
||||
* Required by delta feeds that do NOT send deletes for levels evicted from
|
||||
* their top-N window (e.g. Kraken v2 `book`): without this, evicted levels
|
||||
* linger forever as phantom quotes.
|
||||
*/
|
||||
truncate(): void {
|
||||
if (this.levels.size <= this.depth) return;
|
||||
const prices = [...this.levels.keys()].sort((a, b) =>
|
||||
this.side === "bid" ? b - a : a - b,
|
||||
);
|
||||
for (const price of prices.slice(this.depth)) this.levels.delete(price);
|
||||
}
|
||||
|
||||
/** Sorted (bids desc, asks asc) and capped to `depth` levels. */
|
||||
toArray(): Level[] {
|
||||
const arr: Level[] = [];
|
||||
for (const [price, qty] of this.levels) arr.push({ price, qty });
|
||||
arr.sort((a, b) => (this.side === "bid" ? b.price - a.price : a.price - b.price));
|
||||
arr.sort((a, b) =>
|
||||
this.side === "bid" ? b.price - a.price : a.price - b.price,
|
||||
);
|
||||
return arr.length > this.depth ? arr.slice(0, this.depth) : arr;
|
||||
}
|
||||
|
||||
@@ -48,4 +67,10 @@ export class LocalBook {
|
||||
this.bids.clear();
|
||||
this.asks.clear();
|
||||
}
|
||||
|
||||
/** Truncate both sides to their depth (see BookSide.truncate). */
|
||||
truncate(): void {
|
||||
this.bids.truncate();
|
||||
this.asks.truncate();
|
||||
}
|
||||
}
|
||||
|
||||
+8
-1
@@ -2,9 +2,16 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<title>Arb Pulse — BTC Arbitrage Engine</title>
|
||||
<meta name="description" content="Real-time cross-exchange BTC arbitrage detection & simulation" />
|
||||
<meta name="theme-color" content="#0a0e14" />
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
<link rel="icon" type="image/png" sizes="192x192" href="/icon-192.png" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="Arb Pulse" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 28 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 33 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 254 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 254 KiB |
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "Arb Pulse — BTC Arbitrage Engine",
|
||||
"short_name": "Arb Pulse",
|
||||
"description": "Real-time cross-exchange BTC arbitrage detection & simulation (Kraken, Bybit, OKX, Binance).",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"orientation": "any",
|
||||
"theme_color": "#0a0e14",
|
||||
"background_color": "#0a0e14",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/icon-maskable-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
+13
-5
@@ -19,13 +19,16 @@ export function App(): JSX.Element {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-[1400px] px-4 py-6 lg:px-8">
|
||||
<header className="mb-6 flex items-end justify-between">
|
||||
<div className="mx-auto max-w-[1400px] px-4 pb-6 pt-[max(1.5rem,env(safe-area-inset-top))] lg:px-8">
|
||||
<header className="mb-6 flex items-end justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="font-display text-2xl font-extrabold tracking-tight text-slate-100">
|
||||
Arb<span className="text-accent">Pulse</span>
|
||||
</h1>
|
||||
<p className="text-sm text-slate-500">Real-time cross-exchange BTC arbitrage engine · Kraken · Bybit · OKX · Binance</p>
|
||||
<p className="text-sm text-slate-500">
|
||||
Real-time cross-exchange BTC arbitrage engine · Kraken · Bybit · OKX
|
||||
· Binance
|
||||
</p>
|
||||
</div>
|
||||
<p className="hidden text-xs text-slate-600 sm:block">
|
||||
BTC/USDT · WebSocket feeds · inventory model
|
||||
@@ -33,7 +36,9 @@ export function App(): JSX.Element {
|
||||
</header>
|
||||
|
||||
{!snapshot ? (
|
||||
<div className="flex h-[60vh] items-center justify-center text-slate-500">Connecting to engine…</div>
|
||||
<div className="flex h-[60vh] items-center justify-center text-slate-500">
|
||||
Connecting to engine…
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-5">
|
||||
<StatsBar stats={snapshot.stats} connected={connected} />
|
||||
@@ -47,7 +52,10 @@ export function App(): JSX.Element {
|
||||
<div className="space-y-5">
|
||||
<Controls stats={snapshot.stats} config={snapshot.config} />
|
||||
<ConfigPanel config={snapshot.config} />
|
||||
<Wallets wallets={snapshot.wallets} rebalances={snapshot.rebalances} />
|
||||
<Wallets
|
||||
wallets={snapshot.wallets}
|
||||
rebalances={snapshot.rebalances}
|
||||
/>
|
||||
<OpportunityFeed opportunities={snapshot.recentOpportunities} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -37,12 +37,21 @@ function Toggle({
|
||||
type="button"
|
||||
onClick={() => onChange(!on)}
|
||||
className={clsx(
|
||||
"flex items-center justify-between gap-3 rounded-xl border px-3 py-2 text-left text-sm transition",
|
||||
modified ? "border-accent/50 bg-accent/5" : "border-ink-600/50 bg-ink-700/30 hover:border-ink-500",
|
||||
"flex min-h-[44px] items-center justify-between gap-3 rounded-xl border px-3 py-2 text-left text-sm transition",
|
||||
modified
|
||||
? "border-accent/50 bg-accent/5"
|
||||
: "border-ink-600/50 bg-ink-700/30 hover:border-ink-500",
|
||||
)}
|
||||
>
|
||||
<span className={clsx(modified ? "text-accent" : "text-slate-300")}>{label}</span>
|
||||
<span className={clsx("relative h-5 w-9 rounded-full transition", on ? "bg-accent" : "bg-ink-500")}>
|
||||
<span className={clsx(modified ? "text-accent" : "text-slate-300")}>
|
||||
{label}
|
||||
</span>
|
||||
<span
|
||||
className={clsx(
|
||||
"relative h-5 w-9 rounded-full transition",
|
||||
on ? "bg-accent" : "bg-ink-500",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={clsx(
|
||||
"absolute top-0.5 h-4 w-4 rounded-full bg-white transition-all",
|
||||
@@ -77,11 +86,15 @@ function SliderRow({
|
||||
<div
|
||||
className={clsx(
|
||||
"rounded-xl border px-3 py-2",
|
||||
modified ? "border-accent/50 bg-accent/5" : "border-ink-600/50 bg-ink-700/30",
|
||||
modified
|
||||
? "border-accent/50 bg-accent/5"
|
||||
: "border-ink-600/50 bg-ink-700/30",
|
||||
)}
|
||||
>
|
||||
<div className="mb-1 flex items-center justify-between text-sm">
|
||||
<span className={clsx(modified ? "text-accent" : "text-slate-300")}>{label}</span>
|
||||
<span className={clsx(modified ? "text-accent" : "text-slate-300")}>
|
||||
{label}
|
||||
</span>
|
||||
<span className="font-mono text-accent">{valueLabel}</span>
|
||||
</div>
|
||||
<input
|
||||
@@ -179,7 +192,9 @@ export function ConfigPanel({ config }: Props): JSX.Element {
|
||||
/>
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-medium uppercase tracking-wider text-slate-500">Active exchanges</p>
|
||||
<p className="text-xs font-medium uppercase tracking-wider text-slate-500">
|
||||
Active exchanges
|
||||
</p>
|
||||
{EXCHANGES.map(({ id, label }) => (
|
||||
<Toggle
|
||||
key={id}
|
||||
|
||||
@@ -9,15 +9,28 @@ interface Props {
|
||||
config: PublicConfig;
|
||||
}
|
||||
|
||||
function Toggle({ label, on, onChange }: { label: string; on: boolean; onChange: (v: boolean) => void }): JSX.Element {
|
||||
function Toggle({
|
||||
label,
|
||||
on,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
on: boolean;
|
||||
onChange: (v: boolean) => void;
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange(!on)}
|
||||
className="flex items-center justify-between gap-3 rounded-xl border border-ink-600/50 bg-ink-700/30 px-3 py-2 text-left text-sm transition hover:border-ink-500"
|
||||
className="flex min-h-[44px] items-center justify-between gap-3 rounded-xl border border-ink-600/50 bg-ink-700/30 px-3 py-2 text-left text-sm transition hover:border-ink-500"
|
||||
>
|
||||
<span className="text-slate-300">{label}</span>
|
||||
<span className={clsx("relative h-5 w-9 rounded-full transition", on ? "bg-accent" : "bg-ink-500")}>
|
||||
<span
|
||||
className={clsx(
|
||||
"relative h-5 w-9 rounded-full transition",
|
||||
on ? "bg-accent" : "bg-ink-500",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={clsx(
|
||||
"absolute top-0.5 h-4 w-4 rounded-full bg-white transition-all",
|
||||
@@ -41,7 +54,7 @@ export function Controls({ stats, config }: Props): JSX.Element {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => control.resume()}
|
||||
className="rounded-xl border border-profit/40 bg-profit/10 px-3 py-2 text-sm font-semibold text-profit transition hover:bg-profit/20"
|
||||
className="min-h-[44px] rounded-xl border border-profit/40 bg-profit/10 px-3 py-2 text-sm font-semibold text-profit transition hover:bg-profit/20"
|
||||
>
|
||||
Resume
|
||||
</button>
|
||||
@@ -49,7 +62,7 @@ export function Controls({ stats, config }: Props): JSX.Element {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => control.pause()}
|
||||
className="rounded-xl border border-warn/40 bg-warn/10 px-3 py-2 text-sm font-semibold text-warn transition hover:bg-warn/20"
|
||||
className="min-h-[44px] rounded-xl border border-warn/40 bg-warn/10 px-3 py-2 text-sm font-semibold text-warn transition hover:bg-warn/20"
|
||||
>
|
||||
Pause
|
||||
</button>
|
||||
@@ -57,13 +70,17 @@ export function Controls({ stats, config }: Props): JSX.Element {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => control.reset()}
|
||||
className="rounded-xl border border-loss/40 bg-loss/10 px-3 py-2 text-sm font-semibold text-loss transition hover:bg-loss/20"
|
||||
className="min-h-[44px] rounded-xl border border-loss/40 bg-loss/10 px-3 py-2 text-sm font-semibold text-loss transition hover:bg-loss/20"
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Toggle label="Demo mode (synthetic feed)" on={stats.demoMode} onChange={(v) => control.setDemo(v)} />
|
||||
<Toggle
|
||||
label="Demo mode (synthetic feed)"
|
||||
on={stats.demoMode}
|
||||
onChange={(v) => control.setDemo(v)}
|
||||
/>
|
||||
|
||||
<div className="rounded-xl border border-ink-600/50 bg-ink-700/30 px-3 py-2">
|
||||
<div className="mb-1 flex items-center justify-between text-sm">
|
||||
@@ -89,7 +106,10 @@ export function Controls({ stats, config }: Props): JSX.Element {
|
||||
<div className="flex justify-between">
|
||||
<span>Taker fees</span>
|
||||
<span className="font-mono">
|
||||
K {pct(config.takerFees.kraken, 2)} · By {pct(config.takerFees.bybit, 2)} · O {pct(config.takerFees.okx, 2)} · Bn {pct(config.takerFees.binance, 2)}
|
||||
K {pct(config.takerFees.kraken, 2)} · By{" "}
|
||||
{pct(config.takerFees.bybit, 2)} · O{" "}
|
||||
{pct(config.takerFees.okx, 2)} · Bn{" "}
|
||||
{pct(config.takerFees.binance, 2)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 flex justify-between">
|
||||
|
||||
@@ -50,7 +50,83 @@ export function PriceMatrix({ quotes }: Props): JSX.Element {
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
<div className="overflow-hidden rounded-xl border border-ink-600/50">
|
||||
{/* Mobile: per-venue stacked cards (no horizontal overflow). */}
|
||||
<div className="grid grid-cols-1 gap-3 sm:hidden">
|
||||
{quotes.map((q) => {
|
||||
const spread =
|
||||
q.ask !== null && q.bid !== null ? q.ask - q.bid : null;
|
||||
return (
|
||||
<div
|
||||
key={q.exchange}
|
||||
className="rounded-xl border border-ink-600/50 bg-ink-700/30 p-3"
|
||||
>
|
||||
<div className="mb-2 flex items-center gap-2 font-display text-sm">
|
||||
<span
|
||||
className={clsx("h-2 w-2 rounded-full", statusDot(q.status))}
|
||||
/>
|
||||
{LABELS[q.exchange]}
|
||||
</div>
|
||||
<dl className="grid grid-cols-2 gap-x-4 gap-y-1.5 font-mono text-sm tabular-nums">
|
||||
<div className="flex items-center justify-between">
|
||||
<dt className="text-xs uppercase tracking-wider text-slate-500">
|
||||
Bid
|
||||
</dt>
|
||||
<dd
|
||||
className={clsx(
|
||||
q.bid !== null && q.bid === maxBid
|
||||
? "font-semibold text-profit"
|
||||
: "text-slate-300",
|
||||
)}
|
||||
>
|
||||
{q.bid !== null ? usd(q.bid) : "—"}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<dt className="text-xs uppercase tracking-wider text-slate-500">
|
||||
Bid Qty
|
||||
</dt>
|
||||
<dd className="text-slate-500">
|
||||
{q.bidQty?.toFixed(3) ?? "—"}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<dt className="text-xs uppercase tracking-wider text-slate-500">
|
||||
Ask
|
||||
</dt>
|
||||
<dd
|
||||
className={clsx(
|
||||
q.ask !== null && q.ask === minAsk
|
||||
? "font-semibold text-accent"
|
||||
: "text-slate-300",
|
||||
)}
|
||||
>
|
||||
{q.ask !== null ? usd(q.ask) : "—"}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<dt className="text-xs uppercase tracking-wider text-slate-500">
|
||||
Ask Qty
|
||||
</dt>
|
||||
<dd className="text-slate-500">
|
||||
{q.askQty?.toFixed(3) ?? "—"}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<dt className="text-xs uppercase tracking-wider text-slate-500">
|
||||
Spread
|
||||
</dt>
|
||||
<dd className="text-slate-400">
|
||||
{spread !== null ? usd(spread) : "—"}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Desktop / tablet: full table from the sm breakpoint up. */}
|
||||
<div className="hidden overflow-hidden rounded-xl border border-ink-600/50 sm:block">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-ink-700/50 text-left text-xs uppercase tracking-wider text-slate-400">
|
||||
@@ -64,34 +140,50 @@ export function PriceMatrix({ quotes }: Props): JSX.Element {
|
||||
</thead>
|
||||
<tbody className="font-mono tabular-nums">
|
||||
{quotes.map((q) => {
|
||||
const spread = q.ask !== null && q.bid !== null ? q.ask - q.bid : null;
|
||||
const spread =
|
||||
q.ask !== null && q.bid !== null ? q.ask - q.bid : null;
|
||||
return (
|
||||
<tr key={q.exchange} className="border-t border-ink-600/40">
|
||||
<td className="px-4 py-2.5">
|
||||
<span className="flex items-center gap-2 font-display text-sm">
|
||||
<span className={clsx("h-2 w-2 rounded-full", statusDot(q.status))} />
|
||||
<span
|
||||
className={clsx(
|
||||
"h-2 w-2 rounded-full",
|
||||
statusDot(q.status),
|
||||
)}
|
||||
/>
|
||||
{LABELS[q.exchange]}
|
||||
</span>
|
||||
</td>
|
||||
<td
|
||||
className={clsx(
|
||||
"px-4 py-2.5 text-right",
|
||||
q.bid !== null && q.bid === maxBid ? "font-semibold text-profit" : "text-slate-300",
|
||||
q.bid !== null && q.bid === maxBid
|
||||
? "font-semibold text-profit"
|
||||
: "text-slate-300",
|
||||
)}
|
||||
>
|
||||
{q.bid !== null ? usd(q.bid) : "—"}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-right text-slate-500">{q.bidQty?.toFixed(3) ?? "—"}</td>
|
||||
<td className="px-4 py-2.5 text-right text-slate-500">
|
||||
{q.bidQty?.toFixed(3) ?? "—"}
|
||||
</td>
|
||||
<td
|
||||
className={clsx(
|
||||
"px-4 py-2.5 text-right",
|
||||
q.ask !== null && q.ask === minAsk ? "font-semibold text-accent" : "text-slate-300",
|
||||
q.ask !== null && q.ask === minAsk
|
||||
? "font-semibold text-accent"
|
||||
: "text-slate-300",
|
||||
)}
|
||||
>
|
||||
{q.ask !== null ? usd(q.ask) : "—"}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-right text-slate-500">{q.askQty?.toFixed(3) ?? "—"}</td>
|
||||
<td className="px-4 py-2.5 text-right text-slate-400">{spread !== null ? usd(spread) : "—"}</td>
|
||||
<td className="px-4 py-2.5 text-right text-slate-500">
|
||||
{q.askQty?.toFixed(3) ?? "—"}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-right text-slate-400">
|
||||
{spread !== null ? usd(spread) : "—"}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -6,10 +6,20 @@ interface Props {
|
||||
connected: boolean;
|
||||
}
|
||||
|
||||
function Stat({ label, value, tone }: { label: string; value: string; tone?: "profit" | "loss" | "warn" }): JSX.Element {
|
||||
function Stat({
|
||||
label,
|
||||
value,
|
||||
tone,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
tone?: "profit" | "loss" | "warn";
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[10px] uppercase tracking-widest text-slate-500">{label}</span>
|
||||
<span className="text-[10px] uppercase tracking-widest text-slate-500">
|
||||
{label}
|
||||
</span>
|
||||
<span
|
||||
className={clsx(
|
||||
"font-mono text-lg font-semibold tabular-nums",
|
||||
@@ -25,14 +35,23 @@ function Stat({ label, value, tone }: { label: string; value: string; tone?: "pr
|
||||
);
|
||||
}
|
||||
|
||||
function circuitTone(c: EngineStats["circuit"]): { label: string; cls: string } {
|
||||
function circuitTone(c: EngineStats["circuit"]): {
|
||||
label: string;
|
||||
cls: string;
|
||||
} {
|
||||
switch (c) {
|
||||
case "running":
|
||||
return { label: "RUNNING", cls: "bg-profit/15 text-profit border-profit/40" };
|
||||
return {
|
||||
label: "RUNNING",
|
||||
cls: "bg-profit/15 text-profit border-profit/40",
|
||||
};
|
||||
case "paused":
|
||||
return { label: "PAUSED", cls: "bg-warn/15 text-warn border-warn/40" };
|
||||
case "tripped":
|
||||
return { label: "BREAKER TRIPPED", cls: "bg-loss/15 text-loss border-loss/40" };
|
||||
return {
|
||||
label: "BREAKER TRIPPED",
|
||||
cls: "bg-loss/15 text-loss border-loss/40",
|
||||
};
|
||||
default: {
|
||||
const _exhaustive: never = c;
|
||||
return { label: _exhaustive, cls: "" };
|
||||
@@ -45,29 +64,45 @@ export function StatsBar({ stats, connected }: Props): JSX.Element {
|
||||
const pnlTone = stats.realizedPnl >= 0 ? "profit" : "loss";
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-x-8 gap-y-4 rounded-2xl border border-ink-600/60 bg-ink-800/60 px-5 py-4 backdrop-blur-sm">
|
||||
<Stat label="Realized P&L" value={`$${usd(stats.realizedPnl)}`} tone={pnlTone} />
|
||||
<div className="flex flex-wrap items-center gap-x-6 gap-y-4 rounded-2xl border border-ink-600/60 bg-ink-800/60 px-4 py-4 backdrop-blur-sm sm:gap-x-8 sm:px-5">
|
||||
<Stat
|
||||
label="Realized P&L"
|
||||
value={`$${usd(stats.realizedPnl)}`}
|
||||
tone={pnlTone}
|
||||
/>
|
||||
<Stat label="Trades" value={String(stats.tradesExecuted)} />
|
||||
<Stat label="Opportunities" value={String(stats.opportunitiesDetected)} />
|
||||
<Stat label="Rejected" value={String(stats.tradesRejected)} tone="warn" />
|
||||
<Stat label="Ticks" value={stats.ticksProcessed.toLocaleString()} />
|
||||
<Stat label="Engine /tick" value={`${stats.avgTickMs.toFixed(3)}ms`} />
|
||||
<div className="ml-auto flex items-center gap-3">
|
||||
<div className="flex w-full flex-wrap items-center gap-2 sm:ml-auto sm:w-auto sm:gap-3">
|
||||
{stats.demoMode && (
|
||||
<span className="rounded-full border border-accent/40 bg-accent/15 px-3 py-1 text-xs font-semibold uppercase tracking-wider text-accent">
|
||||
Demo / Simulated Feed
|
||||
</span>
|
||||
)}
|
||||
<span className={clsx("rounded-full border px-3 py-1 text-xs font-semibold tracking-wider", circuit.cls)}>
|
||||
<span
|
||||
className={clsx(
|
||||
"rounded-full border px-3 py-1 text-xs font-semibold tracking-wider",
|
||||
circuit.cls,
|
||||
)}
|
||||
>
|
||||
{circuit.label}
|
||||
</span>
|
||||
<span
|
||||
className={clsx(
|
||||
"flex items-center gap-2 rounded-full border px-3 py-1 text-xs font-semibold tracking-wider",
|
||||
connected ? "border-profit/40 bg-profit/10 text-profit" : "border-loss/40 bg-loss/10 text-loss",
|
||||
connected
|
||||
? "border-profit/40 bg-profit/10 text-profit"
|
||||
: "border-loss/40 bg-loss/10 text-loss",
|
||||
)}
|
||||
>
|
||||
<span className={clsx("h-2 w-2 rounded-full", connected ? "bg-profit" : "bg-loss")} />
|
||||
<span
|
||||
className={clsx(
|
||||
"h-2 w-2 rounded-full",
|
||||
connected ? "bg-profit" : "bg-loss",
|
||||
)}
|
||||
/>
|
||||
{connected ? "LIVE" : "OFFLINE"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user