El redondeo suena a problema resuelto. Tienes un número, tienes un tamaño de paso, redondeas al valor válido más cercano. Una función de utilidad, veinte líneas, listo para siempre.
No es una sola función. En un sistema que envía órdenes a múltiples exchanges, la precisión es todo un dominio, y los bugs que genera comparten una propiedad desagradable: no lanzan ninguna excepción. Producen una orden que está ligeramente mal, o una orden que se rechaza por un motivo que el usuario no puede ver.
Así es como se ve realmente el trabajo, usando tres problemas reales de nuestra ruta de órdenes.
Puntos clave
- IEEE-754 hace que el alineamiento al paso sea poco fiable: escalar 0.29 por 100 da 28.999999999999996, que al aplicar el suelo (floor) baja a un valor un paso completo por debajo.
- Tres exchanges aplican tres reglas de orden mínima incompatibles. Uno comprueba el valor en USDT, otro el número de contratos y otro ambos.
- Nuestro peor bug en esta área vino de inventar una regla que un exchange no tiene, y luego rechazar órdenes válidas contra ella.
- Nunca sintetices un límite que la API no te dio. Si un exchange no tiene un mínimo nocional, calcular uno a partir de otros campos es inventar, no ser precavido.
- Los metadatos de exchange que falten deberían lanzar un error, no recurrir a un valor por defecto. Un valor por defecto equivocado produce órdenes equivocadas en silencio.
1. El problema aritmético: el redondeo mueve tu número
Empecemos por el caso más simple, porque es el que la gente supone que es seguro.
Un exchange acepta cantidades en múltiplos de un tamaño de paso. Tienes 0.29 y un paso de 0.01. Ese valor ya es válido, así que aplicarle el suelo no debería cambiar nada. La implementación estándar escala hacia arriba, aplica el suelo y vuelve a escalar hacia abajo:
0.29 / 0.01 = 28.999999999999996 ← IEEE-754, not 29
floor(...) = 28
28 × 0.01 = 0.28
Un valor que ya estaba alineado bajó un paso completo, en silencio, en la dirección de una orden más pequeña. Sin excepción, sin advertencia, y solo ocurre con ciertas combinaciones de valor y paso, que es justo lo que lo hace difícil de detectar.
La solución es un épsilon aplicado durante el escalado, lo bastante grande para absorber el ruido de representación pero lo bastante pequeño para que un valor genuinamente por debajo como 0.289999 siga aplicando el suelo correctamente. Escribí sobre el descubrimiento de esto en lo que subestimé al construir esta plataforma; lo que importa aquí es la categoría a la que pertenece.
Porque una vez que aceptas que la aritmética sobre cantidades es poco fiable, empiezas a mirar todo lo demás que toca una cantidad. Ahí es donde están las verdaderas sorpresas.
2. Tres exchanges, tres mínimos incompatibles
Cada exchange aplica una orden mínima. Esperarías que la regla difiriera en su valor, no en su forma. Difiere en la forma:
| Exchange | Regla |
|---|---|
| Binance | qty × price >= minNotional (una comprobación de valor en USDT) |
| Bybit | qty >= minOrderQty y notional >= minNotional (dos comprobaciones) |
| OKX perpetuos | qty >= minSz (número de contratos, sin ninguna comprobación nocional) |
Lee esa tabla como tres preguntas distintas que se hacen sobre la misma orden. Binance pregunta cuánto vale. OKX pregunta cuántos contratos son. Bybit pregunta ambas cosas, y una orden puede cumplir solo una de ellas y aun así ser rechazada.
Hay un matiz adicional en OKX: las cantidades están en contratos, no en monedas, así que un tamaño de contrato tiene que convertir entre ambos antes de que nada de esto tenga sentido. Una cantidad que significa «0.5 BTC» en un exchange significa «50 contratos» en otro.
La consecuencia arquitectónica por la que optamos: cada exchange valida sus propias órdenes. El motor de estrategia no contiene ni un solo condicional sobre con qué exchange está hablando. Entrega la orden a un adaptador y el adaptador aplica sus propias reglas. Cada vez que intentamos unificar estas reglas detrás de una sola abstracción, la abstracción se convertía en una mentira en cuanto aparecía un cuarto caso.
3. El costoso: inventar una regla que no existe
Este es el bug que más querría que otro ingeniero evitara, porque el error parece diligencia.
Los perpetuos de OKX no tienen mínimo nocional. No lo sabíamos. Razonando por analogía a partir de los otros exchanges, calculamos uno: mínimo de número de contratos por tamaño de contrato por precio. Parecía razonable. Producía una cifra en USDT plausible. Y era ficción.
Dónde se rompió es algo concreto y vale la pena repasarlo. Una estrategia DCA coloca sus órdenes de seguridad por debajo del precio de entrada. Nuestro mínimo sintético se calculaba a un precio y luego se comparaba contra órdenes con un precio más bajo, así que la misma cantidad que pasaba en la parte alta de la escalera fallaba más abajo.
El resultado: órdenes de exactamente 0.1 contratos, que es el mínimo del exchange y habrían sido aceptadas, rechazadas por nuestro propio validador antes de salir siquiera de nuestro sistema. El exchange nunca las vio. Desde el lado del usuario, la estrategia simplemente no hacía lo que estaba configurada para hacer.
La lección se generaliza mucho más allá del trading:
No sintetices una restricción que la API no te dio. Si un exchange expone un mínimo en contratos, el mínimo está en contratos. Derivar un equivalente basado en valor no es ser cuidadoso, es inventar una regla y luego aplicarla contra el usuario.
La señal, en retrospectiva, era que nuestro valor calculado dependía del precio mientras que la regla real del exchange no. Siempre que tu versión de una restricción tiene una entrada que la real no tiene, has construido algo distinto de lo que pretendías modelar.
4. Los metadatos que faltan deberían ser un error, no un valor por defecto
La última pieza es una política más que un bug, y es lo que evita que los tres primeros vuelvan a aparecer en silencio.
Cada exchange nos da metadatos por símbolo: tamaño de paso, tamaño de tick, cantidad mínima, tamaño de contrato. Cualquiera de ellos puede faltar, y la tentación es recurrir a un valor por defecto. ¿Falta el tamaño de paso? Asume 0.001. ¿Falta la cantidad mínima? Asume cero y deja que el exchange decida.
Nosotros hacemos lo contrario: un campo obligatorio que falta lanza una excepción, de inmediato, antes de construir ninguna orden. Nada aguas abajo tiene la oportunidad de construir sobre una suposición.
El razonamiento es la asimetría de resultados. Un error lanzado es un fallo ruidoso que aparece de inmediato, ante alguien que puede arreglarlo. Un valor por defecto es una respuesta equivocada silenciosa que envía una orden con el tamaño equivocado y se revela más tarde, en dinero. Entre un error que no puedes ignorar y un número que no puedes verificar, el error sale más barato siempre.
Esto también significa que la capa de metadatos es honesta sobre lo que sabe. El código que lee un campo obligatorio obtiene un valor real o una excepción. Nunca obtiene un marcador de posición disfrazado con la forma de datos reales.
Qué llevarte
- Trata el alineamiento de paso y de tick como una aritmética que puede mover tu valor, no como un formateo.
- No unifiques reglas que son genuinamente distintas. La validación por exchange se mantiene correcta a medida que se añaden exchanges; una abstracción compartida sobre reglas incompatibles no.
- Nunca derives un límite que la API no declaró. Si tu versión de una restricción toma una entrada que la real no tiene, es una restricción distinta.
- Falla ruidosamente ante metadatos que faltan. Los valores por defecto convierten un error visible en una orden invisible con el precio equivocado.
El trabajo de precisión no es glamoroso y nunca aparece en una lista de funciones. Pero cada uno de estos bugs produjo una orden que estaba mal o una orden que nunca existió, y en un sistema que opera en nombre de alguien, esos son la misma categoría de fallo que perder su dinero directamente.
