Lección 4.5.1 · 70 min
Arquitectura de un sistema en vivo: de los datos a la reconciliación
Un sistema en vivo no es un backtest con un broker enchufado: tiene estado que sobrevive al día, mensajes que llegan tarde o no llegan y dinero que alguien tiene que cuadrar. Esta lección monta, en celdas que se ejecutan, las seis piezas de la cadena con un bus de eventos y un reloj lógico, comprueba la señal contra el registro de un sistema real y provoca un fallo de transporte para ver qué pieza lo descubre.
- Módulo
- 4.5 · Sistema de trading en vivo
- Duración
- 70 min
Lecciones del nivel 4
4.1 · Teoría y optimización de carteras
- 4.1.1 Markowitz: frontera eficiente y restricciones reales
- 4.1.2 Estimación de covarianzas: shrinkage, modelos factoriales y denoising
- 4.1.3 Black-Litterman: del equilibrio del mercado a las visiones
- 4.1.4 Risk parity, mínima varianza, HRP y HERC
- 4.1.5 Optimización con CVaR, drawdown, rotación y costes
- 4.1.6 Modelos factoriales: CAPM, Fama-French, PCA y atribución
- 4.1.7 Rebalanceo y carteras de estrategias
- 4.1.8 Herramientas de carteras: Riskfolio-Lib, PyPortfolioOpt, skfolio y bt
4.2 · Gestión del riesgo y tamaño de posición
- 4.2.1 Riesgo por operación, stops y riesgo de ruina
- 4.2.2 Criterio de Kelly y volatility targeting: cuánto arriesgar
- 4.2.3 VaR, Expected Shortfall y backtesting del VaR
- 4.2.4 Stress testing y riesgos de correlación, liquidez, gap y contraparte
- 4.2.5 Control del drawdown y kill switch
- 4.2.6 Riesgo de derivados: griegas de cartera, margen y liquidaciones
4.3 · Microestructura y ejecución
4.4 · Brokers y APIs
- 4.4.1 Elegir bróker desde España o la UE: regulación, activos, costes y API
- 4.4.2 Interactive Brokers: TWS e IB Gateway, ib_async, contratos, datos y órdenes
- 4.4.3 Alpaca, Tradier, MetaTrader 5 con Python y OANDA v20
- 4.4.4 APIs de exchanges cripto: ccxt, Binance, Bybit, Kraken y Coinbase
- 4.4.5 Credenciales seguras: variables de entorno, gestores de secretos y permisos mínimos
4.5 · Sistema de trading en vivo
- 4.5.1 Arquitectura de un sistema en vivo: de los datos a la reconciliación
- 4.5.2 Gestión de órdenes: estados, fills parciales, cancelaciones y reconexiones
- 4.5.3 Paper trading, shadow trading y seguimiento del slippage
- 4.5.4 Programación de tareas y alertas: cron, APScheduler, Prefect, Telegram, Slack y email
- 4.5.5 Tolerancia a fallos: idempotencia, estado persistente y reconciliación al arrancar
- 4.5.6 NautilusTrader o LEAN en real: del backtest a producción con el mismo código
Del backtest al sistema en vivo
Un backtest por eventos (lección 2.4.2) ya recorre el tiempo como lo haría el sistema en real: llega una barra, la estrategia decide, el broker simulado ejecuta. Lo que no tiene es lo que hace difícil operar de verdad:
- Estado que sobrevive. El backtest empieza de cero en cada ejecución. El sistema en vivo arranca cada mañana con posiciones, órdenes abiertas y efectivo de ayer, y tiene que saber cuáles son.
- Mensajes, no llamadas. En el backtest el broker devuelve el fill en la misma función. En real, la orden sale por la red, el broker contesta cuando contesta, a veces en otro orden, a veces dos veces, a veces nunca (lección 4.5.2).
- Dos verdades. El sistema lleva su contabilidad; el broker lleva la suya, que es la que vale. Si no coinciden, hay que saberlo antes de la siguiente orden.
- Controles. Un error de programación en un backtest da un mal gráfico. En real da una orden.
Esta lección monta la cadena completa en celdas que se ejecutan: el mismo sistema de los datos del nivel (10 ETF ficticios, pesos por momentum y volatilidad), con un broker simulado dentro del proceso. Las lecciones siguientes profundizan en cada borde: las órdenes y sus estados (4.5.2), el paper trading y el deslizamiento (4.5.3), la programación de tareas y las alertas (4.5.4), la tolerancia a fallos y la reconciliación al arrancar (4.5.5) y los motores que hacen todo esto por ti (4.5.6).
Seis piezas y un bus
Fuente Mermaid
flowchart TB D[Datos] --> S[Señal] S --> R[Riesgo] R --> O[OMS] O --> B[Broker] B --> C[Cartera] B --> O C --> Q[Reconciliación] B --> Q Q --> M[Monitor]
| Pieza | Recibe | Publica | Lo que puede fallar |
|---|---|---|---|
| Datos | Precios del proveedor | Precios con su hora | Datos tarde, huecos, ajustes por splits |
| Señal | Precios hasta ahora | Pesos objetivo | Usar información que aún no existe |
| Riesgo | Pesos y órdenes propuestas | Pesos aprobados, bloqueos | Límites mal calibrados: bloquear lo bueno o dejar pasar lo malo |
| OMS | Pesos aprobados, informes del broker | Órdenes con identificador propio | Duplicar o perder órdenes, estados mal llevados |
| Broker | Órdenes | Aceptaciones, ejecuciones, rechazos | Rechazos, ejecuciones parciales, desconexiones |
| Cartera | Ejecuciones | Posiciones y efectivo internos | Contar dos veces una ejecución o no contarla |
| Reconciliación | Cartera interna y la del broker | Descuadres | Llegar tarde: después de la siguiente orden |
La regla que ordena todo: cada pieza solo conoce los mensajes que le han llegado. La señal no pregunta al broker cuántas acciones hay; el OMS no calcula momentum; la cartera no decide nada. Así cada pieza se prueba sola y se puede sustituir sin tocar las demás. La figura de la cabecera muestra esa cadena en una orden real de los datos, del OMS al broker y de vuelta, en milisegundos.
Un reloj que no es el del ordenador
El corazón del sistema es un bus de eventos: una cola de prioridad ordenada por hora en la que cada pieza publica
mensajes y a la que se suscribe por tipo. La hora no la da datetime.now() sino el propio evento: el bus avanza su
reloj al sacar cada mensaje. Con datos históricos el reloj salta de evento en evento sin esperar; en real, el
adaptador de datos publica con la hora del mercado y el bus va al ritmo de la llegada. El resto del código no
distingue los dos casos, y eso es lo que permite repetir un día de producción en el laboratorio.
import hashlib
import heapq
import itertools
from collections import Counter, defaultdict
from dataclasses import dataclass
import exchange_calendars as xcals
import numpy as np
import pandas as pd
@dataclass(frozen=True)
class Evento:
t: pd.Timestamp # hora lógica (UTC)
tipo: str
datos: dict
class Bus:
"""Cola de eventos por hora; el reloj es el del último evento."""
def __init__(self):
self.cola, self.subs = [], defaultdict(list)
self.n = itertools.count() # desempate: orden de publicación
self.ahora = None
self.registro = []
def suscribir(self, tipo, fn):
self.subs[tipo].append(fn)
def publicar(self, t, tipo, **datos):
if self.ahora is not None and t < self.ahora:
raise ValueError(f"{tipo} con hora pasada: {t}")
heapq.heappush(self.cola,
(t, next(self.n), Evento(t, tipo, datos)))
def correr(self):
while self.cola:
t, _, ev = heapq.heappop(self.cola)
self.ahora = t
self.registro.append(ev)
for fn in self.subs[ev.tipo]:
fn(ev)
bus = Bus()
bus.suscribir("eco", lambda ev: print(ev.t.time(), ev.datos))
t0 = pd.Timestamp("2025-09-02 19:45", tz="UTC")
bus.publicar(t0 + pd.Timedelta(seconds=2), "eco", n=2)
bus.publicar(t0, "eco", n=1)
bus.correr()19:45:00 {'n': 1}
19:45:02 {'n': 2}Los eventos salen por su hora, no por el orden en que se publicaron, y un evento con hora anterior al reloj es un
error: nada puede ocurrir en el pasado. El contador n resuelve los empates (dos eventos a la misma hora salen en el
orden en que se publicaron), así que el resultado no depende de nada externo.
Datos y señal: lo que se sabe a cada hora
Los datos son los del sistema ficticio del nivel: 10 ETF que siguen índices del universo de la lección 4.1.1
(universo4_precios.parquet). El precio de cada ETF es su índice por una escala; la escala sale del extracto del
broker (vivo4_extracto_posiciones.csv), que da el precio de cierre de cada ETF. Tomamos las fechas anteriores al
desdoblamiento 3 por 1 de ORAU del 17 de noviembre (lo que hace un desdoblamiento con el sistema es el ejercicio 3):
MAPA = {"ORUS": "RV_EEUU", "ORSC": "RV_EEUU_P", "OREU": "RV_EUR",
"OREM": "RV_EMG", "ORTN": "RF_T710", "ORTL": "RF_T20",
"ORIG": "RF_CIG", "ORAU": "MP_ORO", "ORCM": "MP_AMP",
"ORRE": "INM_EEUU"}
ETF = list(MAPA)
u = pd.read_parquet("universo4_precios.parquet").set_index("fecha")
ext = pd.read_csv("vivo4_extracto_posiciones.csv",
parse_dates=["fecha"])
prim = ext[ext["fecha"] < "2025-11-17"].groupby("instrumento").first()
escala = pd.Series({t: prim.loc[t, "precio_cierre"]
/ u.loc[prim.loc[t, "fecha"], a]
for t, a in MAPA.items()})
precios = u[list(MAPA.values())].set_axis(ETF, axis=1) * escala
CAL = xcals.get_calendar("XNYS")
SESIONES = CAL.sessions_in_range("2025-09-02", "2025-12-31")
print(len(SESIONES), "sesiones;", "cierre del 2 de septiembre:",
CAL.session_close(SESIONES[0]), "| del 31 de diciembre:",
CAL.session_close(SESIONES[-1]))
precios.loc["2025-09-02"].round(2).to_dict()85 sesiones; cierre del 2 de septiembre: 2025-09-02 20:00:00+00:00 | del 31 de diciembre: 2025-12-31 21:00:00+00:00
{'ORUS': 638.29, 'ORSC': 232.33, 'OREU': 61.23, 'OREM': 49.31, 'ORTN': 96.05, 'ORTL': 88.18, 'ORIG': 109.19, 'ORAU': 311.26, 'ORCM': 24.05, 'ORRE': 93.5}El cierre de la NYSE son las 16:00 de Nueva York: las 20:00 UTC hasta el 31 de octubre y las 21:00 UTC desde el 3 de noviembre, cuando EE. UU. cambia de horario. Un sistema que programe la señal «a las 19:45 UTC» la lanzará una hora antes de lo previsto la mitad del año; uno que la programe 15 minutos antes del cierre de cada sesión del calendario, nunca. Las medias sesiones (28 de noviembre y 24 de diciembre, cierre a las 13:00 de Nueva York) también salen del calendario.
La pieza de datos publica tres eventos por sesión: precios 15 minutos antes del cierre (con los cierres hasta la
víspera), cierre a la hora del cierre (con el de hoy, que solo usa el broker simulado para llenar las órdenes) y
fin_dia media hora después. La señal es la regla del sistema del nivel: en cada ETF con momentum de 20 sesiones
positivo, un peso de min(2,5 % / vol_63, 25 %), normalizados si suman más del 100 %.
class Datos:
"""Publica lo que se sabe en cada momento, con su hora."""
def __init__(self, bus, precios):
self.bus, self.p = bus, precios
def programar(self, sesiones):
for d in sesiones:
cierre = CAL.session_close(d)
hist = self.p.loc[:d].iloc[-65:-1] # hasta la víspera
self.bus.publicar(cierre - pd.Timedelta(minutes=15),
"precios", fecha=d, hist=hist)
self.bus.publicar(cierre, "cierre", fecha=d,
px=self.p.loc[d])
self.bus.publicar(cierre + pd.Timedelta(minutes=30),
"fin_dia", fecha=d)
def pesos_momentum(h):
mom = h.iloc[-1] / h.iloc[-21] - 1
vol = h.pct_change().iloc[-63:].std() * np.sqrt(252)
w = np.minimum(0.025 / vol, 0.25).where(mom > 0, 0.0)
return w / w.sum() if w.sum() > 1 else w
class Senal:
def __init__(self, bus):
self.bus = bus
bus.suscribir("precios", self.calcular)
def calcular(self, ev):
h = ev.datos["hist"]
self.bus.publicar(ev.t, "objetivo", fecha=ev.datos["fecha"],
pesos=pesos_momentum(h), ref=h.iloc[-1])Riesgo antes de cada orden
La pieza de riesgo hace dos cosas. Sobre los pesos, recorta lo que se salga de los límites de la cartera (aquí, el 25 % por ETF y el 100 % en total). Sobre cada orden que propone el OMS, aplica controles previos a la orden: valor máximo (un porcentaje del patrimonio), número máximo de órdenes por día y un interruptor que, activado, deja de aprobar objetivos. Son tres de los controles que enumera el artículo 15 del RTS 6; las bandas de precios no hacen falta aquí porque el sistema solo envía órdenes al cierre. El diseño del interruptor y de sus disparadores es la lección 4.2.5; aquí solo importa dónde está: entre la señal y el OMS, para que ninguna orden pase sin él.
Un límite mal calibrado tiene un coste: si es demasiado estricto, bloquea órdenes legítimas y la cartera se aleja del
objetivo; si es demasiado holgado, no para nada. La forma honrada de fijarlo es mirar el historial de órdenes
legítimas. Las 398 órdenes del OMS del sistema real (vivo4_ordenes.csv), con su valor sobre el capital inicial de
1.000.000 USD:
ordenes = pd.read_csv("vivo4_ordenes.csv")
pct = 100 * ordenes["cantidad"] * ordenes["precio_medio_envio"] / 1e6
print(f"Mayor orden: {pct.max():.1f} % del capital; "
f"mediana: {pct.median():.1f} %")
for lim in (10, 20, 30):
print(f"Límite {lim} %: bloquearía {(pct > lim).sum()} "
"órdenes legítimas")
error_dedo = 10 * pct.max()
print(f"Error de dedo (x10): {error_dedo:.0f} %, bloqueada: "
f"{error_dedo > 30}")Mayor orden: 26.1 % del capital; mediana: 2.2 %
Límite 10 %: bloquearía 72 órdenes legítimas
Límite 20 %: bloquearía 5 órdenes legítimas
Límite 30 %: bloquearía 0 órdenes legítimas
Error de dedo (x10): 261 %, bloqueada: TrueLa mayor orden legítima vale el 26,1 % del capital: la venta de toda la posición en ORTN del 31 de diciembre. Con un límite del 30 % no se bloquea ninguna; con uno del 20 % se bloquearían cinco, y cuatro de ellas son ventas de posiciones enteras de bonos en diciembre: el límite impediría salir. Un error de dedo que multiplique una cantidad por diez se queda muy por encima de cualquiera de los tres. El límite de órdenes por día se calibra igual: el día con más órdenes del historial tiene 15 (como mucho una por ETF y una más por cada límite que no se llena), así que 25 es holgado sin ser infinito.
class Riesgo:
"""Límites de cartera y controles previos a cada orden."""
def __init__(self, bus, peso_max=0.25, valor_max=0.30,
ordenes_dia=25):
self.bus, self.peso_max = bus, peso_max
self.valor_max, self.ordenes_dia = valor_max, ordenes_dia
self.interruptor = False # lección 4.2.5
self.hoy = Counter()
bus.suscribir("objetivo", self.revisar)
def revisar(self, ev):
if self.interruptor:
self.bus.publicar(ev.t, "alerta", texto="interruptor")
return
w = ev.datos["pesos"].clip(upper=self.peso_max)
w = w / w.sum() if w.sum() > 1 else w
self.bus.publicar(ev.t, "objetivo_ok",
**{**ev.datos, "pesos": w})
def comprobar(self, orden, patrimonio):
"""None si pasa; si no, el motivo del bloqueo."""
if orden["cantidad"] * orden["ref"] > (self.valor_max
* patrimonio):
return "valor máximo"
self.hoy[orden["fecha"]] += 1
if self.hoy[orden["fecha"]] > self.ordenes_dia:
return "órdenes por día"
return NoneOMS, broker y cartera
El OMS (order management system) convierte los pesos aprobados en órdenes: calcula el patrimonio con la cartera interna, el número de acciones objetivo de cada ETF y la diferencia con la posición; si la diferencia vale menos del 0,4 % del patrimonio, no opera (como el sistema del nivel). Cada orden recibe un identificador propio del cliente, pasa por el riesgo y, si lo supera, sale hacia el broker a los cinco minutos, 10 antes del cierre. El OMS lleva el estado de cada orden con los informes que devuelve el broker; la lección 4.5.2 convierte ese estado en una máquina de estados con sus reglas.
El broker simulado vive en el mismo proceso pero solo habla por el bus, con 42 ms de latencia en cada sentido (la mediana de un sentido de la cuenta real de los datos). Rechaza las compras que no caben en el efectivo más las ventas del día, acepta las demás y las llena en la subasta de cierre, en una a tres ejecuciones, a cierre ± una media horquilla de 1 pb, con la comisión del broker de los datos (0,0035 USD por acción, mínimo 0,35 USD, máximo el 1 % del efectivo). Lleva su propia cuenta: es la verdad contable, como el extracto de un broker real.
La cartera cuenta cada ejecución una vez (por su identificador) y es la que usa el OMS para decidir. El reconciliador compara, al final de cada día, las posiciones y el efectivo de la cartera con los del broker.
MS = pd.Timedelta(milliseconds=1)
def comision(q, px):
return round(min(max(0.0035 * q, 0.35), 0.01 * q * px), 2)
class Cartera:
def __init__(self, bus, efectivo):
self.efectivo, self.pos = efectivo, pd.Series(0, index=ETF)
self.vistas = set()
bus.suscribir("informe", self.anotar)
def anotar(self, ev):
d = ev.datos
if d["evento"] not in ("parcial", "ejecutada") \
or d["id_ejecucion"] in self.vistas:
return
self.vistas.add(d["id_ejecucion"])
s = 1 if d["lado"] == "compra" else -1
self.pos[d["inst"]] += s * d["cantidad"]
self.efectivo -= s * d["cantidad"] * d["precio"] + d["comision"]
def patrimonio(self, px):
return self.efectivo + float((self.pos * px).sum())
class OMS:
def __init__(self, bus, riesgo, cartera, umbral=0.004):
self.bus, self.riesgo, self.cartera = bus, riesgo, cartera
self.umbral, self.ordenes = umbral, {}
bus.suscribir("objetivo_ok", self.rebalancear)
bus.suscribir("informe", self.actualizar)
def rebalancear(self, ev):
ref, w = ev.datos["ref"], ev.datos["pesos"]
pat = self.cartera.patrimonio(ref)
delta = np.floor(w * pat / ref) - self.cartera.pos
for inst, q in delta.sort_values().items(): # ventas antes
if abs(q) * ref[inst] < self.umbral * pat:
continue
o = dict(id=f"C{len(self.ordenes) + 1:05d}",
fecha=ev.datos["fecha"], inst=inst,
lado="compra" if q > 0 else "venta",
cantidad=int(abs(q)), ref=float(ref[inst]))
self.ordenes[o["id"]] = {**o, "estado": "nueva",
"recibida": 0}
motivo = self.riesgo.comprobar(o, pat)
if motivo:
self.ordenes[o["id"]]["estado"] = "bloqueada"
self.bus.publicar(ev.t, "alerta", texto=motivo)
continue
self.ordenes[o["id"]]["estado"] = "enviada"
self.bus.publicar(ev.t + 5 * 60_000 * MS, "orden", **o)
def actualizar(self, ev):
o = self.ordenes[ev.datos["id"]]
o["estado"] = ev.datos["evento"]
o["recibida"] += ev.datos.get("cantidad", 0)
o["acumulada"] = ev.datos.get("acumulada", 0)
class BrokerSimulado:
LAT = 42 * MS
def __init__(self, bus, efectivo, media_horquilla_pb=1.0):
self.bus, self.efectivo = bus, efectivo
self.pos = pd.Series(0, index=ETF)
self.hs, self.cola, self.seq = media_horquilla_pb / 1e4, [], 0
self.perder = set() # fallos de transporte a propósito
bus.suscribir("orden", self.recibir)
bus.suscribir("cierre", self.subasta)
def informar(self, t, **d):
self.seq += 1
if d.get("id_ejecucion") not in self.perder:
self.bus.publicar(t + self.LAT, "informe", seq=self.seq,
**d)
def recibir(self, ev):
o, t = ev.datos, ev.t + self.LAT
ventas = sum(x["cantidad"] * x["ref"] for x in self.cola
if x["lado"] == "venta")
compras = sum(x["cantidad"] * x["ref"] for x in self.cola
if x["lado"] == "compra")
coste = o["cantidad"] * o["ref"] * (o["lado"] == "compra")
if coste > 0 and compras + coste > self.efectivo + ventas:
self.informar(t, id=o["id"], evento="rechazada")
return
self.cola.append(o)
self.informar(t, id=o["id"], evento="aceptada")
def subasta(self, ev):
for o in self.cola:
s = 1 if o["lado"] == "compra" else -1
px = round(ev.datos["px"][o["inst"]] * (1 + s * self.hs), 2)
n = 1 if o["cantidad"] < 300 else (
2 if o["cantidad"] < 1500 else 3)
partes = [o["cantidad"] // n] * n
partes[-1] += o["cantidad"] - sum(partes)
acum = 0
for k, q in enumerate(partes, 1):
acum += q
com = comision(q, px)
self.pos[o["inst"]] += s * q
self.efectivo -= s * q * px + com
fin = acum == o["cantidad"]
self.informar(ev.t + 300 * k * MS, id=o["id"],
evento="ejecutada" if fin else "parcial",
inst=o["inst"], lado=o["lado"],
cantidad=q, acumulada=acum, precio=px,
comision=com,
id_ejecucion=f"{o['id']}.{k:02d}")
self.cola = []
class Reconciliador:
def __init__(self, bus, cartera, broker):
self.bus, self.c, self.b = bus, cartera, broker
self.diario = []
bus.suscribir("fin_dia", self.cuadrar)
def cuadrar(self, ev):
dif = (self.b.pos - self.c.pos)
dif = dif[dif != 0].to_dict()
de = round(self.b.efectivo - self.c.efectivo, 2)
self.diario.append(dict(fecha=ev.datos["fecha"].date(),
posiciones=dif, efectivo=de))
if dif or de:
self.bus.publicar(ev.t, "alerta", texto=f"descuadre {dif}")Hay un detalle de diseño en el OMS: decide con la cartera interna, no con la del broker. Es lo normal (la cartera interna se actualiza al instante con cada informe y no cuesta una consulta), y es exactamente lo que hace peligroso un mensaje perdido, como se verá enseguida.
El sistema completo, del 2 de septiembre al 31 de diciembre
Montar el sistema es crear las piezas sobre el mismo bus y dejar que los datos programen sus eventos. Un monitor mínimo recoge las alertas (la lección 4.5.4 las envía por Telegram, Slack o correo):
def montar(perder=(), capital=1_000_000.0):
bus = Bus()
riesgo = Riesgo(bus)
cartera = Cartera(bus, capital)
broker = BrokerSimulado(bus, capital)
broker.perder = set(perder)
oms = OMS(bus, riesgo, cartera)
rec = Reconciliador(bus, cartera, broker)
Senal(bus)
alertas = []
bus.suscribir("alerta", lambda ev: alertas.append(
(ev.t.date(), ev.datos["texto"])))
Datos(bus, precios).programar(SESIONES)
bus.correr()
return dict(bus=bus, oms=oms, cartera=cartera, broker=broker,
rec=rec, alertas=alertas)
s = montar()
print(Counter(ev.tipo for ev in s["bus"].registro))
print(Counter(o["estado"] for o in s["oms"].ordenes.values()))
cierre = precios.loc[SESIONES[-1]]
print(f"Patrimonio final: {s['cartera'].patrimonio(cierre):,.2f} USD")
print("Días con descuadre:",
sum(bool(r["posiciones"] or r["efectivo"])
for r in s["rec"].diario), "| alertas:", len(s["alertas"]))Counter({'informe': 868, 'orden': 350, 'precios': 85, 'objetivo': 85, 'objetivo_ok': 85, 'cierre': 85, 'fin_dia': 85})
Counter({'ejecutada': 344, 'rechazada': 6})
Patrimonio final: 1,077,668.41 USD
Días con descuadre: 0 | alertas: 0Ochenta y cinco sesiones, tres eventos de datos por sesión y la cadena entera detrás: objetivos, objetivos aprobados,
órdenes, informes del broker. De las 350 órdenes, 344 terminan en ejecutada y 6 en rechazada: son compras que
el broker no acepta porque no caben en el efectivo. El OMS calcula con el cierre de la víspera y manda las ventas
antes que las compras (por eso hay pocas), pero los precios de la subasta no son los de ayer y una cartera casi
invertida al 100 % se queda a veces sin margen; al día siguiente el OMS vuelve a calcular y la diferencia se manda de
nuevo. La cartera interna cuadra con la del broker los 85 días. El patrimonio final no es el tema de la lección (el
sistema es un ejemplo, y cuatro meses no dicen nada de una estrategia, lección 2.5.5).
Fuente Mermaid
flowchart TB P[precios 19:45] --> OB[objetivo] OB --> OK[objetivo_ok] OK --> OR[orden 19:50] OR --> AC[aceptada] CI[cierre 20:00] --> EJ[ejecuciones] AC --> EJ EJ --> FD[fin_dia 20:30]
Una sesión de septiembre, en el orden en que la recorre el bus. Que el sistema sea correcto no se puede ver en el
patrimonio; se comprueba pieza a pieza. La señal tiene una respuesta conocida: el sistema real del nivel registró los
pesos objetivo de cada orden que mandó (vivo4_senales.csv, 347 señales). Si nuestra pieza de señal es la misma
regla, sus pesos tienen que coincidir con los registrados:
obj = {ev.datos["fecha"]: ev.datos["pesos"]
for ev in s["bus"].registro if ev.tipo == "objetivo"}
sen = pd.read_csv("vivo4_senales.csv")
sen["fecha"] = pd.to_datetime(sen["ts"].str[:10])
sen["nuestro"] = [obj[f][i] for f, i in
zip(sen["fecha"], sen["instrumento"])]
dif = (sen["nuestro"].round(4) - sen["peso_objetivo"]).abs()
print(f"{len(sen)} señales; diferencia máxima {dif.max():.4f}; "
f"distintas a 4 decimales: {(dif > 1e-9).sum()}")347 señales; diferencia máxima 0.0001; distintas a 4 decimales: 1Las 347 coinciden; una difiere en una unidad del cuarto decimal, que es lo que puede dar el redondeo cuando el registro guarda cuatro decimales. Esta comprobación es la versión en vivo de la reconciliación entre motores de la lección 2.4.2: la misma regla, con los mismos datos hasta la víspera, tiene que dar los mismos números en el laboratorio y en producción. Si no los da, hay un error en una de las dos, y es mejor encontrarlo antes del dinero.
Un mensaje perdido
Ahora el fallo. El broker ejecuta todas las órdenes, pero un informe de ejecución no llega: el de la primera parte de la compra de ORSC del primer día (en real, un corte de conexión y un broker que no reenvía todo al reconectar; los datos del nivel tienen dos casos, lección 4.5.2):
perdida = next(ev.datos for ev in s["bus"].registro
if ev.tipo == "informe"
and ev.datos["evento"] == "parcial"
and ev.datos["inst"] == "ORSC")
print(perdida["id_ejecucion"], perdida["lado"], perdida["cantidad"],
perdida["precio"], perdida["comision"])
f = montar(perder={perdida["id_ejecucion"]})
o_f = f["oms"].ordenes[perdida["id"]]
print("En el OMS:", o_f["estado"], "| pedida", o_f["cantidad"],
"| sumada", o_f["recibida"], "| acumulada", o_f["acumulada"])
for r in [r for r in f["rec"].diario
if r["posiciones"] or r["efectivo"]][:3]:
print(r)C00003.01 compra 208 232.36 0.73
En el OMS: ejecutada | pedida 416 | sumada 208 | acumulada 416
{'fecha': datetime.date(2025, 9, 2), 'posiciones': {'ORSC': 208}, 'efectivo': np.float64(-48331.61)}
{'fecha': datetime.date(2025, 9, 3), 'posiciones': {'ORSC': 208}, 'efectivo': np.float64(-48331.61)}
{'fecha': datetime.date(2025, 9, 4), 'posiciones': {'ORSC': 208}, 'efectivo': np.float64(-48331.61)}El OMS marca la orden como ejecutada, porque así lo dice el último informe, pero de las 416 acciones pedidas solo ha
sumado 208; el informe trae la cantidad acumulada (416) y la diferencia está a la vista para quien la compare, que es
lo que hace la máquina de estados de la lección 4.5.2. Este OMS no compara, y la cartera interna se queda con 208 ORSC
menos que el broker y 48.331,61 USD más de efectivo (las 208 acciones a 232,36 más 0,73 de comisión). El
reconciliador lo ve esa misma tarde, a las 20:30 UTC, y todos los días siguientes.
def ordenes_de(sim, fecha, inst):
return [(o["lado"], o["cantidad"], o["estado"])
for o in sim["oms"].ordenes.values()
if o["fecha"] == pd.Timestamp(fecha)
and o["inst"] == inst]
for dia in ("2025-09-03", "2025-09-04", "2025-09-05"):
print(dia, "sin fallo:", ordenes_de(s, dia, "ORSC"),
"| con fallo:", ordenes_de(f, dia, "ORSC"))
print("ORSC al final, broker y cartera interna:",
f["broker"].pos["ORSC"], f["cartera"].pos["ORSC"],
"| sistema sano:", s["broker"].pos["ORSC"])
fantasma = (f["broker"].pos["ORSC"] - f["cartera"].pos["ORSC"]) \
* precios.loc[SESIONES[-1], "ORSC"]
print(f"Valor de la posición fantasma: {fantasma:,.0f} USD; días "
f"con descuadre: {sum(bool(r['posiciones'])
for r in f['rec'].diario)}")2025-09-03 sin fallo: [] | con fallo: [('compra', 205, 'rechazada')]
2025-09-04 sin fallo: [('compra', 53, 'ejecutada')] | con fallo: [('compra', 260, 'rechazada')]
2025-09-05 sin fallo: [('venta', 59, 'ejecutada')] | con fallo: [('compra', 201, 'ejecutada')]
ORSC al final, broker y cartera interna: 208 0 | sistema sano: 0
Valor de la posición fantasma: 57,716 USD; días con descuadre: 85Con el fallo, el 3 de septiembre el OMS intenta comprar 205 ORSC que el sistema sano no compra: está reponiendo lo que cree que falta. El broker la rechaza, porque ese efectivo ya se gastó en las acciones que el sistema no sabe que tiene; el 4 lo intenta con 260 y vuelve a ser rechazada, y el 5 la compra de 201 sí entra, mientras el sistema sano vende 59. El 31 de diciembre el sistema sano no tiene ORSC; el que perdió el mensaje tiene 208 en el broker y 0 en su cartera: una posición fantasma de 57.716 USD al cierre del año, más del 5 % del patrimonio, que ninguna regla de la estrategia ha decidido y que ningún stop vigila, porque el sistema no sabe que existe. Lo único que avisó, 85 veces, fue el reconciliador. La lección práctica es el orden de las cosas: reconciliar antes de operar. Un descuadre sin explicar debe parar el rebalanceo de ese instrumento (o de todo) hasta que una persona o un proceso de recuperación lo resuelva; la lección 4.5.5 programa esa recuperación: consultar al broker las ejecuciones del día, corregir la cartera y arrancar con el estado bueno.
Reproducible evento a evento
El bus no usa el reloj del ordenador ni hilos, así que el mismo flujo de entrada produce el mismo registro de eventos. Se comprueba con una huella del registro completo:
def huella(sim):
h = hashlib.sha256()
for ev in sim["bus"].registro:
datos = {k: v for k, v in ev.datos.items()
if k not in ("hist", "px", "pesos", "ref")}
h.update(f"{ev.t.isoformat()}|{ev.tipo}|{datos}".encode())
return h.hexdigest()[:16]
print(huella(s), huella(montar()), huella(f))c62d14133b310f84 c62d14133b310f84 41fa5d84a31624d5Las dos ejecuciones sanas dan la misma huella; la del fallo, otra. Es la propiedad que el research del sitio sobre máquinas de estados llama «el replay como test de oro»: si el registro de un día de producción se puede volver a pasar por el sistema y da lo mismo, cualquier diferencia entre lo que pasó y lo que debía pasar está en las entradas (datos, mensajes del broker), no en el código. Guardar ese registro, en un fichero de solo añadir o en una base de datos, es la base del estado persistente de la lección 4.5.5.
Los mismos componentes en backtest, paper y real
Lo que cambia al pasar al real está en los bordes:
Fuente Mermaid
flowchart TB N[Núcleo común] --> RL[Reloj lógico] N --> RR[Reloj real] RL --> DH[Datos históricos] RR --> DS[Datos streaming] DH --> BS[Broker simulado] DS --> BP[Cuenta paper] DS --> BR[Cuenta real]
| Pieza | Backtest | Paper | Real |
|---|---|---|---|
| Reloj | Lógico, salta de evento en evento | Real | Real |
| Datos | Históricos | En tiempo real | En tiempo real |
| Broker | Simulado en el proceso | Cuenta paper del broker | Cuenta real |
| Señal, riesgo, OMS, cartera, reconciliación | Mismo código | Mismo código | Mismo código |
En real, el bus es el mismo pero lo alimenta un adaptador que recibe mensajes del broker y del proveedor de datos y
los publica con la hora de llegada. Un esquema con asyncio, sin ejecutar (las conexiones reales son las de las
lecciones 4.4.2 a 4.4.4):
import asyncio
from datetime import datetime, timezone
async def adaptador(bus, flujo_broker, flujo_datos):
"""Publica en el bus cada mensaje con su hora de llegada."""
async def bombear(flujo, tipo):
async for msg in flujo:
ahora = pd.Timestamp(datetime.now(timezone.utc))
bus.publicar(ahora, tipo, **msg)
bus.correr() # procesa lo que haya en la cola
await asyncio.gather(bombear(flujo_broker, "informe"),
bombear(flujo_datos, "precios"))Hay sistemas de un proceso, como este, y sistemas de varios procesos que se hablan por una cola de mensajes. Para una estrategia diaria, un proceso basta y es más fácil de razonar: los eventos tienen un orden total y el estado está en un sitio. Varios procesos tienen sentido cuando una pieza es lenta (un modelo pesado) o cuando hay muchas estrategias que comparten datos y broker; a cambio, el orden de los eventos entre procesos ya no está garantizado y hay que reconciliar más. Dónde corre el proceso (un servidor propio, una VPS, un contenedor) cambia la latencia y la disponibilidad, no la arquitectura; el research del sitio sobre Docker explica cómo empaquetarlo.
Una orden real de punta a punta
Los datos del nivel traen el registro de un sistema con esta misma arquitectura operando cuatro meses (paper hasta el
10 de octubre y cuenta real después). La orden R00071 es una venta a mercado de 243 ORUS del 28 de octubre:
ev = pd.read_csv("vivo4_eventos_broker.csv")
o = ordenes.set_index("id_orden_cliente").loc["R00071"]
t0 = pd.Timestamp(o["ts_creacion"])
ms = lambda ts: round((pd.Timestamp(ts) - t0).total_seconds() * 1e3)
print(o["id_senal"], o["instrumento"], o["lado"], o["cantidad"],
o["tipo"], "| envío:", ms(o["ts_envio"]), "ms")
r = ev[ev["id_orden_cliente"] == "R00071"]
pd.DataFrame({"seq": r["seq"], "evento": r["evento"],
"broker_ms": r["ts_broker"].map(ms),
"recibido_ms": r["ts_recibido"].map(ms),
"cantidad": r["cantidad_ultima"],
"precio": r["precio_ultimo"]}).set_index("seq")S00180 ORUS venta 243 mercado | envío: 277 ms
evento broker_ms recibido_ms cantidad precio
seq
176 aceptada 331 378 0 NaN
177 parcial 355 392 122 658.09
178 parcial 668 708 12 658.06
180 ejecutada 1300 1329 109 658.18La señal salió a las 19:45 UTC; el OMS creó la orden cinco minutos después (es la regla del sistema) y la envió 277 ms más tarde. El broker la aceptó a los 331 ms y la llenó en tres ejecuciones en algo más de un segundo; cada mensaje tarda entre 30 y 50 ms más en llegar al sistema. Es la figura de la cabecera. Dos observaciones para las lecciones siguientes: el sistema se enteró de la primera ejecución (392 ms) solo 14 ms después de la aceptación (378 ms), y en otras órdenes de los datos la ejecución llega antes que la aceptación (lección 4.5.2); y el precio medio de las tres ejecuciones frente al precio medio del mercado al enviar es el deslizamiento de la lección 4.5.3.
Ejercicios
- Activa el interruptor de riesgo el 1 de octubre (sin cambiar nada más) y cuenta cuántas sesiones quedan sin objetivos aprobados y cuántas alertas salen. ¿Qué le pasa a la cartera mientras el interruptor está activado?
Solución
class RiesgoConFecha(Riesgo):
def revisar(self, ev):
self.interruptor = ev.datos["fecha"] >= pd.Timestamp(
"2025-10-01")
super().revisar(ev)
def montar_con(riesgo_cls, capital=1_000_000.0):
bus = Bus()
riesgo = riesgo_cls(bus)
cartera = Cartera(bus, capital)
broker = BrokerSimulado(bus, capital)
oms = OMS(bus, riesgo, cartera)
Senal(bus)
alertas = []
bus.suscribir("alerta", lambda e: alertas.append(e.datos["texto"]))
Datos(bus, precios).programar(SESIONES)
bus.correr()
return oms, cartera, alertas
oms_i, cart_i, al_i = montar_con(RiesgoConFecha)
print("alertas:", Counter(al_i))
print("órdenes:", len(oms_i.ordenes), "frente a",
len(s["oms"].ordenes))
print(cart_i.pos.to_dict())alertas: Counter({'interruptor': 64})
órdenes: 68 frente a 350
{'ORUS': 201, 'ORSC': 397, 'OREU': 1835, 'OREM': 1765, 'ORTN': 1360, 'ORTL': 1474, 'ORIG': 1188, 'ORAU': 309, 'ORCM': 0, 'ORRE': 961}Desde el 1 de octubre el riesgo no aprueba ningún objetivo: sale una alerta de interruptor en cada una de las 64 sesiones y el OMS no manda más órdenes (68 en total, todas de septiembre, frente a 350). La cartera se queda congelada con las posiciones del 30 de septiembre: el interruptor de esta lección deja de operar, no liquida. Que además deba cancelar órdenes abiertas o cerrar posiciones es una decisión de diseño con su propio coste (lección 4.2.5).
- Baja el valor máximo por orden al 10 % del patrimonio. ¿Cuántas órdenes se bloquean y cuándo? ¿Qué consecuencia tiene en los pesos de la cartera los primeros días?
Solución
class RiesgoEstricto(Riesgo):
def __init__(self, bus):
super().__init__(bus, valor_max=0.10)
oms_e, cart_e, al_e = montar_con(RiesgoEstricto)
bloq = pd.Series([o["fecha"] for o in oms_e.ordenes.values()
if o["estado"] == "bloqueada"])
print(len(bloq), "bloqueadas en", bloq.nunique(), "de",
len(SESIONES), "sesiones")
print("Primeros días:", bloq.dt.date.value_counts().sort_index()
.head(3).to_dict())
fin = precios.loc[SESIONES[-1]]
pesos = lambda c: (c.pos * fin / c.patrimonio(fin)).round(3)
pd.DataFrame({"límite 30 %": pesos(s["cartera"]),
"límite 10 %": pesos(cart_e)}).T356 bloqueadas en 82 de 85 sesiones
Primeros días: {datetime.date(2025, 9, 2): 6, datetime.date(2025, 9, 3): 6, datetime.date(2025, 9, 4): 5}
ORUS ORSC OREU OREM ORTN ORTL ORIG ORAU ORCM ORRE
límite 30 % 0.000 0.000 0.0 0.0 0.0 0.0 0.0 0.109 0.000 0.000
límite 10 % 0.108 0.107 0.0 0.0 0.0 0.0 0.0 0.108 0.112 0.109Con un 10 %, el límite bloquea 356 órdenes en 82 de las 85 sesiones: el OMS repite cada día la orden que no pasó. Los primeros días no puede montar la cartera (6, 6 y 5 bloqueos), y lo peor llega después. Las posiciones se construyen a trozos por debajo del 10 %, pero cuando la señal pide salir, la venta de toda la posición vale más del 10 % y también se bloquea: el 31 de diciembre el sistema estricto tiene cerca de un 11 % en ORUS, ORSC, ORCM y ORRE, que la señal quiere a cero. Un límite pensado para frenar errores impide reducir el riesgo. Por eso se calibra con el historial y, en muchas mesas, las órdenes que reducen la posición tienen límites distintos de las que la aumentan.
- Explica qué haría falta cambiar en la pieza de datos para que la señal del sistema funcione bien el 17 de noviembre, día del desdoblamiento 3 por 1 de ORAU, si el proveedor diera precios sin ajustar.
Solución
Con precios sin ajustar, el cierre de ORAU del 17 de noviembre sería un tercio del de la víspera: el momentum de 20 sesiones se volvería muy negativo de golpe y la volatilidad de 63 sesiones se dispararía, así que la señal vendería todo ORAU sin ningún motivo económico. La pieza de datos tiene que publicar series ajustadas por eventos corporativos (lección 1.2.1), y la cartera tiene que multiplicar por 3 la posición y dividir por 3 su precio de coste ese mismo día; si no, la reconciliación con el broker dará un descuadre de 374 títulos (lección 4.5.5). Lo importante es que el ajuste se hace en un sitio (la pieza de datos y el registro de eventos corporativos), no repartido por la señal.
Respuesta plegada en cada pregunta
Autoevaluación
¿Por qué el bus de la lección ordena los eventos por una hora lógica en vez de usar
datetime.now()ytime.sleep()?- APorque es más rápido
- BPorque así el mismo flujo de eventos da siempre el mismo resultado: se puede repetir un día entero, en backtest o tras un fallo, y comparar evento a evento
- CPorque los brokers no admiten horas reales
- DPorque
sleepno existe en Python 3.14
Ver respuesta
Respuesta B. Porque así el mismo flujo de eventos da siempre el mismo resultado: se puede repetir un día entero, en backtest o tras un fallo, y comparar evento a evento
Con el reloj inyectado, el orden de los eventos solo depende de sus horas lógicas y del orden de publicación. En la lección, dos ejecuciones completas dan la misma huella SHA-256 del registro de eventos. En real, el reloj lo pone el adaptador de datos; el resto de piezas no cambia.
El sistema pierde la ejecución de 208 ORSC el 2 de septiembre. ¿Qué pieza lo detecta y cuándo?
- ALa señal, al día siguiente
- BEl OMS, al instante
- CEl reconciliador, al cierre de ese día, al comparar la cartera interna con la del broker
- DNadie: la diferencia es demasiado pequeña
Ver respuesta
Respuesta C. El reconciliador, al cierre de ese día, al comparar la cartera interna con la del broker
El OMS da la orden por
ejecutada(lo dice el último informe) y la cartera interna no sabe nada. El reconciliador compara posiciones y efectivo con los del broker a las 20:30 UTC y encuentra 208 ORSC y 48.331,61 USD de diferencia. Si nadie actúa, el OMS intenta comprar lo que cree que falta y el sistema acaba con una posición fantasma.¿Qué control previo a la orden habría parado una orden con diez veces la cantidad prevista (un error de dedo)?
- AUn límite al valor máximo de cada orden, calibrado por encima de la mayor orden legítima
- BUn stop de pérdidas
- CLa reconciliación del cierre
- DNinguno: el broker la rechaza siempre
Ver respuesta
Respuesta A. Un límite al valor máximo de cada orden, calibrado por encima de la mayor orden legítima
Es el control b) del artículo 15 del Reglamento Delegado (UE) 2017/589. En el historial de 398 órdenes del sistema la mayor vale el 26,1 % del capital; un límite del 30 % no bloquea ninguna legítima y para la orden multiplicada por diez.
¿Qué cambia entre backtest, paper y real en la arquitectura de la lección?
- ATodo: son tres programas distintos
- BLa señal y el riesgo
- CSolo los adaptadores de los bordes: de dónde viene el reloj y los datos, y a qué broker van las órdenes
- DNada, ni siquiera el broker
Ver respuesta
Respuesta C. Solo los adaptadores de los bordes: de dónde viene el reloj y los datos, y a qué broker van las órdenes
Señal, riesgo, OMS, cartera y reconciliador son el mismo código. Cambia el reloj (lógico o real), la fuente de datos (histórica o en streaming) y el broker (simulado, cuenta paper o cuenta real). Es la idea de NautilusTrader y LEAN (lecciones 2.4.5 y 4.5.6).
¿Por qué la señal de la lección usa los precios hasta la víspera aunque se calcule 15 minutos antes del cierre?
- APor comodidad
- BPorque el cierre del día aún no existe a esa hora: en el bus, el evento
cierrese publica a la hora del cierre y ninguna pieza puede leerlo antes - CPorque así el Sharpe es mayor
- DPorque el broker lo exige
Ver respuesta
Respuesta B. Porque el cierre del día aún no existe a esa hora: en el bus, el evento
cierrese publica a la hora del cierre y ninguna pieza puede leerlo antesEn un sistema por eventos, la información tiene hora. Lo que un componente sabe es lo que le ha llegado. Es la misma protección contra el sesgo de anticipación que en la lección 2.4.2, ahora con la hora real de cada mensaje.
Ideas clave
- La cadena de un sistema en vivo tiene seis piezas: datos, señal, riesgo, OMS, broker y reconciliación. Cada una tiene una sola responsabilidad y habla con las demás por mensajes con hora.
- Un bus con reloj lógico hace el sistema reproducible: el registro de eventos de dos ejecuciones tiene la misma huella, y un día de producción se puede repetir en el laboratorio.
- Los controles previos a la orden (valor máximo, volumen, número de mensajes, interruptor) son obligatorios para las empresas que hacen negociación algorítmica en la UE y sensatos para cualquiera; se calibran con el historial de órdenes legítimas.
- El OMS sabe lo que pidió; solo el broker sabe lo que pasó. La reconciliación diaria entre los dos es la única pieza que descubre un mensaje perdido.
- Un descuadre que nadie corrige no se queda quieto: el OMS opera sobre una posición falsa y el error se convierte en órdenes.
- Backtest, paper y real comparten el núcleo; cambian el reloj, los datos y el broker.