Lección 7.3.3 · 70 min

Monitorización del modelo en vivo y desactivación automática

Un modelo no avisa cuando deja de funcionar: sigue dando predicciones con la misma seguridad. Esta lección monta el monitor de un modelo en producción sobre un año de datos en el que la ventaja desaparece el 16 de junio: comprueba las entradas cada mañana, compara el IC con lo que prometía la validación, reduce y apaga con una política graduada, y lo comunica al ejecutor con un permiso que caduca si el monitor se calla.

Fig. — z de la media del IC del modelo en vivo frente al IC esperado, en ventanas de 21 y 63 sesiones, durante 2025 (datos sintéticos de la lección; la ventaja desaparece el 16 de junio) (esquema)
Lecciones del nivel 7
  1. 7.1 · Ingeniería de software para trading

    1. 7.1.1 Estructura de proyecto, configuración y calidad: del notebook al repositorio
    2. 7.1.2 Rendimiento: medir, perfilar y compilar solo lo que hace falta
    3. 7.1.3 Patrones: eventos, colas de mensajes y arquitectura hexagonal
  2. 7.2 · Infraestructura y despliegue

    1. 7.2.1 Docker y docker-compose: imágenes reproducibles con uv
    2. 7.2.2 VPS, nube y latencia; CI/CD con GitHub Actions
    3. 7.2.3 Orquestación y monitorización: grafos de tareas, logs estructurados, métricas, alertas y latidos
    4. 7.2.4 Seguridad y continuidad: 2FA, IP, copias y procedimientos ante fallos
  3. 7.3 · MLOps para modelos de trading

    1. 7.3.1 Registro de experimentos, modelos y datos: MLflow, DVC y una ejecución reproducida de principio a fin
    2. 7.3.2 Reentrenamiento programado, validación automática y champion/challenger
    3. 7.3.3 Monitorización del modelo en vivo y desactivación automática
    4. 7.3.4 Servir modelos: ONNX, inferencia en CPU o GPU y el coste de los LLM en producción
  4. 7.4 · Regulación, ética y fiscalidad

    1. 7.4.1 MiFID II: clientes, idoneidad y trading algorítmico
    2. 7.4.2 ESMA y CFD: apalancamiento, cierre y saldo negativo
    3. 7.4.3 PRIIPs, KID y ETFs estadounidenses
    4. 7.4.4 MiCA después del transitorio: comprobar un proveedor en los registros
    5. 7.4.5 Abuso de mercado: spoofing, layering y el bot que los imita sin querer
    6. 7.4.6 Fiscalidad del trading en España
    7. 7.4.7 Ética: datos personales, inteligencia artificial y la venta de señales
  5. 7.5 · Carrera profesional quant

    1. 7.5.1 Roles del quant: seis oficios alrededor de un mismo sistema
    2. 7.5.2 Dónde trabajar: qué hace cada empresa y qué dicen los registros
    3. 7.5.3 Portfolio público y entrevistas: enseñar criterio, no rentabilidades
    4. 7.5.4 Certificaciones, formación y prop firms: qué certifica cada cosa y qué mide un examen

Un modelo no avisa cuando muere

Las piezas ya están en lecciones anteriores. La 4.2.5 enseñó el kill switch de una estrategia: un CUSUM sobre sus rendimientos y un interruptor operativo con rearme manual. La 5.10.2 midió la deriva de datos (PSI, KS, validación adversaria) y la de concepto (media móvil del IC, CUSUM, Page-Hinkley) en un laboratorio. La 7.2.3 montó las métricas, las alertas y los latidos de un sistema. Esta lección las junta alrededor de un modelo en producción: qué se mira cada mañana antes de dejarle operar, con qué se compara su rendimiento, quién decide reducirlo o apagarlo y cómo llega esa decisión al proceso que envía las órdenes.

Modelo en vivoMonitorIC con retrasoPermiso en RedisMétricasAuditoríaEjecutor
Fuente Mermaid
flowchart TB
  M[Modelo en vivo] --> O[Monitor]
  E[IC con retraso] --> O
  O --> K[Permiso en Redis]
  O --> G[Métricas]
  O --> A[Auditoría]
  K --> X[Ejecutor]

El monitor es un proceso aparte, como el motor de riesgo de la lección 6.4.5: no entrena ni predice, solo mira y decide. Su única salida hacia la operativa es un permiso: el multiplicador de tamaño con el que el ejecutor puede operar el modelo hoy.

Lo que se puede vigilar de un modelo se ordena por cuándo se sabe:

CapaEjemplosCuándo se sabeQué decide
Procesolatido, latencia, erroresal momentoalertas del sistema (7.2.3)
Entradascobertura, valores congelados, rangosantes de operarpausa del día
DerivaPSI y KS de features y predicciones (5.10.2)antes de operaraviso y diagnóstico
ResultadoIC, acierto, calibraciónal cerrar la etiquetareducir o apagar
NegocioP&L, slippage (4.5.3)al cierrerevisión

La fila de resultados es la que importa y la que llega tarde. Un modelo que predice el rendimiento de mañana se puede evaluar pasado mañana; uno que predice el de dentro de veinte sesiones, dentro de un mes. Las capas de arriba no dicen si el modelo gana dinero, pero llegan a tiempo para impedir que opere con datos rotos.

