Saltar al contenido principal

Automatización de Precios

Cómo construir un sistema de repricing automático para anuncios P2P en Binance C2C. Esta guía cubre el loop completo: obtener el order book, analizar la competencia, calcular el precio objetivo, actualizar el anuncio y gestionar cada caso de error.

EP-0EP-7

El Loop de Repricing


  ┌────────────────────────────────────────────────────────────────┐
  │                  Loop de Repricing (por anuncio)              │
  └────────────────────────────────────────────────────────────────┘

  Cada N segundos:
  ─────────────────────────────────────────────
  1. Obtener order book     EP-0  BAPI search
  2. Filtrar competidores   en memoria
  3. Ordenar por precio     en memoria
  4. Calcular precio obj.   lógica de estrategia
  5. Actualizar anuncio     EP-7  /ads/update
  ─────────────────────────────────────────────

  Manejo de errores en EP-7:
   187049 ──▶ Eliminar surplusAmount, reintentar
   187055 ──▶ Búsqueda binaria de precio válido, reintentar
   -1021  ──▶ Sincronizar tiempo (UTIL-1), reconstruir, reintentar
   83229  ──▶ Marcar anuncio pausado, saltar

Paso 1: Obtener el Order Book (EP-0)

El order book es la fuente de datos competitivos en bruto. Para un anuncio SELL (vendes USDT), consulta tradeType=BUY — esos son los vendedores que ve el comprador, que es la lista en la que compite tu anuncio. Siempre pasa publisherType: "merchant".

paso1_fetch.py
import httpx

async def fetch_competitors(asset: str, fiat: str) -> list[dict]:
    """Obtener páginas 1-2 de vendedores (= competidores de tu anuncio SELL)."""
    url = "https://p2p.binance.com/bapi/c2c/v2/friendly/c2c/adv/search"
    headers = {
        "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
        "Content-Type": "application/json",
    }
    ads: list[dict] = []
    async with httpx.AsyncClient() as client:
        for page in range(1, 3):  # 40 anuncios en total
            resp = await client.post(url, headers=headers, json={
                "asset": asset,
                "fiat": fiat,
                "tradeType": "BUY",  # devuelve anuncios SELL = competidores
                "page": page,
                "rows": 20,
                "publisherType": "merchant",
            })
            data = resp.json().get("data", [])
            if not data:
                break
            ads.extend(data)
    return ads

Paso 2: Analizar la Competencia

Filtra y ordena los datos brutos del order book para extraer señales competitivas útiles. Filtros clave: excluir tus propios anuncios, excluir anuncios con métodos de pago incompatibles, excluir anuncios con cantidades muy pequeñas o muy grandes.

paso2_analizar.py
def analyze_order_book(
    ads: list[dict],
    my_adv_nos: set[str],
    payment_filter: list[str] | None = None,
    min_surplus: float = 100.0,
) -> list[dict]:
    """Filtrar y ordenar competidores del order book en bruto."""
    result = []
    for ad in ads:
        adv_no = ad.get("advNo") or ad.get("adv", {}).get("advNo")

        # Excluir propios anuncios
        if adv_no in my_adv_nos:
            continue

        price = float(ad.get("adv", {}).get("price") or ad.get("price", 0))
        surplus = float(
            ad.get("adv", {}).get("tradableQuantity")
            or ad.get("tradableQuantity", 0)
        )

        # Excluir anuncios delgados
        if surplus < min_surplus:
            continue

        # Filtro opcional: método de pago
        if payment_filter:
            methods = [
                m.get("identifier")
                for m in (ad.get("adv", {}).get("tradeMethods") or [])
            ]
            if not any(m in payment_filter for m in methods):
                continue

        result.append({
            "advNo": adv_no,
            "price": price,
            "surplus": surplus,
        })

    # Ordenar ascendente por precio (vendedor más barato primero)
    return sorted(result, key=lambda x: x["price"])


def best_ask(competitors: list[dict]) -> float | None:
    """Devuelve el precio del competidor más bajo."""
    return competitors[0]["price"] if competitors else None

Paso 3: Calcular el Precio Objetivo

El precio objetivo depende de tu estrategia. Enfoques comunes: superar al mejor ask por un spread fijo, posicionarte en el rank N del order book, o flotar sobre un precio piso de referencia. Siempre cuantiza con la precisión decimal correcta para la moneda fiat.

paso3_precio.py
from decimal import Decimal, ROUND_DOWN

# Cuantización por moneda (ejemplos)
CURRENCY_PRECISION: dict[str, str] = {
    "USD": "0.01",
    "ARS": "0.01",
    "COP": "1",
    "CLP": "1",
    "UYU": "0.01",
    "CRC": "1",
}

def quantize(price: float, fiat: str) -> str:
    """Redondear a los decimales correctos para la moneda fiat."""
    precision = CURRENCY_PRECISION.get(fiat, "0.01")
    return str(Decimal(str(price)).quantize(Decimal(precision), rounding=ROUND_DOWN))


