Volver a proyectos

ArmyTrading

Una plataforma de trading algorítmico que opera siempre en simulación

Ingiere el mercado en tiempo real, guarda millones de velas, prueba estrategias sobre todo el histórico y ejecuta señales con límites de riesgo. El modo con dinero real viene apagado de fábrica.

  • Python 3.12
  • FastAPI
  • asyncio + uvloop
  • TimescaleDB
  • asyncpg
  • SQLAlchemy + Alembic
  • Redis (pub/sub)
  • Docker Compose
  • pytest
  • structlog + ruff

≈18.500×más rápida la consulta: 44,7 s → 2,4 ms

ArmyTrading — Una plataforma de trading algorítmico que opera siempre en simulación — ampliar imagen

La demo en 30 segundos

Qué es

Un sistema que ingiere datos de mercado en tiempo real, los almacena por millones de filas, permite probar estrategias sobre todo ese histórico y ejecuta señales con límites de riesgo — siempre en simulación. El modo que tocaría dinero de verdad viene apagado, escribe lo que habría hecho a un fichero y exige activarse a mano.

Las tres piezas son el mismo sistema a propósito: el motor de reglas que evalúa una señal sobre el histórico es exactamente el mismo que la evalúa en vivo, así que un resultado de backtest y una sesión real no pueden divergir por tener dos implementaciones. Los límites de riesgo se comprueban antes de abrir cualquier posición y el tamaño sale del riesgo asumido, no de un número fijo.

Debajo del dominio, esto es un problema de datos: capturar series temporales sin huecos ni duplicados, poder reprocesar un rango sin corromper lo ya guardado y consultar millones de filas lo bastante rápido como para decidir en el momento.

Stack técnico

Python 3.12 con FastAPI y asyncio sobre TimescaleDB —Postgres con extensión de series temporales—, asyncpg para hablar con la base, Alembic para las migraciones y Redis pub/sub como bus de avisos en vivo. Las reglas duras —qué es cada fila, qué no puede duplicarse— viven en el esquema de la base de datos y no en el código de aplicación: ingestor y descarga histórica escriben con ON CONFLICT DO NOTHING contra la clave primaria, así que reprocesar un rango no duplica nada. Se despliega con Docker Compose en mi propio servidor, con structlog para el rastro.

De ahí sale el número grande de la cabecera. La consulta que busca la última vela antes de cada descarga filtraba por un campo dentro de la columna JSONB (raw->>'source'), que no es indexable: el planificador la resolvía fila a fila y detoastaba el JSONB de cada una para leer un solo campo, descartando ~2,6 millones de filas antes de dar con la primera buena. Lo obvio era un índice parcial sobre la expresión —una línea de SQL, cero migración—, y lo descarté: arregla el síntoma y deja la regla enterrada en un campo de texto libre, donde cualquier escritor nuevo puede volver a mezclar las dos poblaciones en silencio. Lo que entró fue una columna discriminadora NOT NULL, con CHECK y un índice parcial encima: la regla pasa a vivir en el único sitio donde no se puede saltar. Costó una migración y tocar los tres escritores; a cambio, de 44,7 s a 2,4 ms medidos con EXPLAIN (ANALYZE, BUFFERS) sobre la base real, en el peor caso.