El laboratorio: un año y medio con la respuesta conocida

Cincuenta activos y un modelo cuyas predicciones están correlacionadas con lo que el mercado paga. Hasta el 16 de junio de 2025 el rendimiento del día siguiente depende de esa componente; desde ese día, no. Es la muerte de la ventaja de la 4.2.5, aplicada al modelo en vez de a la estrategia. Encima, tres incidentes de la tubería: dos días con las features congeladas en marzo y un día de abril con un 35 % de predicciones vacías. El periodo de julio a diciembre de 2024 sirve de referencia: el modelo ya estaba en producción y se conoce su IC en vivo.

IN [01]python
import numpy as np
import pandas as pd

rng = np.random.default_rng(702)
fechas = pd.bdate_range("2024-07-01", "2025-12-31")
T, N = len(fechas), 50
MUERTE = pd.Timestamp("2025-06-16")       # la ventaja desaparece
S = rng.standard_normal((T, N))           # lo que el mercado paga
P = S + rng.standard_normal((T, N))       # predicción del modelo
alfa = np.where(fechas < MUERTE, 0.12, 0.0)
Z = alfa[:, None] * S + rng.standard_normal((T, N))
R = 0.01 * Z                              # rendimiento de t a t+1

INCIDENTES = {                            # fallos de la tubería
    "2025-03-11": "features congeladas",
    "2025-03-12": "features congeladas",
    "2025-04-22": "35 % de predicciones vacías",
}
i = fechas.get_loc(pd.Timestamp("2025-03-10"))
P[i + 1] = P[i + 2] = P[i]                # el proveedor no actualiza
j = fechas.get_loc(pd.Timestamp("2025-04-22"))
P[j, rng.choice(N, 17, replace=False)] = np.nan

def ic_dia(p, r):
    """IC de Spearman de un día, solo con predicciones válidas."""
    m = np.isfinite(p)
    a = p[m].argsort().argsort() - (m.sum() - 1) / 2
    b = r[m].argsort().argsort() - (m.sum() - 1) / 2
    return (a * b).sum() / np.sqrt((a * a).sum() * (b * b).sum())

ic = pd.Series([ic_dia(p, r) for p, r in zip(P, R)], index=fechas)
antes = ic["2025":MUERTE].iloc[:-1]
print("IC medio: 2.º semestre de 2024",
      round(ic[:"2024-12"].mean(), 3), "| 2025 hasta la muerte",
      round(antes.mean(), 3), "| después", round(ic[MUERTE:].mean(), 3))
print("desviación típica del IC diario:",
      round(ic[:"2024-12"].std(), 3))
OUT [01]Un año y medio del modelo en vivo, con la respuesta conocida
IC medio: 2.º semestre de 2024 0.084 | 2025 hasta la muerte 0.071 | después -0.028
desviación típica del IC diario: 0.128

El IC medio es de 0,084 en el segundo semestre de 2024, de 0,071 en 2025 hasta la muerte y de −0,028 después. La desviación típica del IC diario, 0,128, es la de cualquier modelo con 50 activos (≈1/50\approx 1/\sqrt{50}): un solo día no dice casi nada.

La primera regla del monitor es no usar lo que todavía no se sabe. Antes de la apertura del 12 de marzo, las predicciones de hoy ya están, pero el último IC conocido es el del 10: el rendimiento del 11 al 12 aún no ha cerrado.

IN [02]python
def lo_que_se_sabe(dia):
    """Antes de la apertura de `dia`: predicciones de hoy, y los IC
    hasta el de anteayer (su rendimiento cerró ayer)."""
    k = fechas.get_loc(dia)
    return P[k], ic.iloc[:k - 1]

hoy = pd.Timestamp("2025-03-12")
pred, ics = lo_que_se_sabe(hoy)
print("hoy:", hoy.date(), "| último IC conocido:", ics.index[-1].date())
print("predicciones iguales a las de ayer:",
      f"{np.mean(pred == P[fechas.get_loc(hoy) - 1]):.0%}")
OUT [02]Cuándo se sabe cada cosa
hoy: 2025-03-12 | último IC conocido: 2025-03-10
predicciones iguales a las de ayer: 100%

Pausa: cuando lo que falla es la tubería

Las comprobaciones de entrada se hacen cada mañana sobre las predicciones del día, antes de que lleguen al ejecutor. Son baratas, no necesitan esperar a ningún resultado y separan un tipo de fallo que no hay que confundir con la pérdida de ventaja:

IN [03]python
def operativas(k, min_cobertura=0.9, max_repetidas=0.5):
    """Lista de problemas de las predicciones del día k (vacía si
    no hay ninguno)."""
    p, ayer = P[k], P[k - 1]
    problemas = []
    cobertura = np.isfinite(p).mean()
    if cobertura < min_cobertura:
        problemas.append(f"cobertura {cobertura:.0%}")
    repetidas = np.mean(p == ayer)
    if repetidas > max_repetidas:
        problemas.append(f"repetidas {repetidas:.0%}")
    return problemas

inicio = fechas.get_loc(pd.Timestamp("2025-01-02"))
for k in range(inicio, T):
    if p := operativas(k):
        print(fechas[k].date(), p)
