Skip to main content

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.

Research clock only

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 z on 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 by add_factor_exposure_features from 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:

  1. 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.
  2. 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.
  3. Report what was applied, not what was asked. The refinement records panel.meta["neutralized_against"] (and an imputation count); the services echo it as neutralized_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.
Known limitations (deliberate, tracked)
  • The MCP tools don't expose neutralize_factors yet — results echo the field (always [] via that surface); wiring the parameter through src/mcp/server.py is 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_CS and the σ_n multiply is correct (the classic sector-momentum example). This is α = σ·IC·z, unchanged.

  • Case 2Std_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 σ_n double-counts it, systematically overweighting high-vol names. The fix replaces the per-name σ_n with one cross-sectional constant c_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_size deflates 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 a shrink_chain with 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 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:

  1. A scorer fills the panel's score column (one per name). A scorer is just Callable[[DataFrame], float]:

    ScorerScore
    strategy_scorerthe strategy's own continuous conviction (calculate_scores) — the natural, richest source
    signal_scorerthe strategy's discrete direction as +1 / −1 / 0 (a lossy bucketing of the score)
    scanner_scorera scanner's signal_strength, signed by direction
  2. The risk producer fills beta and residual_vol.

  3. refine_alpha(panel, context) reads score + residual_vol (+ beta when neutralizing) and writes the z and alpha columns. 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 sets low_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.