Skip to content

aether_quant.neutrality

Neutral books, grouped by the job (V6.6.5): dollar and sector neutrality with caps, beta and factor neutrality, and sector-relative or residualised scores.

Neutral books, grouped by the job (V6.6.5): dollar and sector neutrality with caps, beta and factor neutrality, and sector-relative or residualised scores.

apply_book_neutrality

apply_book_neutrality(raw_weights_by_symbol: dict[str, float], *, sector_by_symbol: dict[str, str], dollar_neutral: bool, sector_neutral: bool, gross_exposure_cap: float, max_weight_per_name: float, sector_max_net_weight: float, reapply_sector_cap_after_dollar_neutral: bool = False) -> tuple[dict[str, float], dict]

Deterministic 4-step pipeline over an already-selected book's raw per-symbol weights (main.py's today-formula: min(max_position_weight, 0.10 + 0.15*confidence) * book_role_multiplier, one entry per book-selected symbol only - not the whole universe).

Steps, always in this order: 1. Per-name cap: clip every weight to [-max_weight_per_name, +max_weight_per_name]. 2. Sector neutrality (if sector_neutral): within each sector bucket, if |bucket net| exceeds sector_max_net_weight, SHRINK the whole bucket (every member, proportionally, sign preserved, never amplified) so its net lands exactly at the cap. A bucket already within the cap is left untouched.

 Deliberately NOT "demean to exact zero" (the pre-V5.1.3 behavior -
 see Problems.md #81). Demeaning only preserves information when a
 bucket has genuine within-bucket weight dispersion to fall back
 on. Both of this module's callers (main.py's book formula,
 evaluation/rank_book_simulator.py) equal-weight every name within
 a role, so a bucket that is monolithic to one role - e.g. every
 Forex ticker maps to a single "Forex" bucket, and Forex can never
 appear on the opposite leg - has NO within-bucket dispersion:
 demeaning drove every member to exactly zero, silently erasing the
 entire position instead of bounding a genuine sector tilt.
 Shrink-toward-cap degrades gracefully in every case: an already-
 balanced bucket is untouched, a lopsided or monolithic bucket is
 shrunk (never erased) toward the cap, and a single-member bucket
 (net == its own weight) needs no special case - same code path.
  1. Dollar neutrality (if dollar_neutral): scale the LARGER leg down so sum(long) == sum(|short|). Scale-down only, never up - this can never increase gross exposure. A one-sided book (no shorts, or no longs) has nothing to balance against; that step is skipped for that bar rather than zeroing the book out, and the skip is recorded in diagnostics.
  2. Gross cap: scale everything by min(1, gross_exposure_cap / gross).

Step 3 shrinks one leg only, so a sector holding both a long and a short can end above its cap (proved in formal/AetherFormal/BookNeutrality.lean, sector_cap_counterexample). reapply_sector_cap_after_dollar_neutral (default False, the shipped behaviour) runs step 2 again after step 3: every sector then ends within its cap, and the book may be left slightly off dollar-neutral (recorded as "sector_cap_reapplied").

A non-positive max_weight_per_name skips step 1 like the other two caps (V6.4.3: a cap of 0 zeroed the book and a negative one turned every weight, shorts included, into a long of |cap|).

Returns (weights, diagnostics) where diagnostics is JSON-serializable (state.json's state["book_neutrality"]): {"pre_gross", "post_gross", "net_before", "net_after", "per_sector_net": {sector: net_weight, ...}, "steps_applied": [...]}

Never raises. An empty input returns ({}, diagnostics-with-zeros). Pure dict-in/dict-out - result does not depend on input dict iteration order (every step is a symmetric sum/mean over the group, not a positional operation).

beta_neutralize

beta_neutralize(weights: Mapping[str, float], betas: Mapping[str, float], *, hedge: str | None = None) -> dict[str, float]

A book with zero net beta. With hedge (an instrument whose beta is in betas), its weight absorbs the net beta; without it, w - (w.b / b.b) * b over the names with a finite beta, the smallest change that does it.

estimate_betas

estimate_betas(returns: DataFrame, market: Series, *, window: int = 120, min_obs: int = 60) -> pd.Series

Each column's OLS beta to market over the last window rows where both are finite; NaN with fewer than min_obs.

factor_neutralize

factor_neutralize(weights: Series, exposures: DataFrame, *, ridge: float = 1e-08) -> pd.Series

w - X (X'X + ridge*I)^-1 X'w on the names weights and exposures (names x factors) share; other names unchanged. Rows of exposures with a non-finite value are left out of the projection.

net_beta

net_beta(weights: Mapping[str, float], betas: Mapping[str, float]) -> float

sum(w_i * beta_i) over names with a finite beta.

residualise_signal

residualise_signal(table: DataFrame, exposures: Mapping[str, DataFrame], sectors: Mapping[str, str]) -> pd.DataFrame

Per date, what is left of the signal's rank after an OLS on the ranked exposures and sector dummies. A name missing an exposure gets NaN; a date with too few names is all NaN, never an unadjusted signal.

sector_relative_scores

sector_relative_scores(scores_by_symbol: dict[str, float], sector_by_symbol: dict[str, str] | None, min_sector_size: int = 3) -> dict[str, float]

Each score minus the mean of its sector's scores in this cross-section (V6.4.0, lead L4): a rank within the sector's own peers rather than across sectors. A sector with fewer than min_sector_size scored names (or none mapped) is de-meaned by the whole cross-section's mean instead, so its names keep their relative order.