OUT [03]Comprobaciones operativas: cobertura y datos congelados
2025-03-11 ['repetidas 100%']
2025-03-12 ['repetidas 100%']
2025-04-22 ['cobertura 66%']

Las tres, exactamente los tres incidentes plantados: el 11 y el 12 de marzo todas las predicciones son iguales a las del día anterior (el proveedor no actualizó las features y el modelo, determinista, repite su salida), y el 22 de abril solo un 66 % de los activos tiene predicción. Ningún otro día de 2025 dispara nada.

La respuesta es una pausa: ese día el modelo no opera, y al día siguiente, si las comprobaciones pasan, vuelve solo. No es la desactivación del modelo. Un modelo sano con datos congelados no ha perdido su ventaja; apagarlo y esperar a que alguien lo rearme castiga al modelo por un fallo de la tubería, y la tubería es lo que hay que arreglar (con su alerta). Al revés también es un error: tratar una pérdida de ventaja como un fallo pasajero que se recupera solo.

Hay más comprobaciones de este tipo: rangos de cada feature, que la distribución de las predicciones no cambie de forma (el PSI de la 5.10.2 aplicado a la salida del modelo), que la versión del modelo cargada sea la del alias champion (lección 7.3.2). Todas comparten la propiedad que las hace útiles: se saben antes de operar.

Lo esperado y lo observado

Para juzgar el IC en vivo hace falta saber qué se esperaba. Ese número sale de la validación del modelo (el IC del walk-forward o del challenger en la lección 7.3.2), rebajado: el IC de una validación es el del mejor de varios intentos, y en vivo suele ser menor (lección 5.4.4). La lección usa 0,06. La desviación típica del IC diario se mide en vivo, en el periodo de referencia.

Con las dos cosas, la comparación es un z de la media del IC de las últimas nn sesiones:

zn=IC‾n−μ0σ/nz_n = \frac{\overline{IC}_n - \mu_0}{\sigma / \sqrt{n}}

Con μ0\mu_0 el IC esperado y σ\sigma la desviación típica del IC diario. Un modelo que rinde lo esperado tiene zz alrededor de cero; uno muerto, con IC medio cero, se va a −μ0n/σ-\mu_0\sqrt{n}/\sigma: −2,1 con 21 sesiones y −3,7 con 63. Es la diferencia con la 5.10.2, que vigilaba si el IC cambiaba: aquí se pregunta si el modelo cumple lo que se prometió al desplegarlo.

IN [04]python
MU0 = 0.06       # IC esperado: el de la validación, con descuento
SIGMA = ic[:"2024-12"].std()    # del IC diario, medido en vivo

def z_ic(ics, n):
    """z de la media de los n últimos IC frente a lo esperado."""
    x = ics.iloc[-n:]
    return (x.mean() - MU0) / (SIGMA / np.sqrt(len(x)))

def zs(dia):
    _, ics = lo_que_se_sabe(dia)
    return tuple(round(float(z_ic(ics, n)), 2) for n in (21, 63))

for d in ("2025-03-03", "2025-05-02", "2025-07-01", "2025-09-01"):
    print(d, "z21, z63 =", zs(pd.Timestamp(d)))
OUT [04]Lo esperado y lo observado: z del IC en dos ventanas
2025-03-03 z21, z63 = (0.53, 1.19)
2025-05-02 z21, z63 = (2.23, 0.73)
2025-07-01 z21, z63 = (-1.07, 1.07)
2025-09-01 z21, z63 = (-1.83, -3.49)

En marzo y mayo, el modelo va bien (el 2 de mayo, incluso mejor de lo esperado en la ventana corta: +2,23). El 1 de julio, dos semanas después de la muerte, la ventana larga todavía marca +1,07: está llena de días buenos. El 1 de septiembre las dos están por debajo de −1,8, y la larga en −3,49.

Una política graduada

Con dos ventanas y dos acciones posibles (reducir el tamaño y apagar) se construye una máquina de estados pequeña:

z21 < -2z21 >= -2z63 < -2z63 < -2z63 > -1z63 < -3rearme manualACTIVOVIGILANCIAREDUCIDODESACTIVADO
Fuente Mermaid
stateDiagram-v2
  [*] --> ACTIVO
  ACTIVO --> VIGILANCIA: z21 < -2
  VIGILANCIA --> ACTIVO: z21 >= -2
  ACTIVO --> REDUCIDO: z63 < -2
  VIGILANCIA --> REDUCIDO: z63 < -2
  REDUCIDO --> ACTIVO: z63 > -1
  REDUCIDO --> DESACTIVADO: z63 < -3
  DESACTIVADO --> ACTIVO: rearme manual

Cuatro decisiones de diseño:

  • La ventana corta solo avisa. Con 21 sesiones el error de la media del IC es de 0,028, casi la mitad del IC esperado: un modelo sano baja de −2 con frecuencia. Sirve para mirar antes, no para actuar.
  • La acción barata necesita menos evidencia que la cara. Reducir a la mitad se deshace solo y cuesta poco si era una falsa alarma; apagar exige rearme y deja el modelo fuera hasta que alguien lo revise.
  • Histéresis. De REDUCIDO no se vuelve a ACTIVO al cruzar −2 hacia arriba, sino al pasar de −1. Sin ella, un zz que ronda el umbral haría que el tamaño cambiase cada día, con sus costes.
  • Rearme manual, como en la 4.2.5 y en el artículo 12 del Reglamento Delegado (UE) 2017/589 que cita esa lección: lo que apaga un modelo es algo que nadie entiende todavía.
