Saltar a contenido

Q01 · Time-series momentum con objetivo de volatilidad

Campo Valor
Familia Momentum temporal (cada activo contra su propio pasado) con sizing por volatilidad
Universo / panel Panel multiactivo alineado (align_panel); en la evidencia, us_equities_36 con 34 columnas densas diarias XNYS
Datos Cierres ajustados o retornos válidos, precios ejecutables (open), calendario
Features returns (log_return{lag=1}), momentum{lookback, skip=0}, rolling_vol{window, ddof=0} sin anualizar
Parámetros y defaults lookback = 252, vol_window = 63, vol_target = 0.10, gross_cap = 1.5, vol_floor = 0.02, cap_individual = 0.25, rebalance_bars = 21
Rejilla lookback ∈ {63, 126, 252}, vol_window ∈ {20, 63, 126}, rebalance_bars ∈ {5, 21} → 18 candidatos válidos
Estado Implementada (hito H3) · paridad 3/3 PASS en carriles de pesos · carril nativo UNSUPPORTED (D-14)
Motores implicados finazbench/policy/q01_tsmom.py (Q01Tsmom, Q01PortfolioState), ledger de cartera (WeightIntentArrays), carriles Nautilus nt_weight_* (D-40)

Fuentes: catalog/strategy_catalog.json, docs/estrategias/Q01.md (especificación con fixture), finazbench/policy/q01_tsmom.py, runs/parity/summary_weights.json, docs/DECISIONES.md (D-14, D-21, D-22, D-40).

Qué vas a aprender

  • Qué es el time-series momentum y en qué se diferencia del momentum transversal de Q02.
  • Cómo se convierte una señal de signo en un peso por volatilidad con tres topes encadenados: vol_floor, cap_individual y gross_cap.
  • Por qué la decisión se toma con close[t] y se ejecuta en open[t+1], y por qué las cantidades se truncan hacia cero.
  • A seguir el primer rebalanceo del fixture de la especificación número a número.
  • Qué acredita (y qué no) la paridad de 2912 fills sobre 34 valores.

La idea en una frase

Si un activo ha subido en el último año, estar largo; si ha bajado, estar corto; y dimensionar cada posición para que aporte aproximadamente la misma volatilidad.

Intuición y evidencia académica

Intuición. La literatura de time-series momentum en futuros, divisas, bonos y acciones documenta que el retorno pasado de un activo tiene cierto poder para predecir su retorno futuro a horizontes de semanas a meses. Las explicaciones habituales son la infrarreacción a la información, los flujos lentos de capital y el comportamiento gregario.

Por qué el objetivo de volatilidad. Un activo con volatilidad del 60 % anual y otro con 15 % no deberían pesar lo mismo: el primero dominaría el riesgo de la cartera. Dividir por la volatilidad iguala el riesgo individual de cada posición.

Cuándo falla.

  • Giros bruscos (momentum crashes): tras una caída larga, un rebote violento pilla la cartera corta.
  • Mercados sin tendencia: el signo cambia de rebalanceo en rebalanceo y la cartera paga costes sin capturar nada.
  • Correlación alta: el objetivo de volatilidad es por activo; si todos se mueven juntos, la volatilidad de la cartera supera el objetivo.

Variante propia

El catálogo lo dice explícitamente: es una variante de investigación inspirada en TSMOM, no una reproducción de ningún factor publicado.

El modelo matemático

Pesos de Q01 según momentum y volatilidad objetivo

En cada barra de rebalanceo t y para cada activo elegible i (de S elegibles):

m_i[t]   = ln(P_i[t]) − ln(P_i[t − L])              momentum, L = lookback
s_i      = sign(m_i[t])                              con sign(0) = 0
v_i[t]   = std(r_i[t−V+1 .. t], ddof=0) · sqrt(A_i)  vol anualizada, V = vol_window
w_i      = s_i · min(cap_individual,
                     vol_target / (S · max(v_i, vol_floor)))
gross    = sum_i |w_i|
si gross > gross_cap:   w_i ← w_i · gross_cap / gross    (escala común)

