---
title: "7.3.3 Monitorización en vivo y desactivación automática"
description: "Un monitor que compara el IC en vivo con el esperado, separa fallos de datos de pérdida de ventaja y desactiva el modelo con un permiso en Redis."
url: "https://pythonparatrading.com/curso-python-trading/nivel-7/monitorizacion-en-vivo-y-desactivacion-automatica/"
lang: "es"
image: "https://pythonparatrading.com/og/curso-python-trading/nivel-7/monitorizacion-en-vivo-y-desactivacion-automatica.png"
---

# 7.3.3 · 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.

- Curso: [Nivel 7: Producción, MLOps, regulación, carrera y capstone](https://pythonparatrading.com/curso-python-trading/nivel-7/)
- Módulo: 7.3 · MLOps para modelos de trading
- Duración: 70 min
- Requisitos: [4.2.5 · Control del drawdown y kill switch](https://pythonparatrading.com/curso-python-trading/nivel-4/control-del-drawdown-y-kill-switch/), [5.10.2 · Deriva de datos y de concepto: detectarla y qué hacer](https://pythonparatrading.com/curso-python-trading/nivel-5/deriva-de-datos-y-de-concepto/), [7.2.3 · Orquestación y monitorización: grafos de tareas, logs estructurados, métricas, alertas y latidos](https://pythonparatrading.com/curso-python-trading/nivel-7/orquestacion-y-monitorizacion/)

## Al terminar esta lección

- Ordenar lo que se vigila de un modelo en producción por cuándo se sabe: entradas antes de operar, resultados con el retraso de la etiqueta.
- Separar los fallos de la tubería (pausa del día, automática) de la pérdida de ventaja (reducir y apagar, con rearme manual).
- Comparar el IC en vivo con el esperado con un z en dos ventanas y calibrar los umbrales de una política graduada por su tasa de falsas alarmas y su retraso.
- Comunicar la decisión al ejecutor con un permiso en Redis que caduca y que, ante cualquier duda, vale cero.
- Publicar las métricas del modelo para Prometheus y dejar cada cambio de estado en un registro de auditoría.

## 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.

```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**:

| Capa | Ejemplos | Cuándo se sabe | Qué decide |
|---|---|---|---|
| Proceso | latido, latencia, errores | al momento | alertas del sistema (7.2.3) |
| Entradas | cobertura, valores congelados, rangos | antes de operar | pausa del día |
| Deriva | PSI y KS de features y predicciones (5.10.2) | antes de operar | aviso y diagnóstico |
| Resultado | IC, acierto, calibración | al cerrar la etiqueta | reducir o apagar |
| Negocio | P&L, slippage (4.5.3) | al cierre | revisió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.

```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))
```
```output
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 ($\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.

```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%}")
```
```output
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:

```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)
```
```output
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 $n$ sesiones:

$$
z_n = \frac{\overline{IC}_n - \mu_0}{\sigma / \sqrt{n}}
$$

Con $\mu_0$ el IC esperado y $\sigma$ la desviación típica del IC diario. Un modelo que rinde lo esperado tiene $z$
alrededor de cero; uno muerto, con IC medio cero, se va a $-\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.

```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)))
```
```output
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:

```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 $z$
  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.

```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()))
```
```output
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:

```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%}")
```
```output
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).* Dos líneas que suben casi juntas hasta mediados de junio de 2025, al 13,3 % sin política y al 15,0 % con ella. Tras la muerte de la ventaja el 16 de junio, la línea sin política cae sin parar hasta −5,7 % a final de año; la línea con política baja menos, se reduce a la mitad el 30 de julio, se aplana al desactivarse el 6 de agosto y termina en +10,4 %.

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 $\mu_0$ y desviación $\sigma$, y miles de muertes:

```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)
```
```output
             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 gana | Lo que cuesta |
|---|---|---|
| −1,5 / −2,5 | apaga en 40 sesiones de mediana | un año sano reduce con probabilidad 0,67 y apaga con 0,15 |
| −2 / −3 | apaga en 48 sesiones de mediana | reduce con 0,36 y apaga con 0,04 |
| −2,5 / −3,5 | un 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.

<aside class="callout" data-kind="aviso">

La simulación supone que el IC esperado es correcto. Si la validación lo inflaba y el modelo sano rinde 0,05 en vez
de 0,06, la política ve un modelo «peor de lo prometido» y actúa mucho más (el primer ejercicio lo mide). Es una
razón más para rebajar el IC de la validación antes de usarlo como referencia, y para revisarlo con los primeros meses
en vivo.

</aside>

## 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.

```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
```
```output
(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):