IN [05]python
from dataclasses import dataclass, field

MULT = {"ACTIVO": 1.0, "VIGILANCIA": 1.0, "REDUCIDO": 0.5,
        "DESACTIVADO": 0.0}

@dataclass
class Politica:
    z_vigilar: float = -2.0      # ventana de 21: solo avisa
    z_reducir: float = -2.0      # ventana de 63: mitad de tamaño
    z_apagar: float = -3.0       # ventana de 63: a cero
    z_volver: float = -1.0       # histéresis para volver a ACTIVO
    estado: str = "ACTIVO"
    cambios: list = field(default_factory=list)

    def decidir(self, dia, z21, z63):
        e = self.estado
        if e == "DESACTIVADO":
            return e                     # solo una persona lo rearma
        if z63 < self.z_apagar:
            e = "DESACTIVADO"
        elif z63 < self.z_reducir:
            e = "REDUCIDO"
        elif e == "REDUCIDO" and z63 < self.z_volver:
            e = "REDUCIDO"               # no vuelve hasta z63 > -1
        elif z21 < self.z_vigilar:
            e = "VIGILANCIA"
        else:
            e = "ACTIVO"
        if e != self.estado:
            self.cambios.append((dia.date(), self.estado, e,
                                 round(z21, 2) + 0.0,
                                 round(z63, 2) + 0.0))
            self.estado = e
        return e

def ejecutar_anio(pol):
    filas = []
    for k in range(inicio, T):
        dia = fechas[k]
        _, ics = lo_que_se_sabe(dia)
        estado = pol.decidir(dia, z_ic(ics, 21), z_ic(ics, 63))
        pausa = operativas(k)
        filas.append({"fecha": dia, "estado": estado,
                      "pausa": bool(pausa),
                      "mult": 0.0 if pausa else MULT[estado]})
    return pd.DataFrame(filas).set_index("fecha")

pol = Politica()
diario = ejecutar_anio(pol)
for c in pol.cambios:
    print(*c)
print("días en pausa:", int(diario["pausa"].sum()))
OUT [05]La política: cuatro estados y una pausa
2025-04-04 ACTIVO VIGILANCIA -2.07 0.0
2025-04-07 VIGILANCIA ACTIVO -1.86 -0.15
2025-07-15 ACTIVO VIGILANCIA -2.03 -0.26
2025-07-30 VIGILANCIA REDUCIDO -3.28 -2.11
2025-08-06 REDUCIDO DESACTIVADO -3.46 -3.01
días en pausa: 3

La figura de la cabecera sigue las dos ventanas todo el año. Antes de la muerte, una sola alarma: la ventana corta toca −2,07 el viernes 4 de abril y el lunes ya ha vuelto. Es una falsa alarma y la política la trata como tal, porque la ventana larga está en 0. Después, la corta avisa el 15 de julio, la larga cruza −2 el 30 de julio (32 sesiones después de la muerte) y −3 el 6 de agosto (37 sesiones). Los tres días de pausa son los tres incidentes.

¿Merece la pena? La cartera del modelo compra los diez activos con mejor predicción y vende los diez peores cada día, con un coste de 5 pb por pata:

IN [06]python
def cartera(k, n=10):
    """Rendimiento de comprar los n mejores y vender los n peores."""
    ok = np.where(np.isfinite(P[k]))[0]
    orden = ok[np.argsort(P[k, ok])]
    return R[k, orden[-n:]].mean() - R[k, orden[:n]].mean()

rc = pd.Series([cartera(k) for k in range(inicio, T)],
               index=fechas[inicio:])
con = rc * diario["mult"]
COSTE = 0.0005                    # 5 pb por rotación completa
for nombre, serie, m in (("sin política", rc, 1.0),
                         ("con política", con, diario["mult"])):
    neto = serie - COSTE * 2 * m  # dos patas, rotación diaria
    print(f"{nombre:13} antes {neto[:MUERTE].iloc[:-1].sum():+.1%}"
          f" · después {neto[MUERTE:].sum():+.1%}")
OUT [06]Lo que vale la desactivación: cartera larga-corta
sin política  antes +13.3% · después -19.0%
con política  antes +15.0% · después -4.5%
Fig. — Rendimiento neto acumulado (suma de rendimientos diarios, con 5 pb de coste por pata) de la cartera larga-corta del modelo en 2025, con y sin la política de desactivación (datos sintéticos de la lección) (esquema)

Desde la muerte hasta fin de año, la cartera pierde un 19,0 % sin la política y un 4,5 % con ella (sumas de rendimientos diarios). Antes de la muerte la política no cuesta nada: un 15,0 % frente a un 13,3 %, porque las tres pausas evitaron operar con datos rotos y sus costes. Una estrategia muerta no gana cero: pierde sus costes de transacción cada día, y el monitor es lo que pone fin a eso.

Los umbrales −2 y −3 no salen de una tabla: se eligen mirando lo que cuestan. Dos números por umbral, los mismos de la 4.2.5: cuántas veces actúa la política en un año en que el modelo está sano y cuánto tarda en actuar cuando muere. Se simulan miles de años con IC diarios normales de media μ0\mu_0 y desviación σ\sigma, y miles de muertes:

IN [07]python
def simular(mu, dias, n, pol_kw, semilla=0):
    """n trayectorias de IC diario normales; devuelve el primer día
    en REDUCIDO y en DESACTIVADO (o dias si no llega)."""
    g = np.random.default_rng(semilla)
    x = mu[None, :] + SIGMA * g.standard_normal((n, len(mu)))
    c = np.cumsum(np.pad(x, ((0, 0), (1, 0))), axis=1)
    m63 = (c[:, 63:] - c[:, :-63]) / 63      # medias móviles
    z63 = (m63 - MU0) / (SIGMA / np.sqrt(63))
    red = np.full(n, dias); apa = np.full(n, dias)
    for t in range(z63.shape[1]):
        nuevo_r = (z63[:, t] < pol_kw["z_reducir"]) & (red == dias)
        red[nuevo_r] = t
        nuevo_a = (z63[:, t] < pol_kw["z_apagar"]) & (apa == dias)
        apa[nuevo_a] = t
    return red, apa

filas = {}
for zr, za in ((-1.5, -2.5), (-2.0, -3.0), (-2.5, -3.5)):
    kw = {"z_reducir": zr, "z_apagar": za}
    sano = np.full(252 + 63, MU0)              # un año sano
    r0, a0 = simular(sano, 252, 4000, kw)
    muerto = np.r_[np.full(63, MU0), np.zeros(252)]  # muere en 63
    r1, a1 = simular(muerto, 252, 4000, kw, semilla=1)
    filas[f"{zr} / {za}"] = {
        "P_reducir_sano": (r0 < 252).mean(),
        "P_apagar_sano": (a0 < 252).mean(),
        "reducir_mediana": np.median(r1),
        "apagar_mediana": np.median(a1),
        "apagar_p90": np.quantile(a1, 0.9)}
pd.DataFrame(filas).T.round(3)
OUT [07]Calibrar la política: falsas alarmas y retraso
             P_reducir_sano  P_apagar_sano  reducir_mediana  apagar_mediana  apagar_p90
-1.5 / -2.5           0.665          0.145             23.0            40.0        61.0
-2.0 / -3.0           0.364          0.041             31.0            48.0        75.0
-2.5 / -3.5           0.145          0.008             40.0            57.0       108.0
Umbrales (reducir / apagar)Lo que ganaLo que cuesta
−1,5 / −2,5apaga en 40 sesiones de medianaun año sano reduce con probabilidad 0,67 y apaga con 0,15
−2 / −3apaga en 48 sesiones de medianareduce con 0,36 y apaga con 0,04
−2,5 / −3,5un año sano casi nunca apaga (0,008)apaga en 57 sesiones de mediana y 108 en el percentil 90

La política de la lección es la del medio. Que un año sano reduzca alguna vez con probabilidad 0,36 parece mucho, y es aceptable porque reducir es la acción barata. Lo que tiene que ser raro es apagar. Y hay un límite que ningún umbral evita: con un IC esperado de 0,06 y 50 activos, un modelo muerto necesita semanas para distinguirse de uno sano. Para detectar antes hay que tener más información por día (más activos, un IC esperado mayor) o aceptar más falsas alarmas.

El permiso para operar

La decisión del monitor tiene que llegar al ejecutor, que es otro proceso. La forma más simple y robusta es un permiso en un almacén compartido: el monitor lo escribe cada mañana con su estado, su multiplicador y una hora de caducidad; el ejecutor lo lee antes de cada orden. Tres propiedades:

  • A prueba de fallos. Si no hay permiso, si ha caducado o si el almacén no responde, el multiplicador es cero.
  • Caducidad. El permiso vale 26 horas. Si el monitor se cae, no hace falta que nadie se dé cuenta: al día siguiente el permiso ha caducado y el modelo deja de operar. Es la versión del latido de la lección 7.2.3 que actúa en vez de avisar.
  • Una sola dirección. El monitor escribe y el ejecutor lee. El ejecutor no puede renovar ni cambiar el permiso.
IN [08]python
import json
import fakeredis
import redis

servidor = fakeredis.FakeServer()
kv = fakeredis.FakeRedis(server=servidor, decode_responses=True)
CLAVE = "permiso:senal-n7"
VALIDEZ = 26 * 3600            # el monitor lo renueva cada mañana

def publicar(kv, ahora, estado, mult, motivo=""):
    """El monitor escribe el permiso del día (lo único que hace)."""
    kv.hset(CLAVE, mapping={"estado": estado, "mult": mult,
                            "valido_hasta": ahora + VALIDEZ,
                            "motivo": motivo})

def multiplicador(kv, ahora):
    """El ejecutor pregunta antes de cada orden. Ante la duda, 0."""
    try:
        p = kv.hgetall(CLAVE)
    except redis.exceptions.RedisError:
        return 0.0, "Redis no responde"
    if not p:
        return 0.0, "no hay permiso"
    if ahora > float(p["valido_hasta"]):
        return 0.0, "permiso caducado"
    return float(p["mult"]), p["estado"]