A_i es el factor de anualización del calendario del activo (252 sesiones para XNYS). La primitiva rolling_vol no anualiza: lo hace la política (D-22).

Conversión a cantidades (contrato WeightIntentArrays, D-21):

frozen_equity   = equity al cierre de t (se congela una vez por rebalanceo)
decision_price  = close_i[t]            (NO open[t+1])
q_i             = trunc( w_i · frozen_equity / decision_price_i )   hacia cero

Pseudocódigo causal

origin = first_decision_index = max(first_valid(momentum), first_valid(rolling_vol))
para cada barra t del panel (en orden, tras el watermark de panel):
    esperar a que las S barras de t estén cerradas          # watermark del panel
    si (t − origin) % rebalance_bars != 0:  mantener q; continuar
    elegibles = activos con feature finita y is_tradable en t
    S = len(elegibles)
    para i en elegibles: w_i = fórmula de arriba (solo datos con known_at <= t)
    escalar si gross > gross_cap
    congelar frozen_equity[t]; q_i = trunc(w_i · frozen_equity / close_i[t])
    emitir target_qty[t]; el ledger ejecuta el delta en open[t+1]

La consecuencia comprobable del watermark: cambiar el orden de llegada de las barras de t no cambia ni un peso ni una cantidad (la especificación lo prueba reproduciendo el fixture cuatro veces con órdenes distintos).

Cómo lo hace la plataforma

Pieza Quién la calcula
Alineación del panel align_panel del banco sobre el universo us_equities_36
log_return, momentum, rolling_vol Primitivas cuantitativas unificadas (D-22), proveedor custom_reference; VectorTA ayuda en features, no en el sizing
Signo, sizing, topes, escala finazbench/policy/q01_tsmom.py: targets_from_panel (batch) y Q01PortfolioState (online, un estado por cartera)
Anualización annualization_factor(calendar_id) en la misma política
Cantidades La política, con frozen_equity y decision_price = close[t] (D-21)
Ejecución y contabilidad Ledger de cartera (ledger/portfolio.py) y carriles NT_WEIGHT_REPLAY, NT_WEIGHT_FEATURES, NT_WEIGHT_ONLINE (D-40)
Pieza reutilizable finaz_risk.anualizar_volatilidad y volatilidad_cartera permiten medir la vol de cartera que Q01 no controla
flowchart LR
    D["Panel us_equities_36<br/>34 activos · 1d · watermark"] --> F["Features<br/>log_return · momentum 252<br/>rolling_vol 63"]
    F --> M["Señal<br/>s_i = sign(momentum)"]
    M --> W["Pesos<br/>vol_target / (S·max(v, floor))<br/>cap 0.25 · gross_cap 1.5"]
    W --> V["WeightIntentArrays<br/>target_weight · target_qty<br/>frozen_equity · close[t]"]
    V --> L["Lotes<br/>trunc hacia cero"]
    L --> E["Ledger de cartera<br/>fill open[t+1]<br/>paridad nt_weight_*"]

Ejemplo numérico

Del fixture de la especificación (docs/estrategias/Q01.md §6). Parámetros deliberadamente fuera de la rejilla para que la aritmética se pueda hacer a mano:

lookback = 3   vol_window = 3   rebalance_bars = 2
vol_target = 0.10   gross_cap = 0.40   vol_floor = 0.02   cap_individual = 0.25
S = 2 activos (AAA, BBB)   A = 252   cash inicial = 10.000,00   sin costes

Cierres de las primeras barras:

t close AAA close BBB
0 100,00 50,00
1 101,00 49,50
2 102,00 49,50
3 103,00 49,50

first_decision_index = 3, y con rebalance_bars = 2 los rebalanceos caen en t = 3, 5, 7, 9, 11, 13.

Primer rebalanceo, t = 3.

