Files
Mauricio Barragan f6108d8f8f feat(deploy): automatic VPS deploy via GHCR on push to main
Build the Docker image in GitHub Actions, publish to
ghcr.io/mauricioabh/arbpulse (latest + sha tags), then SSH into the
Hetzner VPS to docker compose pull + up -d with a health-check gate.
The VPS no longer builds images, keeping CPU/RAM free for the running
apps and making rollbacks a matter of pulling a previous sha tag.

- .github/workflows/vps-deploy.yml: build-push (GHCR) + deploy (SSH) jobs
- deploy/docker-compose.yml: app image now ghcr.io/mauricioabh/arbpulse
- deploy/deploy.sh: pulls from GHCR by default (BUILD=1 for local build),
  default APP_DIR aligned to /root/projects/arbpulse
- docs: deploy/README.md CI/CD section + paths, README deploy section

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-19 12:23:49 -06:00

17 KiB
Raw Permalink Blame History

Arb Pulse

Detección y simulación de arbitraje de Bitcoin en tiempo real entre Kraken, Bybit, OKX y Binance.

Monolito Node.js + TypeScript: API REST, stream SSE y dashboard React servidos desde un solo proceso y una sola URL. Escucha libros de órdenes por WebSocket, calcula rentabilidad neta de fees y slippage VWAP, y simula ejecución con inventario pre-posicionado. No mueve fondos reales.


Descripción

Arb Pulse compara BTC/USDT en cuatro exchanges, camina el order book nivel a nivel, descarta oportunidades que no pagan comisiones taker, y —si pasan anti-flicker y riesgo— simula compra y venta en paralelo actualizando wallets virtuales por venue.

En mercado real verás sobre todo oportunidades rejected · fees (comportamiento esperado en mercados eficientes). El modo demo inyecta divergencias sintéticas con badge visible para demostrar el pipeline completo: detección → riesgo → ejecución → P&L → rebalanceo.


Capturas de pantalla

Feed en vivo (mercado real)

Badge LIVE, price matrix con cuatro venues, oportunidades rechazadas por fees y P&L en cero — el filtro económico es estricto.

Dashboard en modo live — feeds reales, oportunidades rechazadas por fees

Modo demo (feed sintético)

Badge Demo / Simulated Feed, trades simulados y curva de P&L — útil para presentaciones sin confundir datos sintéticos con mercado real.

Dashboard en modo demo — trades simulados y badge demo

Capturas completas (scroll): dashboard-live.png · dashboard-demo.png


Stack tecnológico

Capa Tecnología
Runtime Node.js 20+, TypeScript (strict), tsx
HTTP Express — REST + SSE + Swagger UI (/api-docs)
Feeds WebSocket nativo (ws) — APIs públicas
Frontend React 18, Vite, Tailwind CSS v3
Estado In-memory (sin base de datos)
Deploy VPS Hetzner — Docker Compose + GHCR (CI/CD en vps-deploy.yml); Fly.io como alternativa manual

No se requieren API keys: los feeds de mercado son públicos.


Instalación

Requisitos

Pasos

git clone https://github.com/mauricioabh/arbpulse.git
cd arbpulse

# Dependencias (raíz + frontend)
npm install
npm --prefix web install

# Variables opcionales (defaults en .env.example)
cp .env.example .env   # Linux / macOS
# copy .env.example .env   # Windows

# Build del frontend
npm run build

Arrancar

Producción local (un proceso, recomendado para probar el dashboard):

npm start
# → http://localhost:8080

Desarrollo (backend + Vite con hot reload):

npm run dev          # Backend :8080
npm run dev:web      # Frontend :5173 (proxy /api → :8080)

Modo demo (presentación con actividad visible en segundos):

# Windows PowerShell
$env:DEMO_MODE="true"; npm start
# Linux / macOS
DEMO_MODE=true npm start

Verificar

  1. Abre http://localhost:8080 — el badge debe mostrar LIVE (o Demo / Simulated Feed si usaste demo).
  2. La price matrix debe listar los cuatro exchanges con bid/ask numéricos.
  3. Healthcheck: GET http://localhost:8080/api/health{ "success": true, "data": { "status": "ok" } }.

Calidad de código

npm run typecheck    # TypeScript backend + frontend
npm test             # Tests unitarios (node:test)
npm run build        # Frontend Vite → web/dist

Los PRs hacia dev o main deben pasar GitHub Actions (CI / quality: typecheck, test, build).


Por qué existe y qué problema resuelve

En mercados líquidos, las divergencias de precio entre exchanges suelen desaparecer en neto en cuanto sumas comisiones taker y el costo real de cruzar el libro. Muchas demos de arbitraje muestran spreads brutos o asumen un slippage fijo del 0,1 %, y eso infla resultados.