t0 = 1_750_000_000                     # reloj inyectado (segundos)
publicar(kv, t0, "REDUCIDO", 0.5, "z63 < -2")
print(multiplicador(kv, t0 + 3600))
print(multiplicador(kv, t0 + 30 * 3600))   # el monitor no renovó
servidor.connected = False                 # Redis cae
print(multiplicador(kv, t0 + 3600))
servidor.connected = True
OUT [08]El permiso para operar: en Redis, con caducidad y a prueba de fallos
(0.5, 'REDUCIDO')
(0.0, 'permiso caducado')
(0.0, 'Redis no responde')

En la celda, Redis lo simula fakeredis con el mismo cliente redis-py, y el reloj se inyecta (como en la 4.2.5) para que la caducidad se pueda probar sin esperar 26 horas. En producción, la caducidad la puede poner el propio Redis (SET con EX, o EXPIRE), de forma que la clave desaparece sola; el ejecutor la sigue tratando igual, porque «no hay permiso» y «permiso caducado» llevan al mismo cero.

Cada cambio de estado queda en el registro de auditoría con los números que lo provocaron, en JSON con las claves ordenadas (lección 7.2.3):

IN [09]python
import io
import structlog

buf = io.StringIO()               # en producción, un fichero JSONL
structlog.configure(
    processors=[structlog.processors.JSONRenderer(sort_keys=True)],
    logger_factory=structlog.PrintLoggerFactory(file=buf))
log = structlog.get_logger().bind(modelo="senal-n7", version=3)
for dia, antes, despues, z21, z63 in pol.cambios:
    log.warning("cambio_estado", fecha=str(dia), antes=antes,
                despues=despues, z21=z21, z63=z63)
print(*buf.getvalue().splitlines()[-2:], sep="\n")
OUT [09]Cada cambio de estado, en el registro de auditoría
{"antes": "VIGILANCIA", "despues": "REDUCIDO", "event": "cambio_estado", "fecha": "2025-07-30", "modelo": "senal-n7", "version": 3, "z21": -3.28, "z63": -2.11}
{"antes": "REDUCIDO", "despues": "DESACTIVADO", "event": "cambio_estado", "fecha": "2025-08-06", "modelo": "senal-n7", "version": 3, "z21": -3.46, "z63": -3.01}

Y las métricas del modelo se publican para Prometheus junto a las del sistema. La 7.2.3 explica cómo se recogen y cómo se escriben las reglas; aquí basta con lo específico del modelo: el zz de cada ventana, el multiplicador vigente y la edad del último resultado, que avisa de que el monitor se ha quedado ciego:

IN [10]python
from prometheus_client import (CollectorRegistry, Gauge,
                               generate_latest)

reg = CollectorRegistry()
g_z = Gauge("modelo_ic_z", "z del IC frente a lo esperado",
            ["modelo", "ventana"], registry=reg)
g_estado = Gauge("modelo_multiplicador", "Tamaño permitido (0 a 1)",
                 ["modelo"], registry=reg)
g_edad = Gauge("modelo_ultimo_ic_dias",
               "Sesiones desde el último IC conocido",
               ["modelo"], registry=reg)
dia = pd.Timestamp("2025-07-30")
z21, z63 = zs(dia)
g_z.labels("senal-n7", "21").set(z21)
g_z.labels("senal-n7", "63").set(z63)
g_estado.labels("senal-n7").set(diario.loc[dia, "mult"])
g_edad.labels("senal-n7").set(2)
print("\n".join(l for l in generate_latest(reg).decode().splitlines()
                if not l.startswith("#")))
OUT [10]Métricas del modelo para Prometheus
modelo_ic_z{modelo="senal-n7",ventana="21"} -3.28
modelo_ic_z{modelo="senal-n7",ventana="63"} -2.11
modelo_multiplicador{modelo="senal-n7"} 0.5
modelo_ultimo_ic_dias{modelo="senal-n7"} 2.0
IN [11]python
import yaml

REGLAS = """
groups:
- name: modelos
  rules:
  - alert: ModeloRendimientoBajo
    expr: modelo_ic_z{ventana="63"} < -2
    labels: {severidad: aviso}
  - alert: ModeloDesactivado
    expr: modelo_multiplicador == 0
    labels: {severidad: critica}
  - alert: ModeloSinResultados
    expr: modelo_ultimo_ic_dias > 3
    labels: {severidad: critica}
"""
for r in yaml.safe_load(REGLAS)["groups"][0]["rules"]:
    print(f'{r["alert"]:22} {r["expr"]:34} {r["labels"]["severidad"]}')
OUT [11]Reglas de alerta para las métricas del modelo
ModeloRendimientoBajo  modelo_ic_z{ventana="63"} < -2     aviso
ModeloDesactivado      modelo_multiplicador == 0          critica
ModeloSinResultados    modelo_ultimo_ic_dias > 3          critica

Después de apagar

Un modelo desactivado no vuelve porque su zz suba. Vuelve cuando alguien entiende qué ha pasado:

  1. Diagnosticar. ¿Cambiaron las features (PSI, validación adversaria de la 5.10.2) o solo su relación con el resultado? ¿Hubo un cambio en la tubería o en el mercado (un cambio de régimen, una regla nueva, una estrategia que se ha masificado)?
  2. Decidir qué opera mientras tanto. Nada (multiplicador cero), el modelo anterior (el alias anterior de la 7.3.2, si su receta sigue siendo válida) o una versión más conservadora.
  3. Volver por la puerta de entrada. Un modelo corregido o reentrenado entra como challenger en el ciclo de la 7.3.2, con sus puertas y su sombra. El rearme manual de este monitor solo se usa cuando la desactivación fue un error del propio monitor (por ejemplo, un IC esperado mal puesto).