def calculate_target_price(
    competitors: list[dict],
    my_current_price: float,
    strategy: str = "undercut",
    spread: float = 0.01,
    target_rank: int = 1,
    fiat: str = "USD",
) -> str | None:
    """Calcular nuevo precio objetivo.

    strategy="undercut": igualar mejor ask menos spread
    strategy="position": precio para alcanzar target_rank en el book
    """
    if not competitors:
        return None  # Sin datos — no actualizar

    if strategy == "undercut":
        best = competitors[0]["price"]
        target = best - spread
        return quantize(target, fiat)

    if strategy == "position":
        # Precio justo por debajo del competidor en la posición target_rank
        if len(competitors) >= target_rank:
            anchor = competitors[target_rank - 1]["price"]
            target = anchor - spread
            return quantize(target, fiat)
        # No hay suficientes competidores — superar al mejor
        return quantize(competitors[0]["price"] - spread, fiat)

    return None

Paso 4: Actualizar el Anuncio (Patrón Solo-Precio EP-7)

Crítico: El body de EP-7 debe contener únicamente advNo + price. Añadir cualquier otro campo (especialmente surplusAmount) causa el error 187049 porque Binance valida el surplus contra un valor en caché que puede diferir de tu saldo actual.
paso4_update.py
import hmac
import hashlib
import time
import httpx

async def update_price(
    client: httpx.AsyncClient,
    api_key: str,
    api_secret: str,
    adv_no: str,
    price: str,  # string ya cuantizado
) -> dict:
    """EP-7: Actualización solo de precio."""
    timestamp = int(time.time() * 1000)
    query = f"recvWindow=60000&timestamp={timestamp}"
    signature = hmac.new(
        api_secret.encode(), query.encode(), hashlib.sha256
    ).hexdigest()

    resp = await client.post(
        f"https://api.binance.com/sapi/v1/c2c/ads/update?{query}&signature={signature}",
        # CRÍTICO: SOLO advNo + price. Nada más.
        json={"advNo": adv_no, "price": price},
        headers={"X-MBX-APIKEY": api_key, "clientType": "web"},
    )
    return resp.json()

Paso 5: Manejar Errores

Tres errores son específicos del path de repricing. Cada uno tiene una estrategia de recuperación determinística:

paso5_errores.py
import asyncio
import httpx

async def reprice_with_recovery(
    client: httpx.AsyncClient,
    api_key: str,
    api_secret: str,
    adv_no: str,
    price: str,
    fiat: str,
) -> bool:
    """Repricing completo con recuperación de errores."""
    for attempt in range(5):
        result = await update_price(client, api_key, api_secret, adv_no, price)
        code = str(result.get("code", ""))

        if result.get("success"):
            return True

        if code in ("-1021", "-1022"):
            # Drift de reloj — sincronizar tiempo y reconstruir timestamp
            server_time = await get_server_time(client)
            # Usar server_time para generar timestamp fresco en el siguiente loop
            await asyncio.sleep(0.1)
            continue

        if code == "187049":
            # surplusAmount en el body — nunca debería pasar
            # con el patrón solo-precio, pero manejar defensivamente
            raise ValueError(
                "187049: Eliminar surplusAmount del body de EP-7"
            )

        if code == "187055":
            # Precio fuera del rango permitido
            price = await find_valid_price(
                client, api_key, api_secret, adv_no, float(price), fiat
            )
            continue

        if code in ("83229", "83230"):
            # Anuncio desactivado por Binance
            print(f"Anuncio {adv_no} está offline — saltando reprice")
            return False

        if code in ("-1000", "429", "-9000"):
            # Backoff y reintentar
            backoff = min(2 ** attempt * 0.5, 30.0)
            await asyncio.sleep(backoff)
            continue

        # Error desconocido
        print(f"Error de reprice {code}: {result.get('message')}")
        return False

    return False


async def find_valid_price(
    client: httpx.AsyncClient,
    api_key: str,
    api_secret: str,
    adv_no: str,
    current_price: float,
    fiat: str,
) -> str:
    """Búsqueda binaria de precio válido cuando se devuelve 187055.

    Incrementar precio en pasos hasta encontrar uno sin superposición.
    En producción, obtener primero el rango dinámico de Binance.
    """
    step = {"USD": 0.01, "ARS": 1.0, "COP": 100.0, "CLP": 10.0}.get(fiat, 0.01)
    for i in range(50):  # máximo 50 pasos
        candidate = current_price + (step * i)
        result = await update_price(
            client, api_key, api_secret, adv_no, quantize(candidate, fiat)
        )
        if result.get("success"):
            return quantize(candidate, fiat)
        if str(result.get("code")) != "187055":
            break  # Error diferente — dejar de buscar
    raise RuntimeError(f"No se encontró precio válido cerca de {current_price} {fiat}")


async def get_server_time(client: httpx.AsyncClient) -> int:
    resp = await client.get("https://api.binance.com/api/v3/time")
    return resp.json()["serverTime"]

AutoP2P hace todo esto en menos de 1 segundo

El loop de repricing descrito en esta guía — obtención del order book, análisis de competencia, cuantización de precio, actualización solo-precio EP-7 y recuperación completa de errores — se ejecuta en AutoP2P en menos de 1 segundo por ciclo de anuncio. Gestiona múltiples anuncios en paralelo en múltiples exchanges y monedas, con rate limiter y circuit breaker incorporados.

<1s

Latencia de reprice

Automática

Recuperación de errores

3

Capas de rate limit

Ver Precios