Cómo construí AutoP2P
AutoP2P nació de una frustración concreta. Operaba anuncios en Binance P2P y la dinámica del mercado es despiadada: si un competidor ajusta su precio un centavo mejor que el tuyo, tu anuncio cae en el ranking y dejás de recibir órdenes. La solución manual era revisar el feed cada 10-15 minutos. Insostenible.
Decidí automatizarlo. Lo que empezó como un script de 80 líneas terminó siendo una plataforma con un motor de competencia de casi 5.000 líneas, un sistema de rate limiting multicapa, múltiples estrategias de trading configurables y un panel de control con actualizaciones en tiempo real via WebSocket.
El problema de fondo
En Binance P2P, la posición en el feed la determina el precio. El primero en aparecer captura la mayoría de las órdenes. Eso crea un juego de suma cero: todos compiten por la posición #1 moviendo su precio un tick por delante del competidor.
Automatizar esa lógica parece simple. El desafío real está en hacerlo de manera robusta:
- La API tiene rate limits agresivos. Con múltiples anuncios activos y ciclos cortos, es trivial excederlos si no los modelás explícitamente.
- Los errores son heterogéneos y muchos no están documentados. Hay condiciones de throttling que devuelven respuestas vacías en vez de un código de error estándar.
- El estado del sistema (precio actual, volumen disponible, anuncio activo/inactivo) puede divergir entre lo que tenés en memoria y lo que hay en la plataforma.
- Múltiples anuncios del mismo par pueden entrar en conflicto entre sí bajo reglas de la plataforma que no son evidentes hasta que las encontrás en producción.
Cada uno de esos puntos requirió investigación, un modo de fallo, y una estrategia de recuperación explícita.
Arquitectura
┌─────────────────────────────────────┐
│ Frontend (React 19 + Vite) │
│ Dashboard, charts, config forms │
└────────────────┬────────────────────┘
│ WebSocket + REST (token auth)
┌────────────────▼────────────────────┐
│ Backend (FastAPI + asyncio) │
│ │
│ ┌──────────────────────────────┐ │
│ │ EngineManager │ │
│ │ ┌──────────┐ ┌──────────┐ │ │
│ │ │ Engine 1 │ │ Engine N │ │ │
│ │ └──────────┘ └──────────┘ │ │
│ └──────────────────────────────┘ │
│ │
│ GlobalRateLimiter (singleton) │
│ RealtimeBroker (WS / SSE) │
│ SchedulerService (ventanas horarias)│
└────────────────┬────────────────────┘
│ HTTP + auth
┌────────────────▼────────────────────┐
│ API externa (Binance P2P) │
└─────────────────────────────────────┘
Persistencia: PostgreSQL · Redis (opcional)
Infra: Docker · Nginx · VPS propio
El backend corre en VPS propio con Docker. El frontend es una SPA conectada via WebSocket para ver en tiempo real lo que hace cada engine.
El motor de competencia
El componente central del sistema. Cada anuncio tiene su propia instancia corriendo en un loop asyncio independiente. El orquestador crea, destruye y actualiza engines sin reiniciar el proceso completo cuando cambia la configuración — esto es crítico para no perder ciclos en producción.
Ciclo de iteración
┌─────────────────────────────────────────────────────────┐
│ ENGINE ITERATION CYCLE │
│ │
│ 1. PREFLIGHT ──── ad pausado? backoff activo? → skip │
│ │ │
│ 2. FETCH ──────── order_book + our_price (paralelo) │
│ │ asyncio.gather() │
│ 3. PARSE ──────── normalizar → List[OrderBookEntry] │
│ │ │
│ 4. CONFIG ─────── cargar estrategia + parámetros │
│ │ │
│ 5. PAGINATE ───── buscar target en páginas adicionales │
│ │ (solo estrategia FOLLOW) │
│ 6. STRATEGY ───── pipeline multifase → StrategyResult │
│ │ │
│ 7. LOG/EMIT ───── log estructurado + evento WebSocket │
│ │ │
│ 8. EXECUTE ────── update precio si result.should_update│
│ │ │
│ 9. METRICS ────── ventana deslizante + heartbeat │
└─────────────────────────────────────────────────────────┘
El fetch paralelo en la fase 2 fue una de las primeras optimizaciones reales. Antes hacía las dos requests en serie; con asyncio.gather la latencia del ciclo se redujo casi a la mitad. Emitir la decisión al frontend antes de ejecutar el update significa que el dashboard refleja lo que va a pasar, no lo que ya pasó.
Máquina de estados
┌─────────┐
start() │ │
┌──────────────►│ STARTING│
│ │ │
│ └────┬────┘
│ │ ok
│ ┌────▼────┐ watchdog ┌─────────┐
│ │ ├──────timeout──────►│ STALLED │
│ │ RUNNING │ └────┬────┘
│ │ │◄──────restart──────────┘
│ └────┬────┘
│ │
│ ┌─────────┴──────────┐
│ │ │
│ ┌──────▼──────┐ ┌────────▼────────┐
│ │ DISABLED │ │ ERROR_EXTERNAL │
│ │ _BY_SYSTEM │ │ │
│ └──────┬──────┘ └────────┬────────┘
│ │ ad vuelve │ cooldown
│ └──────────┬─────────┘
│ │
└─────────────────────┘
stop() desde cualquier estado → STOPPED
Cuando la plataforma desactiva un anuncio, el engine entra en DISABLED_BY_SYSTEM y arranca un loop de verificación periódica. Cuando el ad vuelve a estar operativo, se reactiva solo — crítico para operar con horarios nocturnos sin intervención manual.
El watchdog detecta stalls: si el tiempo desde la última iteración supera un umbral configurable, marca el engine y alerta. Para evitar restart storms, los reinicios automáticos tienen un rate limit propio separado del rate limit de la API.
Rate limiting: el problema más difícil
Este fue el problema que más iteraciones requirió. Un sistema de rate limiting mal diseñado falla de formas impredecibles en producción — a veces silenciosamente, que es peor. La solución final es una arquitectura de tres capas independientes con responsabilidades distintas.
Request entrante al cliente HTTP
│
┌───────▼────────────────────────────────┐
│ CAPA 3: HTTP client │
│ Retry con jitter exponencial │
│ Dispersa reintentos en el tiempo │
└───────┬────────────────────────────────┘
│
┌───────▼────────────────────────────────┐
│ CAPA 2: Coordinador global (singleton)│
│ ┌─────────────────────────────────┐ │
│ │ Token bucket → limita RPS │ │
│ │ Semáforo → limita concur. │ │
│ │ Weight tracker → backoff proact.│ │
│ │ Circuit breaker→ OPEN/HALF/CLOSED│ │
│ └─────────────────────────────────┘ │
└───────┬────────────────────────────────┘
│
┌───────▼────────────────────────────────┐
│ CAPA 1: Aislamiento por anuncio │
│ Backoff independiente por ad │
│ Fallo en ad-1 no afecta ad-2 │
└───────┬────────────────────────────────┘
│
API
Capa 1: aislamiento por anuncio
Cada anuncio mantiene su propio estado de backoff. Si un anuncio falla repetidamente — por conflicto de precio, por estar offline — su backoff crece de forma exponencial e independiente sin penalizar al resto del sistema. Cuando vuelve a funcionar, su contador se resetea.
El principio es el mismo del bulkhead pattern: el fallo de un componente no debe propagarse al sistema completo.
Capa 2: coordinador global
El singleton que coordina todas las requests del proceso. El punto más importante es el backoff proactivo: en vez de esperar a recibir un error de la plataforma, el sistema se frena por su cuenta cuando el consumo de peso se acerca al límite. Esto evita suspensiones completas que afectarían a todos los engines simultáneamente.
La secuencia de adquisición para cada request es: circuit breaker → semáforo de concurrencia → token bucket → verificación de peso acumulado. Si cualquiera de los checks falla, la request se bloquea antes de salir al exterior.
Circuit breaker
Requests normales
│
┌─────▼─────┐ N fallos consecutivos ┌──────────┐
│ CLOSED ├──────────────────────────►│ OPEN │
│ (normal) │ │(bloqueado)│
└─────▲─────┘ └────┬─────┘
│ │ timeout
│ 1 request de prueba ┌─────▼─────┐
└──────────────────────────────── │ HALF_OPEN │
éxito │ (prueba) │
└───────────┘
fallo → vuelve a OPEN
Capa 3: retry con jitter
El jitter es lo que hace que el retry sea útil en un sistema multi-engine. Sin él, múltiples engines que fallan al mismo tiempo se sincronizan y reintentan en ráfaga, agravando exactamente el problema que causó el fallo original. Con jitter exponencial, los reintentos se dispersan en el tiempo:
# Pseudocódigo — patrón ilustrativo
for intento in range(MAX_REINTENTOS):
try:
return await ejecutar_request()
except ErrorRetriable:
espera_base = BASE * (2 ** intento)
jitter = random(0, espera_base)
await sleep((espera_base + jitter) / 1000)
raise MaxIntentosExcedido()
El caso más traicionero
La plataforma a veces devuelve respuestas vacías como señal implícita de throttling, en vez de un código de error estándar. Sin manejo específico, el sistema interpreta eso como “no hay anuncios en el mercado” — silenciosamente incorrecto, y potencialmente muy costoso si el engine decide actualizar el precio con datos inválidos.
La solución fue agregar un contador de respuestas vacías consecutivas. Si supera un umbral, se eleva una excepción de throttling implícito que sube por el stack y activa el backoff del coordinador global. El detector usa una ventana corta para no disparar falsos positivos en mercados genuinamente bajos en actividad.
Estrategias de trading
Todas las estrategias se ejecutan por un pipeline unificado que procesa el order book en múltiples fases: filtrado, selección de target, cálculo de precio, y aplicación de políticas post-cálculo.
Order book raw
│
┌───▼──────────────────────────────────────┐
│ PIPELINE UnifiedStrategy │
│ │
│ 1. Normalizar config │
│ 2. Filtrar: exclusiones de nickname │
│ 3. Filtrar: métodos de pago │
│ 4. Filtrar: volumen mínimo │
│ 5. Filtrar: universo de precios │
│ 6. Seleccionar target (posición o nick) │
│ 7. Validar no auto-selección │
│ 8. Reglas condicionales por nick │
│ 9. Optimizar margen si TOP-1 │
│ 10. Calcular precio objetivo │
│ 11. ClampPolicy [price_min, price_max] │
│ 12. RatchetPolicy (anti guerra precios) │
└───┬──────────────────────────────────────┘
│
StrategyResult { price, action, reason }
TOP-1: mantener la primera posición
La estrategia busca estar siempre en el primer lugar del feed. Cuando ya se es líder, no baja el precio indefinidamente — verifica si hay margen para subir sin perder la posición y lo recupera cuando puede. Eso evita un deterioro gradual del precio en mercados donde la competencia está quieta.
El caso de no ser #1 es simple: superar al líder actual por la mínima diferencia posible.
FOLLOW: seguir a un competidor específico
Sigue la estrategia de precios de un competidor identificado por nickname. Tiene un comportamiento configurable para cuando el target no aparece en el order book: puede mantener el precio actual, o hacer fallback automático a TOP-1. El sistema maneja la paginación del book si el competidor está más allá de los primeros resultados de la API.
Anti-ratchet: evitar guerras de precios
Sin esta política, dos bots en TOP-1 compitiendo entre sí pueden llevarse el precio hasta el suelo en minutos. La política anti-ratchet detecta cuándo el competidor ya se movió en nuestra dirección y nosotros seguimos siendo líderes — en ese caso, no sigue bajando. El sistema frena la carrera antes de que se vuelva destructiva para ambas partes.
Precios relativos al tipo de cambio
En vez de fijar rangos de precio absolutos, podés configurar márgenes en USD que se convierten automáticamente al tipo de cambio del momento. El rango se recalcula justo antes del update (no al inicio del ciclo) para no operar con valores desactualizados si la cotización cambió durante la iteración. Hay un TTL corto en el caché de la tasa de cambio por esta razón.
Manejo de errores heterogéneos
La plataforma tiene categorías de error con comportamientos muy distintos. El insight más importante del proyecto fue que no podés tratarlos de forma uniforme — necesitás clasificarlos explícitamente y tener una estrategia específica para cada uno.
Las categorías principales y su tratamiento:
| Categoría | Estrategia |
|---|---|
| Sync de tiempo | Re-sincronizar reloj → reintentar |
| Inconsistencia de estado | Refetch de datos frescos → reintentar |
| Rango de precio excedido | Detectar rango válido → ajustar precio → reintentar |
| Anuncio offline | Entrar en DISABLED_BY_SYSTEM → verificar periódicamente |
| Conflicto entre anuncios propios | Calcular zona de exclusión → ajustar → reintentar |
| No retriable | Cooldown largo → alerta |
El caso más interesante es el de rango de precio dinámico: la plataforma restringe los precios a una banda que cambia durante el día. Cuando el update falla por esto, el sistema tiene tres capas de fallback para encontrar el rango válido actual:
- Intentar extraer el rango directamente del mensaje de error
- Estimarlo desde el tipo de cambio actual si el paso 1 falla
- Usar el último rango conocido en caché como último recurso
Con esas tres capas, la tasa de updates que fallan definitivamente por rango es prácticamente cero.
Observabilidad en tiempo real
El frontend recibe eventos via WebSocket organizado en canales independientes por dominio:
Backend Frontend
│ │
│──── ads channel ──────────────────►│ precios, cache invalidation
│ │
│──── engine channel ───────────────►│ ciclos, decisiones, posición
│ │
│──── orders channel (poll) ────────►│ nuevas órdenes, estado chat
│ │
│──── system channel ───────────────►│ heartbeat, rate limiter status
Cada ciclo del engine emite su resultado al canal correspondiente antes de que el update se ejecute. Esto significa que el dashboard muestra la intención del sistema en tiempo real, no solo el resultado final. Cuando el engine decide no actualizar el precio (hold por anti-ratchet, por ejemplo), eso también aparece en el frontend con la razón específica.
El backend también expone métricas compatibles con Prometheus: latencia de ciclo, consumo de rate limit, estado del circuit breaker y contadores de error por categoría.
Invalidación de caché
Cuando el engine actualiza un precio, el caché HTTP del frontend puede estar desactualizado. El sistema usa event-driven cache invalidation: el engine emite un evento al bus interno cuando actualiza, el broker lo recibe, invalida las claves relevantes en Redis, y emite un evento al frontend para que refetchee. La paridad entre el estado real y lo que muestra el dashboard fue uno de los problemas más persistentes del desarrollo.
Scheduling: ventanas horarias
Los anuncios se pueden agrupar y asociar a ventanas horarias con timezone explícita. El scheduler evalúa periódicamente si cada engine debe estar activo o pausado según las reglas configuradas.
Cuando múltiples grupos aplican al mismo anuncio con reglas conflictivas, hay un sistema de prioridades que determina cuál gana. El scheduler también crea configuraciones por defecto automáticamente para anuncios que nunca fueron configurados explícitamente.
Lo que más costó
El rate limiting fue el problema que más iteraciones requirió. El primer sistema falló en producción la primera semana porque modelé límites por request, no por peso acumulado. Llegar al límite de peso suspende todas las requests del proceso por un período fijo — no solo las del anuncio que lo causó. Reescribir eso correctamente, con el coordinador global y el backoff proactivo, tomó varios días.
La paridad de estado entre frontend y backend es un problema constante en sistemas de tiempo real. Implementar invalidación de caché event-driven fue la solución correcta, pero lleva tiempo llegar a ese diseño.
El panel de control terminó siendo el 70% del trabajo total. El motor de repricing es lo más interesante de construir, pero los usuarios necesitan ver qué está pasando en tiempo real, configurar estrategias sin posibilidad de configuraciones inválidas, y saber cuándo hay una orden que requiere atención. Todo eso es mucho más trabajo de lo que parece antes de empezarlo.
Números en producción
Con múltiples anuncios activos corriendo en paralelo:
| Métrica | Valor |
|---|---|
| Latencia de ciclo (p95) | ~1.2 segundos |
| Consumo de rate limit | 20-25% del límite (backoff proactivo) |
| Uptime | 99.5%+ |
| Win rate de updates | 85-95% en primer intento |
El sistema corre en producción desde 2023. Las iteraciones más importantes desde entonces fueron todas alrededor del manejo de errores: cada vez que aparecía un caso nuevo en producción, se agregaba una categoría explícita con su estrategia de recuperación.
Si construís algo similar, el consejo más concreto que puedo dar: diseñá el sistema de rate limiting antes de tocar el motor de negocio. Es lo más difícil de retrofittear y lo que más daño hace si falla silenciosamente en producción. El modelo mental correcto es “qué pasa si 20 componentes intentan hacer requests en el mismo millisecond” — no “qué pasa con una request”.