DEV Community

Jesus Corredor
Jesus Corredor

Posted on

La fuga silenciosa: el bot que creía haber vendido

Diario de un bot que opera con dinero real — Entrada #5

El antecedente

En 2012, Knight Capital perdió 440 millones de dólares en 45 minutos. No fue un mal trade: fue código en producción haciendo algo distinto de lo que sus dueños creían.

A mi bot, un error de esa misma familia me costó 71 centavos. Pero me tomó dieciséis días descubrirlo. Ese es el verdadero peligro.

Cambio de bot

Hasta aquí, la serie fue sobre el bot de futuros. En la entrada anterior lo pausé: no tenía edge, y cuando até los cabos del backtest, apagarlo fue lo único sensato.

Pero yo tenía un segundo bot corriendo en paralelo. Uno de spot. Y mientras desenredaba el lío del de futuros, este otro me estaba mintiendo de una forma distinta.

No con números inflados. En silencio.

El síntoma

Una noche de agosto revisé mi cuenta de Binance. No buscaba nada en particular. Y ahí estaban: posiciones de ETH y BNB abiertas. Vivas.

El bot juraba haberlas cerrado días atrás. Cada una tenía su línea de venta en el log, su mensaje de Telegram, su fila en el Excel con la ganancia calculada.

Binance opinaba distinto.

No era una fuga. Eran tres: BNB el 5 de julio, ETH el 19 de julio, BNB el 31 de julio. La de ETH quedó viva dieciséis días.

Dieciséis días expuesto al mercado, creyendo que estaba cerrado. Si ETH hubiera dado un salto brusco, me habría enterado por el saldo, no por el bot.

El disparador

La causa era mínima: la comisión.

Binance cobraba el 0.1% de cada compra en la misma moneda que comprabas. Si el bot compraba 0.032 ETH, en la cuenta quedaban 0.031968 —el resto se lo llevó la comisión—.

Al cerrar, el bot mandaba vender 0.032. Los que había pedido, no los que tenía.

Binance respondía con un rechazo: -2010, saldo insuficiente.

Un rechazo es un rechazo. El problema fue lo que el bot hizo con él.

Por qué era silenciosa

Ante el rechazo, el bot no se inmutaba. Hacía lo mismo que ante una venta exitosa:

escribía EXIT STOP_DYNAMIC en el log;
mandaba "VENTA" por Telegram;
sumaba la fila al Excel con su PnL;
y marcaba la posición como cerrada.

Cuatro confirmaciones de éxito. Para una operación que había fallado.

Y lo peor: la fuga se tapaba sola. La siguiente vez que vendía ese par, la venta sí funcionaba —el saldo "olvidado" de la vez anterior cubría justo el faltante—. Cada fuga nueva borraba la huella de la anterior.

La única prueba estaba donde nadie mira: el USDT libre. Después de una venta real, sube. Después de una fantasma, no se mueve.
05-jul (fantasma): 141.06 → 141.06
19-jul (fantasma): 166.69 → 166.69
31-jul (fantasma): 116.86 → 116.86
31-jul (real): 20.51 → 70.27

El bug en una línea

El problema estaba en la función que hablaba con Binance:


Reintentaba ante rate limits y errores de servidor. Para todo lo demás, devolvía el JSON. Y un 400 con insufficient balance es "todo lo demás".

Los handlers que recibían esa respuesta solo preguntaban si venía vacía:


Un rechazo de Binance no viene vacío. Tiene su code, su msg. Así que if resp: daba verdadero. El bot confundió "Binance me respondió" con "Binance hizo lo que pedí".

Es la misma trampa del artículo del getenv, pero peor. Allá leí un valor por defecto. Acá el error venía escrito, clarísimo —insufficient balance—, y mi código lo leyó como un éxito.

La parte incómoda

Al reconstruir el costo real, descubrí algo peor que el bug: mis propias notas estaban mal.

Para la fuga de ETH había anotado una pérdida de −8.29 USDT. La real era −0.27. Me equivoqué por un factor de treinta, usando el costo promedio de la app en vez del precio real de entrada. Y encima había inventado un supuesto "rastro del problema" en mayo que nunca existió.

El bot me mintió reportando éxitos falsos. Yo, al investigarlo apurado, amplifiqué la mentira. Dos capas de datos falsos sobre el mismo hecho.

Lo que de verdad costó

En dinero, casi nada: −0.71 USDT en total.

El costo real fue otro: dieciséis días operando a ciegas, creyendo que la cuenta estaba en un estado que no era el real. Si en esos días hubiera decidido comprar, retirar o rebalancear, lo habría hecho con información falsa.

El costo no fueron 71 centavos. Fue la confianza en que mi registro era real.