Arb Pulse prioriza tres cosas:

  1. Precisión económica — VWAP nivel a nivel, fees taker por exchange, profit neto sin doble conteo de slippage.
  2. Modelo operativo creíble — inventario USDT + BTC en cada exchange; no se “transfiere BTC por trade” (eso tarda decenas de minutos on-chain).
  3. Honestidad en vivo — en feed real verás sobre todo rechazos por fees; el modo demo inyecta divergencias sintéticas con badge visible para demostrar el pipeline completo.

Decisiones de diseño que importan

Inventario pre-posicionado (no transferencia por operación)

El arbitraje cross-exchange real asume que ya tienes BTC y USDT en cada venue. En cada oportunidad: compras BTC donde está barato (gastas USDT) y vendes BTC donde está caro (recibes USDT), en paralelo, sin mover monedas entre exchanges en esa operación.

El inventario deriva con el tiempo. Un Rebalancer corrige desbalances cuando el ratio de BTC o USDT cae por debajo de umbrales. El withdrawal fee de red solo se aplica en rebalanceo, amortizado sobre muchos trades — no en cada arbitraje simulado. Cobrar withdrawal por trade es un error común en simuladores simplificados.

Mismo par en todos lados: BTC/USDT

Comparar BTC/USDT con BTC/USD mezcla basis de stablecoin y genera “arbitrajes” que no son ejecutables de forma consistente. Aquí los conectores normalizan BTC/USDT.

Slippage con VWAP, no un porcentaje fijo

El volumen objetivo recorre el libro bid/ask nivel a nivel (src/domain/services/vwap.ts). El profit neto usa los VWAP de compra y venta; el slippage ya está dentro de esos precios:

profit = sellVwap × vol × (1  feeSell)  buyVwap × vol × (1 + feeBuy)

Fees taker reales (el arbitraje siempre cruza el spread)

Exchange Taker (aprox.)
Kraken 0,26 %
Bybit 0,10 %
OKX 0,10 %
Binance 0,10 %

Consecuencia esperada en feed real: la mayoría de divergencias brutas salen rejected · fees en el log. Eso es comportamiento correcto, no un bug.

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.
  • Circuit breaker — tras N pérdidas consecutivas, pausa con cooldown.
  • Latency drift — entre detección y ejecución simulada se aplica deslizamiento adverso (P&L realista, no best-case).

Modo demo claramente etiquetado

Los arbitrajes netos positivos son raros en vivo. Con DEMO_MODE=true un feed sintético inyecta divergencias para mostrar detección → riesgo → ejecución → P&L → rebalanceo. El dashboard muestra el badge Demo / Simulated Feed; nunca debe confundirse con mercado real.

