Q02 · Momentum transversal 12−1, long/short¶
| Campo | Valor |
|---|---|
| Familia | Momentum de sección cruzada (cada activo contra los demás del universo en la misma fecha) |
| Universo / panel | Panel point-in-time alineado (align_panel); en la evidencia, 34 valores US diarios de us_equities_36 |
| Datos | Precios y corporate actions, máscara de universo point-in-time, disponibilidad de cortos |
| Features | momentum_panel (momentum{lookback, skip}), universe_mask, cross_section_rank (desempate por asset_id) |
| Parámetros y defaults | lookback = 252, skip = 21, quantile = 0.2; rebalance_bars = 21 fijo (D-20) |
| Rejilla | lookback ∈ {126, 252}, skip ∈ {5, 21}, quantile ∈ {0.1, 0.2, 0.3} → 12 candidatos |
| Regla fija | Mínimo 10 elegibles; si no, cartera plana |
| Estado | Implementada (hito H3) · paridad 3/3 PASS · carril nativo UNSUPPORTED (D-14) |
| Motores implicados | finazbench/policy/q02_cross_sectional.py (Q02CrossSectional, Q02CrossSectionalState, exact_floor_n_side), ledger de cartera, carriles nt_weight_* |
Fuentes: catalog/strategy_catalog.json, docs/estrategias/Q02.md (especificación con fixture), finazbench/policy/q02_cross_sectional.py, runs/parity/summary_weights.json, docs/DECISIONES.md (D-20, D-21, D-22, D-23).
Qué vas a aprender¶
- La diferencia entre momentum temporal (Q01) y momentum transversal: aquí no importa si un activo sube, sino si sube más que los otros.
- Por qué se salta el último mes (
skip = 21) al medir el momentum. - Cómo un empate exacto de tres activos obliga a desempatar por
asset_idpara que el resultado sea reproducible. - Por qué con 6 activos la estrategia es
software_only: la regla de mínimo 10 elegibles deja la cartera siempre plana. - Qué significa que la cartera sea neutral en dólares (
net = 0,gross = 1).
La idea en una frase¶
Cada mes, ordenar los activos por su retorno de los últimos doce meses sin contar el último, comprar el quintil ganador y vender el quintil perdedor, con el mismo capital en cada lado.
Intuición y evidencia académica¶
Intuición. La literatura de momentum transversal en acciones documenta, en muchos mercados y periodos, que los valores con mejor retorno a 3–12 meses tienden a seguir batiendo a los peores durante los meses siguientes. La cartera larga-corta aísla ese efecto relativo y neutraliza, en primera aproximación, la dirección del mercado.
Por qué el skip. El último mes suele mostrar reversión de corto plazo (efectos de liquidez y microestructura). Excluirlo evita mezclar dos efectos opuestos. De ahí el nombre "12−1": doce meses de ventana, uno excluido.
Cuándo falla.
- Rebotes tras crisis: los perdedores (muy castigados, a menudo de beta alta) rebotan con fuerza y la pata corta sufre.
- Universos pequeños: con pocos activos los quintiles tienen uno o dos nombres y el ruido domina.
- Coste de los cortos: el préstamo de títulos no está modelado (el catálogo pide declararlo como "no modelado").
Variante propia
No es el factor publicado ponderado por capitalización: aquí cada lado reparte 0,5 de exposición a partes iguales y el ranking es entre los activos del universo en cada fecha.
El modelo matemático¶
En cada barra de rebalanceo t:
score_i[t] = ln(P_i[t − skip]) − ln(P_i[t − lookback]) para i elegible
elegible = pit_mask_i[t] y score finito
n_eligible = número de elegibles
si n_eligible < 10: todos los pesos = 0 (cartera plana)
orden = ordenar por (−score, asset_id) ascendente desempate determinista
n_side = floor(quantile · n_eligible) exacto, sin error de float
top = rangos [0, n_side) w = +0.5 / n_side
bottom = rangos [n_eligible − n_side, n_eligible) w = −0.5 / n_side
resto = 0
Propiedades: gross = 1, net = 0, y 2 · n_side <= n_eligible garantiza que top y bottom no se solapan. exact_floor_n_side calcula el floor en aritmética exacta (fracción desde el decimal): en float64 0.29 · 100 = 28.999999999999996 y el floor daría 28 en lugar de 29. La rejilla publicada no toca ese caso, pero la especificación no depende de ello.
Cuántos nombres por lado según el tamaño del universo (n_side = floor(quantile · n_eligible)):
n_eligible |
quantile = 0.1 |
quantile = 0.2 |
quantile = 0.3 |
|---|---|---|---|
| 6 | cartera plana (< 10) | cartera plana (< 10) | cartera plana (< 10) |
| 10 | 1 por lado (±0,50) | 2 por lado (±0,25) | 3 por lado (±0,1667) |
| 12 | 1 por lado (±0,50) | 2 por lado (±0,25) | 3 por lado (±0,1667) |
| 34 | 3 por lado (±0,1667) | 6 por lado (±0,0833) | 10 por lado (±0,05) |
Con quantile = 0.1 y 10–19 activos la cartera concentra la mitad del capital en un solo nombre por lado: técnicamente válido, económicamente muy ruidoso.
Pseudocódigo causal¶
para cada barra t (tras el watermark del panel: las S barras de t cerradas):
si t no es rebalanceo (cada 21 barras desde origin): mantener q; continuar
scores = momentum{lookback, skip} de cada activo con known_at <= t
elegibles = pit_mask[t] y score finito # la elegibilidad solo usa el pasado
si len(elegibles) < 10: target_qty = 0; continuar
ranking con desempate por asset_id; n_side; pesos ±0.5/n_side
frozen_equity = equity al cierre de t; q_i = trunc(w_i · frozen_equity / close_i[t])
emitir target_weight, target_qty, rank, n_eligible (extensión transversal de WeightIntentArrays)
Cómo lo hace la plataforma¶
| Pieza | Quién la calcula |
|---|---|
momentum{lookback, skip} |
Primitiva unificada (D-22) sobre el panel alineado |
cross_sectional_rank |
Primitiva unificada con desempate por asset_id (D-22) |
n_side exacto |
exact_floor_n_side(quantile, n_eligible) |
| Pesos y rango | _pesos_y_rank en q02_cross_sectional.py |
| Estado online | Q02CrossSectionalState (un estado por cartera, reproduce el batch) |
| Métricas propias | q02_run_metrics (n elegibles, rebalanceos planos) |
| Contrato | WeightIntentArrays con extensión transversal rank, n_eligible (D-21) |
| Ejecución | Ledger de cartera y carriles nt_weight_* |
flowchart LR
D["Panel point-in-time<br/>34 activos · pit_mask"] --> F["momentum 252 · skip 21"]
F --> R["Ranking transversal<br/>(−score, asset_id)"]
R --> G{"n_eligible >= 10?"}
G -- no --> P["Cartera plana"]
G -- sí --> W["Top q: +0.5/n_side<br/>Bottom q: −0.5/n_side"]
W --> V["WeightIntentArrays<br/>+ rank, n_eligible"]
V --> L["trunc hacia cero<br/>frozen_equity · close[t]"]
L --> E["Ledger de cartera<br/>fill open[t+1]"]
Ejemplo numérico¶
Del fixture de la especificación (docs/estrategias/Q02.md §6). Doce activos sintéticos A01…A12, seis sesiones, parámetros fuera de la rejilla para poder calcular a mano:
score_i[3] = ln(close_i[2]) − ln(close_i[0]) y todos parten de 100,00:
| Activo | close[2] | score[3] | Rango |
|---|---|---|---|
| A01 | 112,00 | +0,113329 | 0 |
| A02 | 108,00 | +0,076961 | 1 |
| A03 | 108,00 | +0,076961 | 2 |
| A05 | 108,00 | +0,076961 | 3 |
| A04 | 106,00 | +0,058269 | 4 |
| A06 | 102,00 | +0,019803 | 5 |
| A07 | 100,00 | 0,000000 | 6 |
| A08 | 98,00 | −0,020203 | 7 |
| A09 | 96,00 | −0,040822 | 8 |
| A10 | 94,00 | −0,061875 | 9 |
| A11 | 92,00 | −0,083382 | 10 |
| A12 | 90,00 | −0,105361 | 11 |
Paso a paso del rebalanceo t = 3.
n_eligible = 12 (>= 10: la cartera no queda plana)
n_side = floor(0.2 · 12) = floor(2.4) = 2
top = {A01, A02} w = +0.5/2 = +0.25 cada uno
bottom = {A11, A12} w = −0.5/2 = −0.25 cada uno
gross = 1.00 net = 0.00
A02, A03 y A05 empatan EXACTAMENTE en +0.076961041.
Solo A02 entra en el top, por asset_id ascendente.
Cantidades (frozen_equity = 100.000, decision_price = close[3]):
A01: trunc(+0.25 · 100000 / 112.00) = trunc(+223.21) = +223
A02: trunc(+0.25 · 100000 / 108.00) = trunc(+231.48) = +231
A11: trunc(−0.25 · 100000 / 92.00) = trunc(−271.74) = −271
A12: trunc(−0.25 · 100000 / 90.00) = trunc(−277.78) = −277
Fíjate en el truncado hacia cero: floor(−271,74) sería −272, un título más corto. La especificación exige trunc para que largos y cortos se traten simétricamente.
Evidencia de paridad real (runs/parity/summary_weights.json):
| Caso | Activos | Barras | Fills ledger / Nautilus | Estado |
|---|---|---|---|---|
US34_1d_Q02_5bps_NT_WEIGHT_REPLAY |
34 | 2690 | 1690 / 1690 | PASS |
US34_1d_Q02_5bps_NT_WEIGHT_FEATURES |
34 | 2690 | 1690 / 1690 | PASS |
US34_1d_Q02_5bps_NT_WEIGHT_ONLINE |
34 | 2690 | 1690 / 1690 | PASS |
US34_1d_Q02_5bps_NT_ONLINE_native |
— | — | — | UNSUPPORTED (D-14) |
Con 34 activos y defaults: n_side = floor(0.2 · 34) = 6, cada nombre del top pesa +0.5/6 = +0,0833 y cada uno del bottom −0,0833. Q02 genera menos fills que Q01 (1690 frente a 2912) porque solo 12 de los 34 activos tienen peso en cada rebalanceo y muchos repiten de un mes al siguiente.
Q02 frente a Q01, lado a lado¶
| Aspecto | Q01 (temporal) | Q02 (transversal) |
|---|---|---|
| Pregunta | ¿Ha subido este activo? | ¿Ha subido más que los otros? |
| Señal | sign(momentum) por activo |
Rango dentro del universo |
| Exposición neta | Libre (puede estar toda larga) | Siempre 0 |
| Sizing | Inverso a la volatilidad | Igual peso por lado |
| Mínimo de activos | Ninguno | 10 elegibles |
| Estado entre rebalanceos | Ninguno (fórmula de cero) | Ninguno (ranking de cero) |
| Extensión del contrato | Núcleo WeightIntentArrays |
Núcleo + rank, n_eligible |
| Fills en la paridad (34 activos) | 2912 | 1690 |
Las dos recalculan los pesos desde cero en cada rebalanceo: no hay máquina de estados. Eso las hace fáciles de verificar con un fixture y explica que online_equals_batch salga true sin esfuerzo especial. Q03 es distinta: tiene posición, tiempo de permanencia y modelo congelado por bloque.
Por qué el pit_mask importa aunque hoy valga siempre 1¶
En el universo actual los 34 activos existen durante todo el periodo, así que la máscara point-in-time es trivial. El diseño la mantiene porque, con un universo histórico real (altas, bajas, fusiones), un activo que todavía no cotizaba o que ya había sido excluido no puede entrar en el ranking de esa fecha. Si la elegibilidad usase información futura (por ejemplo, "activos que existen hoy"), el ranking tendría sesgo de supervivencia por construcción.
Cómo lanzarla¶
Registrar la instancia y lanzar el backtest con el selector assets, igual que Q01:
{
"schema_version": "v1",
"payload": {
"template_id": "Q02",
"params": { "lookback": 252, "skip": 21, "quantile": 0.2 },
"name": "Momentum 12-1 quintiles"
}
}
{
"schema_version": "v1",
"payload": {
"strategy": { "strategy_id": "<id de Q02>" },
"data": { "assets": ["AAPL", "ADBE", "AMD", "AMZN", "BAC", "CAT", "COST", "CSCO",
"CVX", "DIS", "GOOGL", "GS"],
"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"
}
}
Con menos de 10 activos la petición es válida, pero la cartera será siempre plana (D-23). Evidencia de paridad:
docker compose --profile dev run --rm -T dev python -m tools.parity_weights \
--strategies Q02 --output runs/parity
Riesgos, límites y qué no hace¶
- Mínimo 10 elegibles. Con el universo inicial de 6 activos Q02 era
software_only(D-23): el código funcionaba pero la cartera nunca abría. Por eso WP-10 descargó ~30 valores US antes de sacar conclusiones. rebalance_bars = 21no es un mes de calendario: es un parámetro fijo contado en barras (is_calendar_month = false, D-20).- Sin coste de préstamo para los cortos: se declara como no modelado.
- Supervivencia: el universo contiene solo valores que existen hoy; la máscara point-in-time no puede corregir ese sesgo.
- No pondera por capitalización ni neutraliza sectores o beta: la neutralidad es solo en dólares.
Resumen¶
- Score:
ln(P[t−skip]) − ln(P[t−lookback]), ranking transversal con desempate porasset_id. n_side = floor(quantile · n_eligible); top+0.5/n_side, bottom−0.5/n_side; gross 1, net 0.- Mínimo 10 elegibles o cartera plana.
- Implementada en
finazbench/policy/q02_cross_sectional.py; paridad 3/3 PASS con 1690 fills idénticos.
Para practicar¶
- Calcula el rebalanceo de
t = 5del fixture con los scoresscore[5]de la especificación. ¿Quién entra ahora en el top? - ¿Qué pasaría con el empate de A02/A03/A05 si el desempate fuese por orden de llegada de las barras?
- Con
quantile = 0.3y 34 activos, ¿cuántos nombres hay por lado y cuánto pesa cada uno? - Ejecuta
0.29 * 100en Python y explica por quéexact_floor_n_sideconviertequantilea fracción exacta antes de multiplicar.