Construir un Bot de Trading P2P con la API Binance C2C
Tutorial completo para construir un bot P2P automatizado en Binance C2C desde cero. Cubre el loop completo: monitorear el order book, gestionar anuncios, procesar órdenes entrantes e integrar el sistema de chat.
1. Vista General de Arquitectura
Un bot P2P en producción necesita cinco componentes centrales trabajando juntos en un event loop asíncrono:
┌─────────────────────────────────────────────────────────────────┐ │ Arquitectura del Bot P2P │ ├─────────────────────────────────────────────────────────────────┤ │ │ │ ┌──────────────┐ ┌───────────────┐ ┌────────────────┐ │ │ │ Market Data │ │ Gestor de │ │ Procesador │ │ │ │ (EP-0) │───▶│ Anuncios │ │ de Órdenes │ │ │ │ Poll loop │ │ (EP-4,7,8) │ │ (EP-13,15) │ │ │ └──────────────┘ └───────────────┘ └────────────────┘ │ │ │ │ │ ┌──────────────┐ ┌───────────────┐ │ │ │ │ Sistema de │◀───│ WebSocket │◀──────────┘ │ │ │ Chat │ │ (cred EP-33) │ │ │ │ (EP-33,34) │ │ Tiempo real │ │ │ └──────────────┘ └───────────────┘ │ │ │ │ ┌──────────────────────────────────────────────────────────┐ │ │ │ Rate Limiter + Circuit Breaker (todas las llamadas) │ │ │ └──────────────────────────────────────────────────────────┘ │ └─────────────────────────────────────────────────────────────────┘
El loop de market data se ejecuta cada pocos segundos y alimenta al gestor de anuncios. El procesador de órdenes detecta nuevas órdenes y dispara el sistema de chat. Todas las llamadas a Binance pasan por un rate limiter compartido para evitar soft limits y circuit breakers.
2. Configuración de Autenticación
La mayoría de los endpoints requieren firmas HMAC-SHA256. Consulta la guía de Primeros Pasos para la configuración completa de firma. Aquí está el helper reutilizable que usarás en todo el tutorial:
import hmac
import hashlib
import time
import httpx
BASE_URL = "https://api.binance.com"
def sign(api_secret: str, query: str) -> str:
return hmac.new(
api_secret.encode(),
query.encode(),
hashlib.sha256,
).hexdigest()
async def signed_post(
client: httpx.AsyncClient,
api_key: str,
api_secret: str,
path: str,
body: dict,
) -> dict:
timestamp = int(time.time() * 1000)
query = f"recvWindow=60000×tamp={timestamp}"
signature = sign(api_secret, query)
resp = await client.post(
f"{BASE_URL}{path}?{query}&signature={signature}",
json=body,
headers={"X-MBX-APIKEY": api_key, "clientType": "web"},
)
return resp.json()3. Monitorear el Order Book (EP-0)
EP-0 es el endpoint público BAPI de búsqueda — no requiere autenticación. Es la fuente principal para entender el paisaje competitivo. Consulta cada 5–30 segundos según la volatilidad del mercado.
tradeType=BUY significa "quiero comprar" — devuelve anuncios SELL (vendedores). Usa SELL para obtener anuncios de compradores. Siempre pasa publisherType: "merchant" para evitar clasificar erróneamente a merchants como usuarios regulares.import httpx
import asyncio
BAPI_URL = "https://p2p.binance.com/bapi/c2c/v2/friendly/c2c/adv/search"
async def fetch_order_book(
asset: str,
fiat: str,
trade_type: str, # "BUY" = quiero comprar → devuelve vendedores
pages: int = 3,
) -> list[dict]:
"""Obtener hasta pages * 20 anuncios del order book público."""
all_ads: list[dict] = []
headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
"Content-Type": "application/json",
}
async with httpx.AsyncClient() as client:
for page in range(1, pages + 1):
resp = await client.post(
BAPI_URL,
json={
"asset": asset,
"fiat": fiat,
"tradeType": trade_type,
"page": page,
"rows": 20,
"publisherType": "merchant",
},
headers=headers,
)
data = resp.json().get("data", [])
if not data:
break # Vacío = sin más páginas o soft rate limit
all_ads.extend(data)
if page < pages:
await asyncio.sleep(0.1) # 100ms entre páginas
return all_ads
async def poll_order_book(asset: str, fiat: str, interval_sec: float = 10.0):
"""Loop de polling continuo."""
while True:
try:
sellers = await fetch_order_book(asset, fiat, "BUY")
buyers = await fetch_order_book(asset, fiat, "SELL")
yield {"sellers": sellers, "buyers": buyers}
except Exception as e:
print(f"Error order book: {e}")
await asyncio.sleep(interval_sec)4. Gestionar Anuncios (EP-4, EP-7, EP-8)
EP-4 lista tus anuncios activos. EP-7 actualiza el precio (y opcionalmente el estado). EP-8 actualiza el estado en lote (online/offline). La regla crítica en EP-7: envía únicamente advNo + price — incluir surplusAmount causa el error 187049.
import httpx
from auth import signed_post
async def list_my_ads(
client: httpx.AsyncClient,
api_key: str,
api_secret: str,
) -> list[dict]:
"""EP-4: Listar todos los anuncios activos con paginación."""
result = await signed_post(client, api_key, api_secret,
"/sapi/v1/c2c/ads/listWithPagination",
{"page": 1, "rows": 100},
)
return result.get("data", [])
async def update_ad_price(
client: httpx.AsyncClient,
api_key: str,
api_secret: str,
adv_no: str,
price: str,
) -> bool:
"""EP-7: Actualización solo de precio. NUNCA incluir surplusAmount."""
result = await signed_post(client, api_key, api_secret,
"/sapi/v1/c2c/ads/update",
# CRÍTICO: solo advNo + price
{"advNo": adv_no, "price": price},
)
return result.get("success", False)
async def set_ads_online(
client: httpx.AsyncClient,
api_key: str,
api_secret: str,
adv_nos: list[str],
) -> bool:
"""EP-8: Poner anuncios online en lote (advStatus=1)."""
result = await signed_post(client, api_key, api_secret,
"/sapi/v1/c2c/ads/updateStatus",
{"advNos": adv_nos, "advStatus": 1},
)
if result.get("code") == "-1102":
# Fallback: algunos entornos necesitan CSV string
result = await signed_post(client, api_key, api_secret,
"/sapi/v1/c2c/ads/updateStatus",
{"advNos": ",".join(adv_nos), "advStatus": 1},
)
return result.get("success", False)5. Procesar Órdenes (EP-13, EP-15)
EP-15 lista tus órdenes abiertas. EP-13 obtiene el detalle de una orden específica. EP-13 usa auth adaptativa: primero intenta solo con API key (sin HMAC), luego cae al HMAC completo si es rechazada.
import httpx
from auth import signed_post, BASE_URL
async def list_open_orders(
client: httpx.AsyncClient,
api_key: str,
api_secret: str,
) -> list[dict]:
"""EP-15: Listar todas las órdenes con paginación."""
result = await signed_post(client, api_key, api_secret,
"/sapi/v1/c2c/orderMatch/listOrders",
{"page": 1, "rows": 20},
)
return result.get("data", [])
async def get_order_detail(
client: httpx.AsyncClient,
api_key: str,
api_secret: str,
order_no: str,
) -> dict:
"""EP-13: Auth adaptativa — sin firma primero, HMAC como fallback."""
url = f"{BASE_URL}/sapi/v1/c2c/orderMatch/getUserOrderDetail"
# Intento 1: solo API key (sin HMAC)
resp = await client.post(
url,
json={"adOrderNo": order_no},
headers={"X-MBX-APIKEY": api_key, "clientType": "web"},
)
data = resp.json()
if resp.status_code in (401, 403) or data.get("code") in ("-1021", "-1022"):
# Intento 2: HMAC completo
data = await signed_post(client, api_key, api_secret,
"/sapi/v1/c2c/orderMatch/getUserOrderDetail",
{"adOrderNo": order_no},
)
return data.get("data", {})6. Integración de Chat (EP-33, EP-34)
EP-33 devuelve una credencial WebSocket de vida corta. Conéctate a la URL WebSocket y autentícate con el listenKey. EP-34 recupera mensajes existentes con paginación.
import json
import websockets
import httpx
from auth import signed_post
async def get_chat_credential(
client: httpx.AsyncClient,
api_key: str,
api_secret: str,
) -> dict:
"""EP-33: Obtener credencial WebSocket para chat en tiempo real."""
import hmac, hashlib, time
from auth import sign, BASE_URL
timestamp = int(time.time() * 1000)
query = f"recvWindow=60000×tamp={timestamp}"
signature = sign(api_secret, query)
resp = await client.get(
f"{BASE_URL}/sapi/v1/c2c/chat/retrieveChatCredential?{query}&signature={signature}",
headers={"X-MBX-APIKEY": api_key, "clientType": "web"},
)
return resp.json().get("data", {})
async def stream_chat(api_key: str, api_secret: str):
"""Conectar al WebSocket de chat y procesar mensajes."""
async with httpx.AsyncClient() as client:
cred = await get_chat_credential(client, api_key, api_secret)
wss_url = cred["wssUrl"]
listen_key = cred["listenKey"]
async with websockets.connect(wss_url) as ws:
# Autenticar con listenKey
await ws.send(json.dumps({"listenKey": listen_key}))
async for message in ws:
event = json.loads(message)
order_no = event.get("orderNo")
content = event.get("content")
print(f"Nuevo mensaje para orden {order_no}: {content}")
# Procesar mensaje, enviar auto-respuesta, marcar como leído...
async def get_chat_messages(
client: httpx.AsyncClient,
api_key: str,
api_secret: str,
order_no: str,
) -> list[dict]:
"""EP-34: Recuperar mensajes de chat paginados para una orden."""
import time
from auth import sign, BASE_URL
timestamp = int(time.time() * 1000)
query = f"page=1&rows=20&orderNo={order_no}&recvWindow=60000×tamp={timestamp}"
signature = sign(api_secret, query)
resp = await client.get(
f"{BASE_URL}/sapi/v1/c2c/chat/retrieveChatMessagesWithPagination?{query}&signature={signature}",
headers={"X-MBX-APIKEY": api_key, "clientType": "web"},
)
return resp.json().get("data", [])7. Patrones de Manejo de Errores
Los errores de Binance C2C caen en categorías predecibles con patrones de recuperación definidos. Consulta la referencia completa de Códigos de Error. Estos son los más frecuentes en un bot:
-1021 / -1022TimestampLlamar /api/v3/time, calcular offset, reconstruir query con timestamp fresco, reintentar
187049surplusAmountEliminar surplusAmount del body de EP-7. Enviar solo advNo + price
187055Rango de precioBúsqueda binaria dentro del rango permitido — encontrar el primer precio válido que no se superponga
429 / -9000Rate limitBackoff exponencial. Mínimo 100ms; duplicar en cada golpe consecutivo
83229 / 83230Anuncio offlineMarcar anuncio como pausado. Inspeccionar estado antes del próximo ciclo
import asyncio
RETRY_WITH_SYNC = {"-1021", "-1022"}
RETRIABLE = {"-1000", "429", "-9000"}
VOLUME_SYNC = {"187049", "187040", "187031"}
AD_OFFLINE = {"83229", "83230"}
async def handle_binance_error(code: str, context: dict) -> str:
"""Devuelve acción a tomar."""
if code in RETRY_WITH_SYNC:
return "sync_and_retry"
if code in RETRIABLE:
await asyncio.sleep(context.get("backoff", 1.0))
return "retry"
if code in VOLUME_SYNC:
await asyncio.sleep(0.3)
return "refetch_and_retry"
if code in AD_OFFLINE:
return "pause_ad"
if code == "187055":
return "find_valid_price"
return "abort"8. Buenas Prácticas de Rate Limiting
Binance C2C aplica tres capas de rate limiting: peso por endpoint, RPS global, y un soft limit en el BAPI público. Consulta la guía completa de Rate Limiting. Prácticas clave:
Techo de 5 RPS global
Nunca superar 5 requests por segundo entre todos los endpoints de Binance C2C combinados. Usar un token bucket compartido.
100ms entre páginas de EP-0
Al paginar el order book público, añadir 100ms de delay entre páginas para evitar disparar el soft limit de respuestas vacías.
Rotar User-Agent en EP-0
El endpoint BAPI detecta headers de librerías por defecto antes. Rotar strings de User-Agent tipo browser para extender el umbral del soft limit.
Circuit breaker ante fallos consecutivos
Tras 15 errores consecutivos de Binance, abrir un circuit breaker por 45 segundos. No realizar nuevas llamadas en estado abierto.
O salta meses de desarrollo y usa AutoP2P
Todo lo descrito en esta guía — polling de market data, actualizaciones solo de precio, auth adaptativa, rate limiting, circuit breakers, recuperación de errores, integración de chat — ya está construido en AutoP2P y corriendo en producción en múltiples nodos VPS. El repricing ocurre en menos de 1 segundo.
Ver Precios