PythonTradingBotsccxtGestión de riesgo

El redondeo que hace que tu bot arriesgue 10 veces lo que crees

Publicado el 2026-07-18 · Xiliux

Casi todo bot de trading tiene una línea que decide cuánto comprar. Suele parecer trivial: si arriesgo el 1% de mi saldo y mi stop está a 1000 dólares de distancia, la cantidad sale de una división. El cálculo es de primaria.

Lo que no es de primaria es hacer que ese número quepa en las restricciones del exchange. Ahí es donde se pierde dinero, y de formas que no aparecen en los logs.

El patrón que multiplica tu riesgo

Esto está en incontables bots, y estuvo en uno mío:

cantidad = max(1, int(cantidad_teorica / contract_size))

La intención es defensiva: "que nunca salga cero". El efecto es el contrario.

Si la cantidad teórica sale 0.1 contratos, int() la trunca a 0, y entonces max(1, ...) la fuerza a uno. Acabas de abrir una posición diez veces más grande que la que autorizaste.

Con números concretos: saldo de 500 USD, riesgo del 1% (5 USD), entrada en 50 000, stop en 45 000, contratos de 0.01. El riesgo real de ese contrato único es de 50 USD — diez veces el presupuesto. Y ocurre en silencio: la orden se acepta, el bot sigue, no hay excepción que capturar.

Lo peor es que no es un caso raro. Pasa siempre que el saldo es pequeño o el stop es ancho, es decir, exactamente cuando menos margen tienes para equivocarte.

Los otros dos que cuestan dinero

Ignorar el nocional mínimo. El exchange rechaza la orden por valor mínimo, el bot lo registra como error de red, y nadie se entera de que esa señal nunca se operó. El backtest la contó; la cuenta no.

No reservar para comisiones. Con un stop ajustado, las comisiones de ida y vuelta pueden ser la mitad del riesgo real. Si dimensionas contra la distancia al stop y nada más, arriesgas sistemáticamente más de lo que crees.

Cómo lo resolví

Saqué el cálculo del bot y lo publiqué como librería: position-sizing (Apache-2.0, sin dependencias).

from decimal import Decimal
from position_sizing import MarketSpec, size_for_risk

spec = MarketSpec(
    amount_step=Decimal("0.001"),
    min_amount=Decimal("0.001"),
    min_notional=Decimal("5"),
)

r = size_for_risk(
    balance=Decimal("1000"),
    risk_fraction=Decimal("0.01"),
    entry=Decimal("50000"),
    stop=Decimal("49000"),
    spec=spec,
    fee_rate=Decimal("0.0005"),
)

if r.ok:
    exchange.create_order(symbol, "market", "buy", float(r.quantity))
else:
    log.warning("sin operar: %s", r.reason)

Con ccxt no hace falta escribir la MarketSpec a mano:

from position_sizing import spec_from_ccxt
spec = spec_from_ccxt(exchange.market("BTC/USDT:USDT"))

Dos decisiones que importan más que el código

Decimal, no float, y redondeo siempre hacia abajo. El bug nace de un truncamiento accidental. Con decimal y redondeo explícito, la dirección deja de ser un accidente del tipo y pasa a ser una decisión — y la decisión correcta es hacia abajo, porque el error es asimétrico: pasarse del presupuesto se paga con dinero, quedarse corto solo cuesta un poco de rendimiento.

Función pura: recibe una MarketSpec, no un exchange. Se prueba sin red, sirve para cualquier exchange y —lo que más importa— calcula igual en backtest que en producción. Un backtest que asume posiciones fraccionarias que el mercado jamás habría aceptado infla el retorno; usando la misma función en ambos lados, esa mentira desaparece. (De eso va otro artículo.)

Negarse a operar también es un resultado

Cuando la cantidad mínima del mercado no cabe en el presupuesto de riesgo, la librería no opera. Pero no se limita a devolver None: dice cuánto saldo haría falta para que cupiera.

la cantidad mínima del mercado arriesga más de lo presupuestado;
harían falta 5000.00 de saldo

Un None te obliga a reconstruir el porqué leyendo el código seis meses después. Un motivo con la cifra dentro se lee en el log y se actúa. Rechazar está bien; rechazar explicando está mejor.

Qué NO hace

Dimensiona órdenes. No decide cuándo operar, ni dónde poner el stop, ni en qué dirección. Es aritmética sobre restricciones de exchange, no una estrategia.

Esa frontera es deliberada: la parte que da ventaja no se publica, pero la fontanería que todos reescribimos mal, sí. Si tu bot tiene un max(1, int(...)) en el camino de la orden, revísalo hoy.

← Ver más artículosCotizar un proyecto