Saltar a contenido

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_id para 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:

lookback = 3   skip = 1   quantile = 0.2   rebalance_bars = 2
cash inicial = 100.000,00   sin costes

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 = 21 no 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 por asset_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

  1. Calcula el rebalanceo de t = 5 del fixture con los scores score[5] de la especificación. ¿Quién entra ahora en el top?
  2. ¿Qué pasaría con el empate de A02/A03/A05 si el desempate fuese por orden de llegada de las barras?
  3. Con quantile = 0.3 y 34 activos, ¿cuántos nombres hay por lado y cuánto pesa cada uno?
  4. Ejecuta 0.29 * 100 en Python y explica por qué exact_floor_n_side convierte quantile a fracción exacta antes de multiplicar.