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, aprobar → ApprovedTarget), 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
Spequeño ykcercano aS, 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:
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 / aprobar → ApprovedTarget |
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 pormax_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¶
- Comprueba a mano
z_7[0] = (0.00956945 − 0.00653679) / 0.01339692 = 0.22637. - Verifica que
eps_7es ortogonal aV_k(su producto escalar debe ser casi cero). - ¿Por qué la proyección usa
lstsq(A, u)y noinv(A'A) A' u? - Si
w = [0.10, −0.20, 0.50, −0.20]ymax_weight = 0.4, ¿cuál es la cartera tras el escalado global? ¿Y su gross?