Multi-signal combination
One signal becomes one alpha. Real research has several — a trend read, a
volume read, a mean-reversion read — and they are correlated. src/alphas/combine.py
combines them into one alpha while avoiding the two classic mistakes:
- Naive weighting double-counts. Weighting by raw IC over-weights redundant signals (three flavors of trend look like three bets but are one) and under-weights a weak-but-independent signal that adds the most.
- Estimated ICs are uncertain, and that uncertainty should shrink a signal's contribution toward zero — more for short histories and low ICs.
Optimal combination via the signal correlation matrix
Given each signal's information coefficient IC and the signal correlation matrix
Ω, the weights that maximize the combined IC are the GLS solution:
w = Ω⁻¹ · IC combined IC = √(ICᵀ Ω⁻¹ IC)
Because Ω⁻¹ accounts for correlation, two redundant signals split a weight
rather than each getting full credit. For two signals this is exactly the closed
form:
IC₁' = (IC₁ − ρ·IC₂)/(1 − ρ²) IC_comb² = (IC₁² + IC₂² − 2ρ·IC₁·IC₂)/(1 − ρ²)
So adding a weak independent signal raises the combined IC; adding a strong
redundant one barely does. A small ridge regularizes Ω⁻¹ so a near-duplicate
pair (ρ → 1) stays finite.
Bayesian IC-uncertainty shrinkage
Each measured IC is shrunk toward zero by its estimation confidence before combining:
IC' = IC · g/(g+1) g = n · IC²
IC' → 0 when the history n is short or the IC is small; IC' → IC as n·IC² → ∞.
This is what stops a noisy, short-history IC from being trusted at face value — a
primary reason backtested alphas disappoint live.
Measured, not assumed
The ICs and Ω are measured over a trailing window, never assumed. At each of
several rebalance dates, every signal is scored on the cross-section (using only bars
≤ t) and correlated with the subsequent realized residual return (return minus
β·benchmark — so it rewards skill, not beta). The mean over rebalances is the IC;
the mean cross-sectional correlation between signals is Ω. Measuring on
out-of-sample data is what keeps the combination from over-fitting its own weights.
The combined score then flows through the same refine_alpha
pipeline, scaled by the combined IC. So the single-signal assumed IC scalar
is replaced by one measured, shrunk, redundancy-aware number — applied once, never
twice.
The per-signal Bayesian shrink here and the
IC-uncertainty level shrink are the same g/(g+1)
math, so applying both would double-shrink and undertrade forever. The rule: the
level shrink owns "is the IC real"; the combination owns "how correlated signals share
credit." On this combined path the combination discharges the level; the level shrink
is not re-applied. The result echoes a shrink_chain so the single application is
auditable.
Where it runs
services/analysis.py::compute_combined_alphas measures the signals, combines the
current cross-section, refines it, and returns the ranked alphas plus the measured
ICs, shrunk ICs, GLS weights, and correlation matrix. The CLI
(python main.py alphas --combine volume_spike,ma_crossover,mean_reversion) and the
read-only MCP tool combine_alphas route through it.
On the bundled synthetic data this is its own honesty check: the two trend
strategies measure as highly correlated (ρ ≈ 0.9) while mean-reversion is the
contrarian foil (ρ < 0), and the shrinkage drives the (genuinely skill-less)
random-walk ICs toward zero — so the combined alpha is near-flat, as it should be.