Saltar a contenido

Q05 · Arbitraje estadístico de residuos PCA

Campo Valor
Familia Stat-arb factorial: cartera neutral a factores construida sobre los residuos de un modelo PCA por bloques
Universo / panel Panel de 20–500 activos con universo histórico y precios sincronizados
Datos Matriz de retornos del panel, universo point-in-time
Features return_matrix, training_scaler, pca_loadings, residual_scores
Parámetros y defaults formation = 252, components = 5, residual_horizon = 20, refit_bars = 21, max_weight = 0.1
Rejilla formation ∈ {126, 252, 504}, components ∈ {3, 5, 10}, residual_horizon ∈ {5, 10, 20} → 27 candidatos
Coste esperado Una SVD por refit + O(N·S·k); nunca almacenar N×S×S
Estado Especificada, no implementada (specified_not_implemented, hito H3)
Motores implicados finaz_risk (covarianza_ledoit_wolf, exposiciones_factoriales, contribuciones_riesgo, var_historico), finaz_constraints (validar, aprobarApprovedTarget), finaz_discrete (lotes). La PCA y la proyección neutral no existen como motor

Estado

Q05 no está implementada. El catálogo la marca specified_not_implemented; la API de backtesting la declara UNSUPPORTED con NOT_IMPLEMENTED_YET: H5 (Q04-Q10) is outside the current scope. Además, el universo disponible (us_equities_36, 34 columnas) está en el límite inferior del panel que pide la receta (20–500). Los números de esta ficha vienen del fixture a mano de docs/estrategias/Q05.md, no de una ejecución.

Fuentes: catalog/strategy_catalog.json, docs/estrategias/Q05.md (especificación y fixture), packages/finaz_risk, packages/finaz_constraints, docs/v2/MOTORES.md (AG-022, AG-023).

Qué vas a aprender

  • Cómo la PCA extrae de un panel de retornos los "factores comunes" (mercado, sectores, estilos).
  • Qué es un residuo idiosincrático y por qué su acumulado puede revertir.
  • Cómo se construye una cartera neutral a factores proyectando la señal fuera del espacio de los factores.
  • Por qué, si un peso supera max_weight, se escala toda la cartera y no solo ese peso.
  • Qué motores de riesgo de la plataforma cubrirían la parte de medición y control.

La idea en una frase

Quitar a cada activo la parte de su retorno que explican los factores comunes del mercado, y apostar a que lo que queda (el residuo) vuelve a su nivel normal, con una cartera que no tenga exposición a esos factores.

Intuición y evidencia académica

Intuición. El retorno de una acción se descompone en una parte común (el mercado sube, el sector tecnológico baja) y una parte propia. La parte común es difícil de predecir; la propia, en horizontes cortos, tiende a revertir cuando se desvía mucho (sobrerreacciones, presión de flujos). La literatura de arbitraje estadístico con factores estadísticos (PCA sobre la matriz de correlaciones) formaliza esta idea. El catálogo es explícito: es una variante propia, no la reproducción exacta de ningún artículo.

Por qué neutral a factores. Si la cartera tuviese exposición al mercado, su resultado dependería del mercado y no de la reversión de los residuos. La proyección elimina esa exposición en el modelo de formación.

Cuándo falla.

  • Cambios de régimen: las cargas congeladas del bloque dejan de describir el mercado (una crisis dispara la correlación).
  • Residuos que no revierten: noticias idiosincráticas permanentes (resultados, fusiones).
  • Pocos activos: con S pequeño y k cercano a S, casi todo es "factor" y el residuo es ruido.

El modelo matemático

En cada refit (formación de T_f retornos anteriores al bloque):

mu_f, sigma_f   = media y desviación (ddof = 1) de cada columna en formación
Z               = (R_formación − mu_f) / sigma_f          estandarizar
V_k             = primeros k vectores singulares derechos de Z (SVD)
cargas congeladas durante todo el bloque

En cada barra t del bloque:

z_t     = (r_t − mu_f) / sigma_f
f_t     = z_t · V_k                    factores (k escalares)
r_hat_t = f_t · V_k'                   reconstrucción común
eps_t   = z_t − r_hat_t                residuo idiosincrático
sum_t   = eps_{t−h+1} + … + eps_t      acumulado de h barras (con cola de formación)
score_t = (sum_t − mu_S) / sigma_S     normalizado con la distribución de acumulados de formación
u       = −clip(score_t, −3, 3)        señal bruta: comprar lo que cayó, vender lo que subió