AAA: mom = ln(103) − ln(100) = +0.029559   → s = +1
     retornos 0.009950, 0.009852, 0.009756 → vol diaria 0.000079
     annvol = 0.000079 · 15.874508 = 0.001258  (< vol_floor: actúa el floor)
     w = min(0.25, 0.10 / (2 · 0.02)) = min(0.25, 2.5) = 0.25   (actúa el cap)

BBB: mom = ln(49.50) − ln(50.00) = −0.010050 → s = −1
     annvol = 0.075210
     w = −min(0.25, 0.10 / (2 · 0.075210)) = −min(0.25, 0.6648) = −0.25

gross = 0.50 > gross_cap = 0.40 → escala = 0.40 / 0.50 = 0.80
w_AAA = +0.20    w_BBB = −0.20

frozen_equity = 10.000,00
q_AAA = trunc(+0.20 · 10000 / 103.00) = trunc(+19.42) = +19
q_BBB = trunc(−0.20 · 10000 /  49.50) = trunc(−40.40) = −40

En este único rebalanceo actúan los tres topes: el floor (AAA tiene una volatilidad casi nula), el cap individual y el gross cap. El fill se ejecuta en open[4]: AAA +19 a 102,50 y BBB −40 a 49,50.

El caso sign(0). En t = 5 BBB lleva tres cierres idénticos (49,50): mom = 0 exactamente, s = 0, peso 0. La cartera cierra el corto de BBB. Es una decisión explícita de la especificación: sign(0) = 0, sin heredar el signo anterior.

Final del fixture. Cantidades objetivo por rebalanceo: (+19,−40), (+24,0), (+20,−39), (+18,−43), (+18,−43), (+19,−43). La decisión de t = 13 queda sin fill (no hay open[14]) y la equity final es 10.267,7000 con la cartera abierta. Los tests test_q01_fixture_* fijan todos estos números.

Evidencia de paridad real (runs/parity/summary_weights.json, generado el 2026-09-16):

Caso Activos Barras Fills ledger / Nautilus first_decision_index Estado
US34_1d_Q01_5bps_NT_WEIGHT_REPLAY 34 2690 2912 / 2912 252 PASS
US34_1d_Q01_5bps_NT_WEIGHT_FEATURES 34 2690 2912 / 2912 252 PASS
US34_1d_Q01_5bps_NT_WEIGHT_ONLINE 34 2690 2912 / 2912 252 PASS
US34_1d_Q01_5bps_NT_ONLINE_native UNSUPPORTED (D-14)

online_equals_batch = true en los tres PASS: el estado incremental reproduce exactamente las cantidades del batch. first_decision_index = 252 es lookback con los defaults.

Qué acredita y qué no

La paridad acredita que dos motores independientes producen los mismos fills con el mismo contrato. No acredita rentabilidad: el universo está formado por valores que sobrevivieron (aviso SURVIVORSHIP_BIAS en el propio fichero) y los tiempos son de desarrollo (is_benchmark: false).

Qué cambia al recorrer la rejilla

La rejilla del catálogo tiene 18 candidatos (3 × 3 × 2). No todos cuestan lo mismo:

Parámetro Qué invalida Trabajo compartido
lookback La feature momentum y el first_decision_index log_return se comparte entre los 18
vol_window La feature rolling_vol momentum se comparte entre los 3 valores
rebalance_bars Solo la política (qué barras rebalancean) Todas las features se comparten
vol_target, gross_cap, vol_floor, cap_individual Solo la política Fuera de la rejilla: fijos en los defaults

Por eso un barrido de Q01 calcula 3 curvas de momentum y 3 de volatilidad por activo, no 18 de cada una. Cambiar lookback también mueve el inicio: con 63 la primera decisión llega en la barra 63; con 252, en la 252. Comparar candidatos con distinto first_decision_index sobre el mismo periodo exige recortar al inicio más tardío.

Lectura de un rebalanceo real