Ejercicios

  1. Repite la calibración con la política −2 / −3 suponiendo que el IC real del modelo sano es 0,05 o 0,04 en lugar de 0,06 (el monitor sigue esperando 0,06). ¿Qué pasa con las falsas alarmas?
Solución
IN [12]python
kw = {"z_reducir": -2.0, "z_apagar": -3.0}
for mu_real in (0.06, 0.05, 0.04):
    r, a = simular(np.full(252 + 63, mu_real), 252, 4000, kw)
    print(f"IC real {mu_real:.2f}: P(reducir) {(r < 252).mean():.2f}"
          f" · P(apagar) {(a < 252).mean():.2f} en un año sano")
OUT [12]Si el IC esperado estaba inflado
IC real 0.06: P(reducir) 0.36 · P(apagar) 0.04 en un año sano
IC real 0.05: P(reducir) 0.73 · P(apagar) 0.18 en un año sano
IC real 0.04: P(reducir) 0.95 · P(apagar) 0.51 en un año sano

Con un IC real de 0,05, un año sano reduce alguna vez con probabilidad 0,73 y apaga con 0,18; con 0,04, con 0,95 y 0,51: la mitad de los años sanos terminarían con el modelo apagado. La política no distingue un modelo muerto de uno que rinde menos de lo prometido, porque las dos cosas son lo mismo para ella: un IC por debajo del esperado.

Hay dos lecturas, y las dos son útiles. La primera: el IC esperado tiene que ser prudente desde el principio (el de la validación con un descuento claro). La segunda: un modelo que rinde un tercio menos de lo que se validó también es una noticia que alguien debe revisar; quizá no haya que apagarlo, pero sí recalcular el tamaño de la posición y el IC esperado con los primeros meses en vivo, y dejarlo escrito.

  1. Añade a la política la regla de la alerta ModeloSinResultados: si el último IC conocido tiene más de cinco sesiones, el estado es como mucho REDUCIDO. Pruébala con una parada de la tubería de precios: el último IC que llega es el del 17 de febrero.
Solución
IN [13]python
def decidir_con_edad(pol, dia, ics, max_edad=5):
    """Edad: sesiones desde el último IC conocido, hoy incluido (2
    en un día normal). Con más de max_edad, el monitor está ciego:
    como mucho, REDUCIDO."""
    edad = len(fechas[(fechas > ics.index[-1]) & (fechas <= dia)])
    estado = pol.decidir(dia, z_ic(ics, 21), z_ic(ics, 63))
    if edad > max_edad and estado in ("ACTIVO", "VIGILANCIA"):
        return "REDUCIDO", edad
    return estado, edad

p2 = Politica()
_, ics = lo_que_se_sabe(pd.Timestamp("2025-02-27"))
ics_parados = ics[:"2025-02-17"]    # los precios dejan de llegar
for d in ("2025-02-19", "2025-02-27"):
    print(d, decidir_con_edad(p2, pd.Timestamp(d), ics_parados))
OUT [13]Sin resultados no hay vigilancia
2025-02-19 ('ACTIVO', 2)
2025-02-27 ('REDUCIDO', 8)

El 19 de febrero el último IC conocido sigue siendo el del 17 (edad 2, la normal) y el estado es ACTIVO. El 27, con ocho sesiones sin resultados, la regla lo lleva a REDUCIDO aunque el zz, calculado con IC viejos, siga diciendo que todo va bien. Si la parada continúa, una segunda regla (por ejemplo, más de diez sesiones) debería llevarlo a cero.

La alerta ModeloSinResultados salta antes (con edad mayor que 3): primero se avisa a una persona, y si el problema dura, la política actúa sola. Es el mismo principio del permiso con caducidad: un control que se queda sin información no puede seguir autorizando como si la tuviera.

  1. Un compañero propone que el modelo desactivado se rearme solo cuando z63z_{63} vuelva por encima de −1. ¿Qué le responderías?
Solución

Que el zz de un modelo apagado sigue moviéndose por azar, y un modelo muerto puede pasar por encima de −1 durante unos días: con IC verdadero cero, la media de 63 sesiones tiene una desviación típica de 0,016 y su zz oscila alrededor de −3,7 con desviación 1, así que cruzar −1 es raro pero no imposible en un año, y cada cruce lo volvería a encender para apagarlo semanas después, con pérdidas entre medias. Además, el zz solo mide el IC: no dice si la causa de la desactivación (una tubería rota, un cambio de régimen) se ha resuelto. Y si el modelo no opera, su IC se puede seguir calculando en sombra, pero eso es exactamente lo que hace el ciclo de la 7.3.2 con un challenger, con puertas y con una persona que decide. La vuelta automática deshace lo que el rearme manual protege.

Respuesta plegada en cada pregunta