```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")
```
```output
{"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 $z$ de cada ventana, el multiplicador vigente y la
**edad del último resultado**, que avisa de que el monitor se ha quedado ciego:

```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("#")))
```
```output
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
```

```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"]}')
```
```output
ModeloRendimientoBajo  modelo_ic_z{ventana="63"} < -2     aviso
ModeloDesactivado      modelo_multiplicador == 0          critica
ModeloSinResultados    modelo_ultimo_ic_dias > 3          critica
```

<aside class="callout" data-kind="riesgo">

`ModeloSinResultados` es la alerta que más se olvida. Si la tubería que calcula los rendimientos se para, el IC deja de
actualizarse, el $z$ se queda en su último valor y el monitor sigue diciendo ACTIVO con la información de hace una
semana. Un monitor sin datos nuevos tiene que comportarse como un permiso caducado: reducir o apagar, no seguir.

</aside>

## Después de apagar

Un modelo desactivado no vuelve porque su $z$ 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.**

```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")
```
```output
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.

2. 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.**

```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))
```
```output
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 $z$, 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.

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

**Solución.**

Que el $z$ 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 $z$ 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 $z$ 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.

## 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?

   - A. Desactiva el modelo hasta que lo rearme una persona
   - B. Pausa esos dos días (multiplicador 0) y vuelve sola a operar en cuanto las predicciones cambian
   - C. Nada: el IC no ha bajado
   - D. Reduce el tamaño a la mitad durante un mes

   *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?

   - A. Porque la ventana de 21 sesiones no se puede calcular hasta febrero
   - B. Porque Prometheus no admite ventanas cortas
   - C. Porque la de 63 reacciona antes
   - 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)

   *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?

   - A. Reduce a la mitad el 30 de julio (z63 = −2,11) y apaga el 6 de agosto (z63 = −3,01)
   - B. Apaga el 17 de junio
   - C. No actúa en todo 2025
   - D. Apaga el 15 de julio, cuando la ventana de 21 sesiones baja de −2

   *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`?

   - A. El último valor que leyó, guardado en memoria
   - B. 1, para no perder oportunidades
   - C. 0: el permiso es a prueba de fallos, y sin respuesta no se opera
   - D. Lanza una excepción y el ejecutor decide

   *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?

   - A. Sí: hay que subir el umbral hasta que no reduzca nunca
   - 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
   - C. Sí: significa que el modelo está mal
   - D. No: las falsas alarmas no cuestan nada

   *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

### Research

- [Máquinas de Estado Finito para Ejecución de Trading Cuantitativo en Python](https://pythonparatrading.com/maquinas-estado-finito-trading-python/) · Research-0029 · 49 min

### Documentación

- [Prometheus: Alerting rules](https://prometheus.io/docs/prometheus/latest/configuration/alerting_rules/) · prometheus.io
- [Redis: EXPIRE (claves con caducidad)](https://redis.io/docs/latest/commands/expire/) · redis.io
- [structlog: documentación](https://www.structlog.org/en/stable/) · structlog.org
- [Google SRE Book: Monitoring Distributed Systems](https://sre.google/sre-book/monitoring-distributed-systems/) · sre.google

### Libros

- [Breck, E. et al. (2017): The ML Test Score: A Rubric for ML Production Readiness and Technical Debt Reduction](https://research.google/pubs/the-ml-test-score-a-rubric-for-ml-production-readiness-and-technical-debt-reduction/) · research.google

Lección anterior: [7.3.2 · Reentrenamiento programado, validación automática y champion/challenger](https://pythonparatrading.com/curso-python-trading/nivel-7/reentrenamiento-validacion-y-champion-challenger/)

Lección siguiente: [7.3.4 · Servir modelos: ONNX, inferencia en CPU o GPU y el coste de los LLM en producción](https://pythonparatrading.com/curso-python-trading/nivel-7/servir-modelos-onnx-y-coste-de-llms/)
