Continuous alphas
src/alphas/ turns a per-name signal into an alpha: a forecast of residual
return (return in excess of what beta to the benchmark explains), expressed in
the same units — annualized return — for every name. That makes views directly
comparable across symbols and directly consumable by a mean-variance
portfolio optimizer.
A strategy's generate_signals returns a string (BUY/SELL/HOLD). That is the
right interface for the single-symbol trade clock, but it throws
away magnitude ("I like NVDA twice as much as AAPL") and comparability
across names. Alphas recover both.
Alphas are forecasts. Computing them never reads a realized forward return and
never places an order. src/engine/live.py is untouched — live trading still
consumes discrete signals via the existing path. See
separation of concerns.
The refinement identity
The standard rule for turning a standardized score into a residual-return forecast:
α_i = σ_i · IC · z_i
cross-sectionally, at each rebalance:
z_i— the standardized raw score of name i:z_i = (s_i − mean(s)) / std(s), so scores have mean 0 and unit dispersion across the universe.IC— the information coefficient, the correlation skill of the signal (realistically 0.02–0.10). Supplied as a prior today; a future information-analysis step will measure it from realized outcomes and feed it back.σ_i— the annualized residual volatility of name i (return minusβ_i · benchmark_return). The risk of the bet that isn't just market exposure.
The cross-sectional std of the resulting alphas is ≈ IC · σ, so they carry the
right information ratio: an optimizer sizes positions by genuine conviction, not by
an arbitrary signal scale. Feeding un-scaled scores into a mean-variance optimizer
is the classic way to get nonsense leverage on the noisiest names.
The pipeline (refine.py)
Each step is a pure cross-sectional function on a symbol-indexed Series, so it is
unit-testable in isolation and composable. Order matters:
raw scores s_i
1. winsorize: clip s_i to [Q(0.025), Q(0.975)] # kill single-name outliers
2. z-score: z_i = (s_i − mean) / std # cross-sectional, this rebalance
3. neutralize: residual of z_i regressed on exposures [optional]
4. scale: α_i = σ_i · IC · z_i # the identity above
5. cap: |α_i| ≤ 3 · std(α) # final sanity bound
AlphaModel.alphas(scores, context) is the one method that runs this for every
model, so the scaling identity and the as-of discipline live in exactly one place.
Neutralization
Alphas should be benchmark-neutral — the equal-weighted average alpha ≈ 0 — so
they express only relative views and don't smuggle in a directional market bet.
Two levers, both ending in refine.neutralize (an OLS residual, orthogonal to the
supplied exposures by construction, with an intercept so the residual is mean-zero):
- Benchmark-beta neutral: regress
zon each name's beta and keep the residual. Enabled by--neutralize. - Factor neutral: regress on the risk model's standardized
factor exposures (
exp_<factor>panel columns, written byadd_factor_exposure_featuresfrom the same builder the factor risk model uses — one definition of "factor", both places). Enabled by--neutralize-factors; the bare flag neutralizes market, volatility, size. Momentum is deliberately not in the default set — a momentum tilt is a return bet the alpha may intend; regress it out explicitly (--neutralize-factors market,volatility,size,momentum) if that's the goal.
The z-score already centers the cross-section at 0, so equal-weight benchmark neutrality holds even before the explicit step.
Both levers compose into one regression on the union of exposures, with three rules that keep degraded inputs honest rather than silently wrong:
- Usability gating. A factor column is used only if it exists and actually varies across covered names. An absent, all-NaN, or constant column (e.g. the exposure build qualified fewer than two names on a short-history universe) degrades to plain-beta neutralization — never to no neutralization.
- Mean-imputation for partial coverage. A name missing one factor value gets the cross-sectional mean (0 — exposures are standardized), keeping it in the regression. Without this, the union's row-wise NaN-drop would strip that name's beta neutralization too, and the cross-section would silently mix neutralized and raw scores.
- Report what was applied, not what was asked. The refinement records
panel.meta["neutralized_against"](and an imputation count); the services echo it asneutralized_against, and the CLI prints it — with an explicit warning when a requested factor's exposures were unavailable. "Factor-neutral" is never claimed for un-neutralized output.
- The MCP tools don't expose
neutralize_factorsyet — results echo the field (always[]via that surface); wiring the parameter throughsrc/mcp/server.pyis a small follow-up for when the agent surface needs it. - The exposure builder's history gate is two-way, not per-factor: a subset with
momentum requires the full 12-1 window (~148 bars), any other subset requires
vol_window + 1(61) bars — even for factors (like market) that could tolerate less. Names between those bounds are dropped from the exposure frame (then mean-imputed per rule 2).
Forecast refinement v2
The identity α = σ·IC·z is exactly right — when the cross-sectional z we compute
equals the time-series z the standard rule is stated in. Whether it does is an
empirical property of each signal, and two refinements the base pipeline skipped close
the gap.
The Case test — which scaling is right
-
Case 1 — a signal's per-name time-series vol
Std_TS{g_n}is ~constant across names ⇒z_TS ≈ z_CSand theσ_nmultiply is correct (the classic sector-momentum example). This isα = σ·IC·z, unchanged. -
Case 2 —
Std_TS{g_n}is ~proportional to the name's residual volσ_n(empirically, most price/estimate signals — momentum, reversal, revision) ⇒ the vol is already inside the raw signal, and multiplying byσ_ndouble-counts it, systematically overweighting high-vol names. The fix replaces the per-nameσ_nwith one cross-sectional constantc_g = Std_CS{g} / Std_CS{g/σ}:α_n = IC · c_g · z_n # Case 2 — one constant scale, no per-name vol tilt
refine.case_test(signal_history, σ) decides by regressing Std_TS{g_n} = a + b·σ_n
across names: R² ≥ 0.25 and a significant slope ⇒ Case 2; R² ≤ 0.05 ⇒ Case 1; the
ambiguous band defaults to the base rate (Case 2 for price-derived signals, Case 1
otherwise), flagged ambiguous so a wrong call is visible. c_g is computed on the
raw signal, winsorized exactly as the pipeline winsorizes — after standardization
the raw dispersion that defines it is gone. --scaling case1|case2|auto selects it;
--scaling-ab (on the info command) is the ground-truth tiebreak: it walk-forwards
the realized IR under both scalings.
The IC-uncertainty level shrink
The IC that scales alphas is itself estimated, and its sampling error dominates
the mapping (Var{Δβ/β} ≈ 1/(IC²·T)). The Bayes-with-zero-prior fix is one factor on
the whole level:
α ← α · 1/(1 + 1/(T_eff·IC²)) # = g/(g+1), g = T_eff·IC²
The honest magnitudes are startling: a good signal (IC 0.05) with 5 years of monthly data keeps 13% of its naive alpha; a great one (IC 0.10, 10 years) keeps ~55%. Two subtleties the implementation gets right:
T_eff, not raw T. Daily rows with a 21-day horizon are ~21× overlapped; using the raw count under-shrinks by that factor.horizon.effective_sample_sizedeflates by the horizon/spacing overlap, so the same panel sampled daily-with-a-monthly-horizon and monthly gives the same shrink.- No double-shrink. The combination's per-signal Bayesian shrink and this level
shrink are the same
g/(g+1)math. The rule: the level shrink owns "is the IC real"; the combination owns "how correlated signals share credit." So it is applied exactly once — by the level shrink on the single-signal measured path, and by the combination's per-signal shrink on the combined path (never both). Every result echoes ashrink_chainwith one multiplier per step so the total haircut is auditable.
The equal-risk-contribution diagnostic
A production monitor that catches a mis-scaled alpha after the fact: under correct
scaling every residual-vol bucket contributes ~equally to active variance (E{z²}=1),
so a bucket's share of w_aᵀΣw_a should track its share of names, not its vol. The
info report buckets the paper active book by residual-vol quantile and flags a
monotone gradient as the fingerprint of mis-scaling (usually a Case mis-choice). It
degrades quintiles → terciles → suppressed as the universe thins, because a bucket mean
of z² has sampling error √(2/n) — quintiles on 30 names are noise, not signal.
The feature panel and refinement
The refinement runs over a FeaturePanel — the cross-sectional,
point-in-time table that holds every name's features in one place. The flow is
scan → panel → refine:
-
A scorer fills the panel's
scorecolumn (one per name). A scorer is justCallable[[DataFrame], float]:Scorer Score strategy_scorerthe strategy's own continuous conviction ( calculate_scores) — the natural, richest sourcesignal_scorerthe strategy's discrete direction as +1 / −1 / 0(a lossy bucketing of the score)scanner_scorera scanner's signal_strength, signed by direction -
The risk producer fills
betaandresidual_vol. -
refine_alpha(panel, context)readsscore+residual_vol(+betawhen neutralizing) and writes thezandalphacolumns. One implementation, one place — whatever produced the score flows through the same pipeline.
This is why each strategy is now score-first (calculate_scores is its one
decision function): the same number the trade clock derives its signal from is the
conviction the alpha layer scales. No parallel discrete-signal path.
Hidden factors
- IC is a prior. The absolute scale of alphas is only as good as the assumed
IC (default
0.03). The relative sizing across names is correct regardless — IC is a common scalar, redundant with the optimizer's risk-aversion term. Flagged loudly in every result. - Cross-sectional, never time-series. Standardize across names at one timestamp. Standardizing a name over time would use the full sample's mean/std — look-ahead.
- Thin universes. Below
min_universe(default 10) names the z-score and winsorize quantiles are unstable, so the pipeline falls back to demean-only (no scaling by cross-sectional std) and setslow_confidence. - As-of discipline. The bar scan is the single home of the
leakage guard — it returns no bar after
as_of, so every panel column (and the residual-vol window) is point-in-time. The alpha table is byte-identical whether or not later bars exist (a regression test asserts this).
Where it runs
services/analysis.py::compute_alphas is the shared entry point: it scans the
universe as of as_of, assembles a FeaturePanel (risk + score
columns), refines it, and returns the ranked table. The CLI (python main.py alphas)
and the read-only MCP tool compute_alphas both route through it (one code path
across surfaces). --source picks the scorer: strategy (continuous conviction,
default), signal (discrete ±1), or scanner (scanner strength). See the
usage guide.
One source of truth
Each strategy was migrated to be score-first: calculate_scores is its one
decision function, and the trade clock's BUY/SELL/HOLD is derived from that score
by the base class (edge-triggered hysteresis — see Strategies).
So strategy_scorer reads exactly the same conviction the live engine acts on;
there is no parallel discrete-signal path to drift. The order path stays
deterministic and model-free — improving how its signal is computed is not the
same as putting a model in the order path.