@@ -71,6 +71,10 @@ A per-symbol time-series computation. Phase 1 ships these built-ins:
7171| ` SMA(window) ` | close | simple moving average |
7272| ` RSI(window) ` | close | Wilder's RSI |
7373| ` Volatility(window, periods_per_year=252) ` | close | annualised stdev of log returns |
74+ | ` StaticPerSymbol(mapping, default=None) ` | — | broadcasts a ` dict[symbol, value] ` (e.g. sector / market-cap) into the cross-section |
75+ | ` CrossSectionalMean(base, mask=None) ` | base factor | per-bar equal-weight mean across surviving symbols |
76+ | ` RollingBeta(target, market, window>=2) ` | two factors | rolling-window OLS beta ` cov(t,m)/var(m) ` ; null when ` var(m) == 0 ` |
77+ | ` Neutralize(target, exposures=[...], mask=None, add_intercept=True) ` | factors | per-bar OLS residualisation of ` target ` against ` exposures ` ; null on rank-deficient bars |
7478
7579You can also subclass ` CustomFactor ` to compute your own.
7680
@@ -90,6 +94,112 @@ factor.top(n) # boolean mask: top-n by descending value
9094factor.bottom(n) # boolean mask: bottom-n by ascending value
9195```
9296
97+ ### Cross-sectional transforms
98+
99+ Per-bar normalisation operators (Phase 2). Each takes an optional
100+ ` mask ` so the statistic is computed only over the universe that
101+ passes the mask:
102+
103+ ``` python
104+ factor.zscore(mask = universe) # (x - mean) / std per bar
105+ factor.demean(mask = universe) # x - mean per bar
106+ factor.winsorize(0.01 , 0.99 , # clip to per-bar quantiles
107+ mask = universe)
108+
109+ # Group-relative variants — stats computed within each group
110+ # (typically sector). `groups` accepts a dict[symbol, key] or any
111+ # Factor that emits a per-symbol category.
112+ factor.zscore(groups = SECTORS ) # z-score within sector
113+ factor.demean(groups = SECTORS , mask = universe)
114+ ```
115+
116+ Where the cross-sectional ` std ` is ` 0 ` or undefined (e.g. only one
117+ symbol survives the mask), ` zscore ` returns ` null ` rather than
118+ ` inf ` /` NaN ` . Masked-out symbols are excluded from the bar's
119+ statistic * and* from the bar's output.
120+
121+ ### Risk neutrality
122+
123+ When you want a factor's signal to be independent of structural
124+ exposures (sector, beta to the market, multi-factor risk model),
125+ use the built-in risk-neutrality primitives. They cover three
126+ common cases:
127+
128+ ** Sector neutrality** — z-score or demean * within* each sector
129+ instead of across the whole universe by passing ` groups= ` . The
130+ mapping can be a ` dict[symbol, sector] ` or any ` Factor ` that
131+ emits a per-symbol category:
132+
133+ ``` python
134+ SECTORS = {" AAPL" : " Tech" , " MSFT" : " Tech" , " JPM" : " Fin" , ... }
135+
136+ class SectorNeutralMomentum (Pipeline ):
137+ momentum = Returns(window = 60 )
138+ signal = momentum.zscore(groups = SECTORS ) # z-score within sector
139+ ```
140+
141+ ** Beta neutralisation** — strip a factor's exposure to the market
142+ (or any other reference series) using ` RollingBeta ` and
143+ ` Neutralize ` :
144+
145+ ``` python
146+ from investing_algorithm_framework import (
147+ Returns, RollingBeta, CrossSectionalMean, Neutralize,
148+ )
149+
150+ class BetaNeutralAlpha (Pipeline ):
151+ r = Returns(window = 1 )
152+ market = CrossSectionalMean(r) # equal-weight market
153+ beta = RollingBeta(r, market, window = 60 )
154+ alpha = Neutralize(r, exposures = [beta]) # market-neutral residual
155+ ```
156+
157+ ** Multi-factor risk model** — pass several exposures to
158+ ` Neutralize ` and the residual is orthogonal to all of them at
159+ each bar (per-bar OLS):
160+
161+ ``` python
162+ class FactorNeutralAlpha (Pipeline ):
163+ r = Returns(window = 1 )
164+ size = StaticPerSymbol(MARKET_CAPS ) # cross-sectional size
165+ val = BookToPrice()
166+ mom = Returns(window = 252 )
167+ residual = Neutralize(r, exposures = [size, val, mom])
168+ ```
169+
170+ Bars where the system is rank-deficient (more exposures than
171+ surviving symbols) yield ` null ` residuals so they're skipped
172+ downstream rather than producing ` NaN ` .
173+
174+ ### Factor algebra
175+
176+ Factors compose via the standard arithmetic operators. The framework
177+ auto-coerces scalar operands and shares sub-expression results via a
178+ per-evaluation cache, so the same input factor is computed once even
179+ when it appears multiple times:
180+
181+ ``` python
182+ class MyScreener (Pipeline ):
183+ momentum = Returns(window = 30 )
184+ vol = Volatility(window = 30 )
185+
186+ universe = AverageDollarVolume(window = 30 ).top(100 )
187+
188+ # Composite alphas — `momentum` is computed once even though it
189+ # appears in two terms.
190+ risk_adjusted = momentum / vol
191+ score = (
192+ momentum.zscore(mask = universe)
193+ - 0.5 * vol.zscore(mask = universe)
194+ )
195+ ```
196+
197+ Supported operators: ` + ` , ` - ` , ` * ` , ` / ` , unary ` - ` . Both operands may
198+ be ` Factor ` instances; either may be a Python ` int ` or ` float ` .
199+ Division by zero leaves ` inf ` in place (downstream filters can drop
200+ it) — for safe normalisation prefer ` zscore ` , which guards against
201+ zero dispersion.
202+
93203## Phased rollout
94204
95205Pipelines run today in the ** event-driven backtest** path and in
@@ -99,7 +209,7 @@ and cached/lazy execution are tracked separately.
99209| Mode | Status | Page |
100210| --- | --- | --- |
101211| Event-driven backtest | ✅ Phase 1 | [ Pipelines: Event-driven backtest] ( pipelines-event-backtest.md ) |
102- | Vector backtest | 🚧 Phase 2 ([ #502 ] ( https://github.com/coding-kitties/investing-algorithm-framework/issues/502 ) ) | [ Pipelines: Vector backtest] ( pipelines-vector-backtest.md ) |
212+ | Vector backtest | ✅ Phase 2 ([ #502 ] ( https://github.com/coding-kitties/investing-algorithm-framework/issues/502 ) ) | [ Pipelines: Vector backtest] ( pipelines-vector-backtest.md ) |
103213| Live trading | 🚧 Phase 3 ([ #503 ] ( https://github.com/coding-kitties/investing-algorithm-framework/issues/503 ) ) | [ Pipelines: Live trading] ( pipelines-live.md ) |
104214
105215Start with the event-driven backtest page — it covers the full Phase 1
0 commit comments