El fix (y por qué no está terminado)

Esa misma madrugada lo arreglé: _retry() ahora exige un código 200 ≤ status < 300, y una verificación extra exige que la orden quede FILLED. Si la venta falla, salta una alerta y la posición sigue marcada como abierta —que es la verdad—.

Pero no lo maté del todo, y no lo escondo: el bot todavía intenta vender la cantidad comprada sin descontar la comisión. El rechazo puede volver. Lo único que cambió es que ahora me enteraría, en vez de recibir una ganancia falsa.

No se repite por una casualidad: desde agosto pago comisiones en BNB, así que la moneda comprada ya no pierde ese 0.1%. La causa raíz sigue ahí, dormida.

La lección

Un rechazo no es un éxito, aunque venga en una respuesta válida. "La API me contestó" no es "la API hizo lo que pedí".

El falso positivo es peor que el error ruidoso: te roba lo único sobre lo que operás, saber en qué estado está tu cuenta de verdad. Knight Capital no quebró por perder dinero. Estuvo a punto de quebrar porque durante 45 minutos su sistema decía que todo iba bien.

Una última cosa

El bug fue lo llamativo, pero no fue lo que terminó con este bot. Fue lo mismo que con el de futuros: no tenía edge.

Usaba el mismo motor de señales ya descartado. En un mes hizo seis operaciones, todas cerradas por stop; el take profit del 4% no se activó ni una vez. Neto: −0.15 USDT.

Lo reemplaza algo mejor: un sesgo de baja volatilidad que hoy corre en pruebas. Si este bot vuelve, vuelve con esa estrategia.

Cierre de la serie

Cinco entradas, cinco formas distintas de que un sistema me dijera una cosa mientras hacía otra: capital que creía retirado, protecciones que no estaban, un valor por defecto leído en vez del real, un backtest que mostraba un edge inexistente, y una venta que nunca ocurrió.

Vistos juntos, cuentan una sola historia: la distancia entre lo que un sistema reporta y lo que un sistema hace. Todos hablaban con seguridad. Todos mentían.

La única fuente que nunca mintió fue la cuenta real —los fills, el saldo, el P&L—. Y casi siempre fue lo último que miré. Debió ser lo primero.

El instrumento más caro del trading automático no es el bot. Es la confianza mal puesta. Cada uno de estos errores costó centavos porque los cacé a tiempo. De no haberlos cazado, el error habría salido costoso.

Los dos bots cumplieron su papel: me enseñaron a programar y a desconfiar de mis propios registros. Hoy opero distinto, con un enfoque que no depende de adivinar la dirección del precio. Pero esa es otra historia. Quizás otra serie.

Tu turno

¿Cuál fue el error más caro que te enseñó tu propio sistema? Cuéntalo en los comentarios.

Top comments (2)

Collapse
 
_firelinks profile image
Mike Dabydeen •

La causa raíz se puede cerrar con datos que Binance ya te devuelve en la compra. Una orden MARKET responde por defecto en modo FULL, con un arreglo fills donde cada ejecución trae commission y commissionAsset. Si guardas como cantidad de la posición la suma de qty menos las comisiones cobradas en el activo base, y la redondeas hacia abajo al stepSize del filtro LOT_SIZE, la venta pide lo que de verdad tienes, pagues la comisión en ETH o en BNB.

Para la fuga silenciosa en sí agregaría una conciliación separada del flujo de órdenes: cada cierto tiempo, comparar las posiciones que el bot cree abiertas con el saldo libre de la cuenta, y alertar cuando un par que el bot da por cerrado todavía tiene saldo por encima del mínimo de LOT_SIZE. Tu tabla de USDT libre ya es esa prueba hecha a mano. Automatizada, la fuga del 19 de julio se habría visto en minutos y no en dieciséis días.

Collapse
 
jesusaco profile image
Jesus Corredor •

Hola Mike, agradezco mucho el comentario. Diste justo con la pieza que me faltaba.

Tienes toda la razón con los fills. El dato estaba ahí, en la respuesta de la propia compra, y yo no lo estaba usando. Guardar la cantidad como la suma de qty menos las comisiones cobradas en el activo base, y redondear hacia abajo al stepSize del LOT_SIZE, hace que la venta pida exactamente lo que tengo —pague la comisión en ETH o en BNB—. Eso cierra la causa raíz que en el artículo dejé como pendiente.

Y lo de la conciliación periódica me gustó todavía más, porque ataca el fondo y no solo este bug. Las veo como dos capas: el fix de los fills evita que el bug pase, y la conciliación me avisa si cualquier otro fallo vuelve a dejar una posición abierta que yo crea cerrada. Me llevo las dos. Gracias de verdad.