Autoevaluación

  1. El 11 y el 12 de marzo las predicciones del modelo son idénticas a las del 10. ¿Qué hace la política de la lección?

    • ADesactiva el modelo hasta que lo rearme una persona
    • BPausa esos dos días (multiplicador 0) y vuelve sola a operar en cuanto las predicciones cambian
    • CNada: el IC no ha bajado
    • DReduce el tamaño a la mitad durante un mes
    Ver respuesta

    Respuesta B. Pausa esos dos días (multiplicador 0) y vuelve sola a operar en cuanto las predicciones cambian

    Unas predicciones congeladas son un fallo de la tubería, no del modelo: se ven antes de operar y se arreglan arreglando la tubería. La pausa no toca el estado estadístico del modelo ni exige rearme; apagar el modelo por un fallo de datos lo dejaría parado sin motivo.

  2. ¿Por qué la política solo avisa (VIGILANCIA) con la ventana de 21 sesiones y deja reducir y apagar a la de 63?

    • APorque la ventana de 21 sesiones no se puede calcular hasta febrero
    • BPorque Prometheus no admite ventanas cortas
    • CPorque la de 63 reacciona antes
    • DPorque con 21 sesiones el error de la media del IC es casi el doble: un modelo sano cruza −2 con frecuencia, como el 4 de abril en la lección (−2,07)
    Ver respuesta

    Respuesta D. Porque con 21 sesiones el error de la media del IC es casi el doble: un modelo sano cruza −2 con frecuencia, como el 4 de abril en la lección (−2,07)

    El error estándar de la media es σ/√n: con σ = 0,128, 0,028 en 21 sesiones frente a 0,016 en 63. La ventana corta es útil para mirar antes, no para actuar: en el laboratorio, la única alarma antes de la muerte de la ventaja viene de ella y dura un fin de semana.

  3. La ventaja desaparece el 16 de junio. ¿Cuándo actúa la política de la lección?

    • AReduce a la mitad el 30 de julio (z63 = −2,11) y apaga el 6 de agosto (z63 = −3,01)
    • BApaga el 17 de junio
    • CNo actúa en todo 2025
    • DApaga el 15 de julio, cuando la ventana de 21 sesiones baja de −2
    Ver respuesta

    Respuesta A. Reduce a la mitad el 30 de julio (z63 = −2,11) y apaga el 6 de agosto (z63 = −3,01)

    La media de 63 sesiones tarda en llenarse de días sin ventaja: 37 sesiones en apagar, algo menos que la mediana de 48 de la calibración. Más deprisa solo se puede con umbrales que, en un año sano, apagarían el modelo muchas más veces.

  4. El ejecutor lee el permiso en Redis antes de cada orden y Redis no responde. ¿Qué devuelve multiplicador?

    • AEl último valor que leyó, guardado en memoria
    • B1, para no perder oportunidades
    • C0: el permiso es a prueba de fallos, y sin respuesta no se opera
    • DLanza una excepción y el ejecutor decide
    Ver respuesta

    Respuesta C. 0: el permiso es a prueba de fallos, y sin respuesta no se opera

    Un control que falla abierto deja de controlar justo cuando más falta hace. Si el monitor no escribe (el permiso caduca) o el almacén no responde, el ejecutor no puede saber si el modelo sigue autorizado, y lo seguro es no operar.

  5. En la calibración, con umbrales −2 y −3, un año sano reduce el tamaño alguna vez con probabilidad 0,36. ¿Es un problema?

    • ASí: hay que subir el umbral hasta que no reduzca nunca
    • BNo necesariamente: reducir es barato y reversible (vuelve con z63 > −1); lo que tiene que ser raro es apagar, que en un año sano pasa con probabilidad 0,04
    • CSí: significa que el modelo está mal
    • DNo: las falsas alarmas no cuestan nada
    Ver respuesta

    Respuesta B. No necesariamente: reducir es barato y reversible (vuelve con z63 > −1); lo que tiene que ser raro es apagar, que en un año sano pasa con probabilidad 0,04

    La política es graduada para eso: la acción barata se dispara con menos evidencia y la cara con más. Un umbral de reducir tan exigente como el de apagar perdería la ventaja de tener dos escalones.

Ideas clave

  • Lo que se vigila de un modelo se ordena por cuándo se sabe: las entradas (cobertura, valores congelados) antes de operar; los resultados (IC, acierto), con el retraso de la etiqueta.
  • Un fallo de la tubería pausa el día y se recupera solo; una pérdida de ventaja reduce y apaga, y solo una persona rearma. Mezclarlos apaga modelos sanos o deja operar modelos muertos.
  • El rendimiento se compara con lo que prometía la validación: z de la media del IC frente al esperado en 21 y 63 sesiones. La ventana corta avisa; la larga actúa.
  • En el laboratorio la ventaja muere el 16 de junio; el monitor reduce el 30 de julio y apaga el 6 de agosto. Después de la muerte, la cartera pierde un 19,0 % sin la política y un 4,5 % con ella.
  • Los umbrales se eligen por simulación: con −2 y −3, un año sano reduce alguna vez con probabilidad 0,36 y apaga con 0,04; un modelo muerto se apaga en una mediana de 48 sesiones. Si el IC esperado está inflado, las falsas alarmas se disparan.
  • La decisión llega al ejecutor como un permiso con caducidad en Redis: sin permiso, caducado o sin respuesta, el multiplicador es cero.

Recursos