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_individualygross_cap. - Por qué la decisión se toma con
close[t]y se ejecuta enopen[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¶
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_cappuede no atar nunca. ConS = 6y defaults, el gross máximo es6 · 0,25 = 1,5 = gross_capy 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,
truncpuede 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,
adjcloserecalculado 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
lookbackbarras. - Peso:
vol_target / (S · max(vol, floor)), topado porcap_individualy escalado porgross_cap. - Decisión a
close[t], cantidades truncadas hacia cero confrozen_equity, fill enopen[t+1]. - Implementada en
finazbench/policy/q01_tsmom.py; paridad 3/3 PASS con 2912 fills idénticos sobre 34 activos.
Para practicar¶
- Con los datos del fixture, calcula a mano el rebalanceo de
t = 5y comprueba que sale(+24, 0). - ¿Cuántos activos hacen falta, con defaults, para que
gross_cap = 1.5empiece a actuar? - Cambia
vol_floora 0,05 ent = 3. ¿Cambia algún peso? ¿Por qué? - Usa
finaz_risk.volatilidad_carteracon una covarianza de tu elección para estimar la volatilidad de la cartera det = 3. ¿Está cerca del 10 %?