Proyección neutral (sin invertir A'A):

A   = [unos, D · V_k]              D = diag(sigma_f)
w   = u − A · lstsq(A, u)          w es ortogonal a las columnas de A
w   = w / sum|w|                   gross = 1
si max|w_i| > max_weight:  w ← w · max_weight / max|w_i|      escala TODA la cartera

Recortar solo el peso grande destruiría la neutralidad; escalar todo la conserva (a costa de un gross menor que 1).

Pseudocódigo causal

para cada bloque (cada refit_bars barras):
    formación = retornos [b − formation, b)   (nunca el retorno de la barra b)
    ajustar escalador y SVD; congelar mu_f, sigma_f, V_k
    calcular residuos de formación para inicializar la ventana de h barras
    para cada t del bloque (tras el watermark del panel):
        eps_t, score_t, u, proyección, escalado
        q_i = trunc(w_i · frozen_equity / close_i[t]); fill en open[t+1]

Condición de validez: k < S − 2.

PCA frente a factores con nombre

Aspecto Factores estadísticos (PCA, Q05) Factores con nombre (mercado, tamaño, valor…)
De dónde salen De la propia matriz de retornos de formación De datos externos (capitalización, balances)
Interpretación El primer componente suele parecerse al mercado; los demás, no siempre tienen nombre Directa
Datos necesarios Solo precios Fundamentales point-in-time
Estabilidad Cambian con cada refit (signo y orden de los componentes pueden rotar) Definición estable
Encaje en la plataforma Solo necesita el panel de precios que ya existe Requeriría datos que la plataforma no tiene

Por eso la receta usa PCA: es la única variante que se puede construir con los datos disponibles. Un detalle práctico: el signo de un vector singular es arbitrario. El residuo eps = z − (z·V)V' no depende del signo de V, así que la cartera tampoco; pero cualquier informe que muestre "el factor 1" debe fijar una convención de signo para ser reproducible.

Memoria y coste

Con S = 500, N = 2520 barras (diez años) y k = 10:

panel de retornos      N·S        = 1.26 millones de float64 ≈ 10 MB
cargas por bloque      S·k        = 5.000 valores; 120 bloques ≈ 4,8 MB
residuos               N·S        ≈ 10 MB
covarianzas por barra  N·S·S      = 630 millones ≈ 5 GB   ← lo que la receta prohíbe almacenar

La SVD por refit es O(T_f · S · min(T_f, S)); con 120 refits de una matriz 252×500 es trivial en CPU. El cuello de botella no es el álgebra sino la disciplina: no guardar nada de tamaño N×S×S.

El escalado por max_weight, con números

Ejemplo didáctico (no del fixture): tras la proyección y la normalización a gross 1, w = [+0.05, −0.45, +0.30, +0.10] (suma de valores absolutos 0,90 aquí solo para simplificar; en la receta sería 1). Con max_weight = 0.4, el mayor valor absoluto es 0,45:

factor = 0.40 / 0.45 = 0.8889
w'     = [+0.0444, −0.4000, +0.2667, +0.0889]

Todos los pesos se reducen en la misma proporción: la dirección de la cartera, y por tanto su ortogonalidad a los factores, no cambia. Si se recortase solo el −0,45 a −0,40, la cartera dejaría de ser neutral.

Cómo lo haría la plataforma

Pieza Quién la cubriría Estado
Matriz de retornos y universo Panel alineado del banco (us_equities_36) Existe (34 columnas)
Escalador, SVD, residuos Código propio previsto (NumPy); no es un motor de la plataforma No existe
Covarianza regularizada para medir el riesgo del resultado finaz_risk.covarianza_ledoit_wolf(retornos)EstimacionCovarianza Existe (G5, paridad con sklearn)
Exposición a factores de la cartera finaz_risk.exposiciones_factoriales(pesos, cargas, nombres_factores) Existe
Contribuciones al riesgo y VaR/ES finaz_risk.contribuciones_riesgo, var_historico, es_historico Existe
Veto de límites (peso máximo, neutralidad) finaz_constraints.validar / aprobarApprovedTarget Existe (G5/G6, anti-bypass probado)
Pesos a lotes finaz_discrete.proyectar_largest_remainder o proyectar_cpsat Existe (G6)
Ejecución Ledger de cartera Existe
flowchart LR
    D["Panel 20–500 activos<br/>watermark"] --> F["return_matrix<br/>training_scaler"]
    F --> P["PCA por bloque<br/>V_k congeladas<br/>(no implementado)"]
    P --> E["Residuos eps<br/>score h barras"]
    E --> N["Proyección neutral<br/>w = u − A·lstsq(A,u)"]
    N --> R["finaz_risk<br/>exposiciones_factoriales<br/>Ledoit-Wolf · VaR/ES"]
    R --> V["finaz_constraints<br/>max_weight · ApprovedTarget"]
    V --> X["finaz_discrete<br/>lotes enteros"]
    X --> L["Ledger de cartera"]

Ejemplo numérico

Del fixture de la especificación (docs/estrategias/Q05.md §6), calculado a mano: S = 4 activos, k = 1, formation = 6, h = 2, refit_bars = 2, max_weight = 0.4 (todos fuera de la rejilla para que la SVD de un vector se pueda exhibir).

Retornos de la barra 7 (primera del bloque; la formación son t = 1..6):

r_7     = [+0.00956945, +0.00575265, −0.00985230, +0.01851905]
mu_f    = [+0.0065367855, +0.0065367855, +0.0033004379, +0.0112764414]
sigma_f = [ 0.0133969177,  0.0117943747,  0.0165629835,  0.0176425806]

z_7     = (r_7 − mu_f) / sigma_f
        = [+0.2263703910, −0.0664836545, −0.7941041809, +0.4105185420]

Factor y residuo (con V_k = [0.5619922, 0.2596536, 0.5396546, 0.5705416]):

f_7     = z_7 · V_k = −0.0843683915
r_hat_7 = f_7 · V_k' = [−0.0474143794, −0.0219065489, −0.0455297896, −0.0481356830]
eps_7   = z_7 − r_hat_7
        = [+0.2737847704, −0.0445770956, −0.7485743913, +0.4586542250]

Acumulado de h = 2 con la cola de formación (el residuo de la barra 6):

sum_7   = eps_f[6] + eps_7 = [+0.1581815523, +0.5499265597, −0.7865480830, +0.3378843874]
score_7 = [+0.7097101692, +0.9314663766, −3.5606450914, +1.6194978426]
u       = −clip(score_7, −3, 3) = [−0.7097, −0.9315, +3.0000, −1.6195]

El activo A3 tiene un residuo acumulado muy negativo (−3,56 desviaciones): la señal quiere comprarlo y vender el resto, y la proyección posterior elimina la parte de esa cartera que se parece al factor. La primera barra del bloque ya tiene un acumulado completo gracias a la cola de formación: un implementador que esperase h barras dentro del bloque no produciría cartera en t = 7.

Cómo lanzarla

Hoy no se puede lanzar. GET /v1/capabilities devuelve Q05 UNSUPPORTED en todos los carriles con NOT_IMPLEMENTED_YET, y POST /v1/backtest con una instancia de Q05 responde 501 STRATEGY_UNSUPPORTED_BY_ADAPTER.

Qué se puede ejecutar hoy. Las piezas de riesgo y validación están en la imagen opt. Su job S7 corre la operación real de la familia (ERC, mínima varianza y CP-SAT sobre casos fijos):

echo '{"job_id": "demo-opt-001", "rol": "opt", "operacion": "erc_cpsat"}' \
    > /spool/jobs/demo-opt-001.json
python -m finaz_runtime.jobs.runner --rol opt --once

Para medir el riesgo de una cartera de Q05 diseñada a mano, las funciones de finaz_risk se usan como biblioteca dentro de esa imagen (ejemplo ilustrativo, no un flujo publicado):

import numpy as np
from finaz_risk import covarianza_ledoit_wolf, contribuciones_riesgo

retornos = np.loadtxt("retornos_formacion.csv", delimiter=",")   # T x S
est = covarianza_ledoit_wolf(retornos)
print(est.estimador, est.intensidad_shrinkage)
w = np.array([-0.10, -0.12, 0.40, -0.18])                        # pesos de ejemplo
print(contribuciones_riesgo(w, est.matriz))

Riesgos, límites y qué no hace

  • No implementada; y el universo de 34 activos apenas cumple el mínimo de la receta.
  • Neutralidad en el modelo, no en el mercado: la cartera es ortogonal a los factores de formación; si los factores cambian, la exposición real no es cero.
  • Escalado global por max_weight: protege la neutralidad pero puede dejar la cartera con muy poco gross.
  • Supervivencia: el universo histórico real (altas y bajas) es imprescindible; con un universo de supervivientes el stat-arb está sesgado.
  • Sin costes de préstamo ni de impacto; con 20–500 activos y rebalanceo mensual los costes son determinantes.
  • No es Avellaneda–Lee ni ningún modelo publicado exacto.

Resumen

  • PCA por bloques sobre retornos estandarizados, residuo = retorno − reconstrucción factorial.
  • Señal = −(residuo acumulado normalizado), recortado a ±3; proyección neutral con lstsq, gross 1, escalado por max_weight.
  • Especificada con fixture; no implementada.
  • La plataforma ya tiene la medición de riesgo (finaz_risk), el veto (finaz_constraints) y los lotes (finaz_discrete); faltan la PCA por bloques y la política.

Para practicar

  1. Comprueba a mano z_7[0] = (0.00956945 − 0.00653679) / 0.01339692 = 0.22637.
  2. Verifica que eps_7 es ortogonal a V_k (su producto escalar debe ser casi cero).
  3. ¿Por qué la proyección usa lstsq(A, u) y no inv(A'A) A' u?
  4. Si w = [0.10, −0.20, 0.50, −0.20] y max_weight = 0.4, ¿cuál es la cartera tras el escalado global? ¿Y su gross?