En la corrida de paridad sobre 34 activos, cada rebalanceo produce 34 pesos. Con defaults, el peso máximo teórico por activo es min(0.25, 0.10 / (34 · 0.02)) = 0.147 (solo si la volatilidad cae al floor) y el gross máximo teórico 34 · 0.147 = 5.0, por encima de gross_cap = 1.5: con 34 activos el escalado puede actuar, al contrario que con S = 6. Pero con volatilidades realistas la cuenta es otra. Ejemplo didáctico: si todos los valores tuvieran una volatilidad anual del 25 %, cada peso sería 0.10 / (34 · 0.25) = 0.0118 y el gross 34 · 0.0118 ≈ 0.40. En la práctica el gross de Q01 ronda vol_target / vol_media y el gross_cap es un techo de seguridad para épocas de volatilidad muy baja. Los 2912 fills son el número de patas con delta distinto de cero a lo largo de 2690 barras.

Cómo lanzarla

1. Registrar la instancia con POST /v1/strategies en la API de backtesting (puerto 8000). Los parámetros omitidos toman los defaults del catálogo:

{
  "schema_version": "v1",
  "payload": {
    "template_id": "Q01",
    "params": { "lookback": 252, "vol_window": 63, "rebalance_bars": 21 },
    "name": "TSMOM vol-target base"
  }
}

2. Lanzar el backtest con POST /v1/backtest. Para las recetas de cartera el selector de datos es assets (lista de activos del universo), mutuamente excluyente con asset_id:

{
  "schema_version": "v1",
  "payload": {
    "strategy": { "strategy_id": "<id devuelto por /v1/strategies>" },
    "data": {
      "assets": ["AAPL", "AMZN", "MSFT", "NVDA", "XOM", "JPM"],
      "timeframe": "1d",
      "window": { "start": null, "end": null, "mode": "reset_flat", "checkpoint_id": null }
    },
    "execution": { "lane": "SIM-S", "contract_version": "1", "initial_cash": 100000,
                   "cost_scenario": "hypothetical_5bps", "costs": null },
    "engine": "both"
  }
}

Si el panel pedido no está descargado, la respuesta es 422 UNIVERSE_NOT_AVAILABLE: la API no inventa un panel parcial.

3. Reproducir la evidencia de paridad (así se generó summary_weights.json):

docker compose --profile dev run --rm -T dev python -m tools.parity_weights \
    --strategies Q01 --output runs/parity

Riesgos, límites y qué no hace

  • No controla la volatilidad de la cartera, solo la de cada posición. Con activos correlacionados la cartera puede superar el 10 %.
  • gross_cap puede no atar nunca. Con S = 6 y defaults, el gross máximo es 6 · 0,25 = 1,5 = gross_cap y el escalado no actúa (comparación estricta). Con 34 activos puede actuar, pero solo si las volatilidades son muy bajas.
  • El peso se trunca: con capital pequeño y precios altos, trunc puede dejar posiciones en cero aunque el peso no lo sea.
  • La última decisión puede quedar sin ejecutar y no se liquida la cartera al final (§3.3 de la especificación).
  • Sesgos de datos: supervivencia, adjclose recalculado hacia atrás (no point-in-time).
  • No es TSMOM de futuros: sin contratos, sin roll, sin margen. Eso sería Q09.

Resumen

  • Señal: signo del retorno logarítmico a lookback barras.
  • Peso: vol_target / (S · max(vol, floor)), topado por cap_individual y escalado por gross_cap.
  • Decisión a close[t], cantidades truncadas hacia cero con frozen_equity, fill en open[t+1].
  • Implementada en finazbench/policy/q01_tsmom.py; paridad 3/3 PASS con 2912 fills idénticos sobre 34 activos.

Para practicar

  1. Con los datos del fixture, calcula a mano el rebalanceo de t = 5 y comprueba que sale (+24, 0).
  2. ¿Cuántos activos hacen falta, con defaults, para que gross_cap = 1.5 empiece a actuar?
  3. Cambia vol_floor a 0,05 en t = 3. ¿Cambia algún peso? ¿Por qué?
  4. Usa finaz_risk.volatilidad_cartera con una covarianza de tu elección para estimar la volatilidad de la cartera de t = 3. ¿Está cerca del 10 %?