Opcionalmente, RECORD_FEED=true graba ticks en data/*.ndjson (ignorados por git; pueden pesar mucho).


Arquitectura

Exchanges (WS: Kraken v2, Bybit v5, OKX v5, Binance depth10@100ms + REST fallback)
   │  libros normalizados (snapshot + deltas, staleness)
   ▼
OrderBookManager ── mejor bid/ask y frescura por venue
   ▼
ArbitrageEngine ── matriz N×N · VWAP · profit neto · scoring · anti-flicker
   ▼
RiskManager ── umbral mínimo · circuit breaker · pause/resume
   ▼
ExecutionSimulator ── partial fills · latency drift · wallets
   ▼                         ▲
Rebalancer ── corrección de inventario (withdrawal fee en rebalanceo)
   ▼
Store (in-memory) + FeedRecorder (NDJSON opcional)
   ▼
API REST + SSE ──► Dashboard React

SSE para el dashboard — el flujo servidor→cliente es unidireccional; SSE reconecta sobre HTTP sin un segundo WebSocket hacia el navegador.

Monolito en Fly.io — el motor necesita conexiones WS persistentes y estado en memoria continuo; no encaja en serverless efímero. Un proceso Node sirve /api, /api/stream y los estáticos de web/dist.

El dominio (src/domain/) no importa infraestructura ni HTTP; los casos de uso en application/ orquestan; composition/bootstrap.ts cablea adaptadores concretos.


Estructura del repositorio

src/
  index.ts                    Express + bootstrap + estáticos web/dist
  composition/                bootstrap.ts, application-service.ts (wiring)
  domain/
    entities/                 OrderBook, Opportunity, Trade, Wallet, …
    ports/                    MarketDataFeed, IQuoteBook, TradingPolicy, …
    services/                 vwap, pricing, ArbitrageEngine
  application/
    use-cases/                ProcessOrderBookUpdate, ExecuteArbitrage, …
  infrastructure/
    exchanges/                WS Kraken / Bybit / OKX / Binance
    demo/                     SyntheticFeed, FeedRecorder (NDJSON)
    state/                    Store, WalletBook, OrderBookManager
    config/                   config.ts, runtime.ts (único lector de env)
    simulation/               ExecutionSimulator, RiskManager
    rebalancing/              Rebalancer
    logging/                  logger
  interfaces/
    http/                     REST → ApplicationService, OpenAPI spec (`openapi.ts`)
    sse/                      SSE hub
  test-support/               fakes + FakeMarketDataFeed (tests)
web/
  src/                  Dashboard (StatsBar, PriceMatrix, PnL, TradeLog, Wallets, Controls, ConfigPanel)
Dockerfile              Imagen Node 20 (build Vite + tsx en prod)
fly.toml                Región, env vars y healthcheck Fly.io
.env.example            Variables documentadas (sin secretos)

Dashboard

El frontend consume solo la API del mismo origen (en dev, Vite hace proxy de /api al backend).

Panel Contenido
Stats bar P&L realizado, trades, rechazos, ticks, ms/tick del motor, estado LIVE/OFFLINE, circuit breaker
Price matrix Mejor bid/ask por exchange y frescura
P&L chart Serie temporal de beneficio simulado
Trade log Ejecuciones y rechazos con motivo
Wallets USDT y BTC por venue + rebalanceos recientes
Opportunity feed Oportunidades detectadas (ejecutadas o no)
Controls Pausa/reanudar, reset, demo on/off, umbral de edge mínimo, resumen de fees y stale/confirm
Live config Min net profit, volumen por trade, ventana anti-flicker, activar/desactivar exchanges por venue

Variables de entorno

Copia .env.example a .env si quieres overrides locales. Todas son opcionales.

Variable Default Descripción
PORT 8080 Puerto HTTP
DEMO_MODE false Feed sintético con divergencias inyectadas
MIN_NET_PROFIT_PCT 0.0005 Edge neto mínimo para ejecutar (0,05 %)
MAX_TRADE_BTC 0.25 Volumen máximo por trade simulado
STALE_MS 3000 Ignorar quotes más viejos que esto
FLICKER_CONFIRM_MS 150 Persistencia mínima antes de ejecutar
LATENCY_MS 120 Latencia simulada detección → ejecución
LATENCY_SLIPPAGE_BPS 2 Drift adverso de precio durante la latencia
CIRCUIT_BREAKER_LOSSES 5 Pérdidas seguidas antes de pausar
CIRCUIT_BREAKER_COOLDOWN_MS 15000 Cooldown del circuit breaker
INITIAL_USDT 50000 USDT inicial por exchange
INITIAL_BTC 0.5 BTC inicial por exchange
RECORD_FEED false Graba ticks en data/*.ndjson

Umbral, volumen máximo, anti-flicker, exchanges activos y demo también se cambian en vivo desde el dashboard (Controls + Live config). La grabación NDJSON (RECORD_FEED / POST /api/control/record) puede activarse en caliente vía API.


API HTTP

Respuestas REST siguen la forma { success, data?, error? }.

Método Ruta Descripción
GET /api/health Healthcheck (Fly.io)
GET /api/state Snapshot completo del estado
GET /api/stream SSE — snapshots en tiempo real
GET /api/config Configuración del motor (fees, umbrales, exchanges activos)
PATCH /api/config Actualización parcial: { minNetProfitPct?, maxTradeBtc?, flickerConfirmMs?, activeExchanges? }
POST /api/control/pause Pausar motor
POST /api/control/resume Reanudar
POST /api/control/reset Reiniciar estado simulado
POST /api/control/demo { "enabled": boolean }
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/max-trade { "btc": number } (atajo de PATCH /api/config)
GET /api-docs Scalar — 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.


Deploy (producción: VPS Hetzner + GHCR)

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. 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).

Detalles, bootstrap manual y rollback: deploy/README.md.

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.


Qué esperar en producción con feed real

  • Conexión estable a los cuatro feeds (Binance usa REST polling si el WS cae).
  • Motor procesando ticks en sub-milisegundo medio (visible como Engine /tick).
  • Pocas o ninguna ejecución rentable en neto; muchos eventos rejected · fees.
  • Wallets y rebalanceos reflejando el modelo de inventario, no transferencias por trade.

Eso no indica que el bot “no funcione”: indica que el filtro económico es estricto.


Roadmap

  • Arbitraje triangular (misma exchange, otra ruta de precios).
  • Replay determinista desde NDJSON grabado (hoy: recorder; loader en evolución).

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.messageorderbook.processarbitrage.evaluatesse.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

MIT — ver archivo LICENSE cuando se añada al repositorio.


Créditos y contexto

Proyecto de detección y simulación educativa/experimental de arbitraje BTC. No es asesoramiento financiero ni ejecución real de órdenes. Los fees y umbrales reflejan configuración documentada en código; verifica siempre las tablas oficiales de cada exchange antes de operar con capital real.