Download algotrader/cross_sectional.py from ParallelLLC/algorithmic_trading: direct link, hf CLI and curl.
- Browser
- Download file 10.4 kB
-
https://huggingface.co/ParallelLLC/algorithmic_trading/resolve/main/algotrader/cross_sectional.py
- Command line
-
hf download hf://ParallelLLC/algorithmic_trading/algotrader/cross_sectional.py
-
curl -L -o cross_sectional.py https://huggingface.co/ParallelLLC/algorithmic_trading/resolve/main/algotrader/cross_sectional.py
10.4 kB
| """Cross-sectional strategies. | |
| Where a single-asset rule asks "should I be long this thing?", a | |
| cross-sectional rule asks "which of these things should I be long, and which | |
| short?". That difference matters for validation: a long-short book that ranks | |
| names is exposed to entirely different failure modes than a timing rule, and it | |
| needs its own null (see :mod:`algotrader.validation.cross_permutation`). | |
| Each strategy is a pure function ``(panel, **params) -> T x N weights``, causal | |
| by construction, with gross exposure of at most 1. | |
| """ | |
| from __future__ import annotations | |
| import itertools | |
| from dataclasses import dataclass | |
| from typing import Callable, Dict, Iterable, List | |
| import numpy as np | |
| import pandas as pd | |
| from .panel import Panel | |
| from .strategies import ParamSpec | |
| __all__ = [ | |
| "CrossSectionalStrategy", | |
| "XS_REGISTRY", | |
| "get_xs_strategy", | |
| "list_xs_strategies", | |
| "scores_to_weights", | |
| ] | |
| MIN_NAMES = 4 # below this, "cross-section" is not a meaningful word | |
| def scores_to_weights( | |
| scores: pd.DataFrame, | |
| long_frac: float = 0.3, | |
| short_frac: float = 0.3, | |
| long_only: bool = False, | |
| min_names: int = MIN_NAMES, | |
| ) -> pd.DataFrame: | |
| """Turn a score matrix into a dollar-neutral (or long-only) weight matrix. | |
| Ranks within each date, takes the top and bottom fractions, and equal-weights | |
| each leg. Gross exposure is 1: half per leg when short-selling, all of it in | |
| the long leg otherwise. | |
| """ | |
| valid = scores.notna() | |
| counts = valid.sum(axis=1) | |
| ranks = scores.rank(axis=1, pct=True, na_option="keep") | |
| longs = (ranks > 1.0 - long_frac) & valid | |
| n_long = longs.sum(axis=1).replace(0, np.nan) | |
| if long_only: | |
| weights = longs.astype(float).div(n_long, axis=0) | |
| else: | |
| shorts = (ranks <= short_frac) & valid | |
| n_short = shorts.sum(axis=1).replace(0, np.nan) | |
| weights = ( | |
| longs.astype(float).div(n_long, axis=0) * 0.5 | |
| - shorts.astype(float).div(n_short, axis=0) * 0.5 | |
| ) | |
| # A cross-section of two names is not a cross-section. | |
| weights = weights.where(counts >= min_names, 0.0) | |
| return weights.fillna(0.0) | |
| def _rebalance_hold(weights: pd.DataFrame, every: int) -> pd.DataFrame: | |
| """Refresh the target only every ``every`` bars, holding it in between.""" | |
| if every <= 1: | |
| return weights | |
| out = weights.copy() | |
| keep = np.zeros(len(out), dtype=bool) | |
| keep[::every] = True | |
| out.iloc[~keep] = np.nan | |
| return out.ffill().fillna(0.0) | |
| # -------------------------------------------------------------------------- | |
| # Strategy implementations | |
| # -------------------------------------------------------------------------- | |
| def _xs_momentum( | |
| panel: Panel, lookback: int = 250, skip: int = 20, rebalance: int = 21, long_frac: float = 0.3 | |
| ) -> pd.DataFrame: | |
| """Classic 12-1 momentum: rank on past return, skipping the most recent month. | |
| The skip is not decoration -- including the last month mixes in short-term | |
| reversal, which points the other way and muddies the signal. | |
| """ | |
| close = panel.close | |
| scores = close.shift(skip) / close.shift(lookback) - 1.0 | |
| return _rebalance_hold(scores_to_weights(scores, long_frac, long_frac), rebalance) | |
| def _xs_reversal( | |
| panel: Panel, lookback: int = 5, rebalance: int = 5, long_frac: float = 0.3 | |
| ) -> pd.DataFrame: | |
| """Short-term reversal: buy the recent losers, sell the recent winners.""" | |
| scores = -(panel.close.pct_change(lookback)) | |
| return _rebalance_hold(scores_to_weights(scores, long_frac, long_frac), rebalance) | |
| def _low_volatility( | |
| panel: Panel, window: int = 60, rebalance: int = 21, long_frac: float = 0.3 | |
| ) -> pd.DataFrame: | |
| """The low-volatility anomaly: long the calm names, short the wild ones.""" | |
| scores = -(panel.close.pct_change().rolling(window, min_periods=window).std()) | |
| return _rebalance_hold(scores_to_weights(scores, long_frac, long_frac), rebalance) | |
| def _xs_value_proxy( | |
| panel: Panel, window: int = 250, rebalance: int = 21, long_frac: float = 0.3 | |
| ) -> pd.DataFrame: | |
| """Distance below the long-run average price, as a crude cheapness proxy. | |
| This is not book-to-market -- there are no fundamentals in the panel -- so | |
| treat it as mean reversion over a long horizon rather than value investing. | |
| """ | |
| close = panel.close | |
| scores = -(close / close.rolling(window, min_periods=window).mean() - 1.0) | |
| return _rebalance_hold(scores_to_weights(scores, long_frac, long_frac), rebalance) | |
| def _equal_weight(panel: Panel, rebalance: int = 21) -> pd.DataFrame: | |
| """Own everything tradable, equally. The bar a stock picker has to clear.""" | |
| listed = panel.close.notna() | |
| counts = listed.sum(axis=1).replace(0, np.nan) | |
| return _rebalance_hold(listed.astype(float).div(counts, axis=0).fillna(0.0), rebalance) | |
| def _xs_random(panel: Panel, rebalance: int = 21, seed: int = 7) -> pd.DataFrame: | |
| """Random long-short book. The control group for cross-sectional claims.""" | |
| rng = np.random.default_rng(int(seed)) | |
| scores = pd.DataFrame( | |
| rng.standard_normal(panel.close.shape), index=panel.index, columns=panel.symbols | |
| ).where(panel.close.notna()) | |
| return _rebalance_hold(scores_to_weights(scores), rebalance) | |
| class CrossSectionalStrategy: | |
| key: str | |
| name: str | |
| family: str | |
| description: str | |
| fn: Callable[..., pd.DataFrame] | |
| params: tuple = () | |
| def defaults(self) -> Dict[str, float]: | |
| return {p.name: p.cast(p.default) for p in self.params} | |
| def clean(self, params: Dict[str, float] | None) -> Dict[str, float]: | |
| merged = self.defaults() | |
| for spec in self.params: | |
| if params and spec.name in params and params[spec.name] is not None: | |
| merged[spec.name] = spec.cast(params[spec.name]) | |
| return merged | |
| def generate(self, panel: Panel, params: Dict[str, float] | None = None) -> pd.DataFrame: | |
| weights = self.fn(panel, **self.clean(params)) | |
| return ( | |
| weights.reindex(index=panel.index, columns=panel.symbols) | |
| .astype(float) | |
| .fillna(0.0) | |
| .clip(-1.0, 1.0) | |
| ) | |
| def grid(self, limit: int | None = None) -> List[Dict[str, float]]: | |
| if not self.params: | |
| return [{}] | |
| names = [p.name for p in self.params] | |
| combos = [dict(zip(names, v)) for v in itertools.product(*[p.grid for p in self.params])] | |
| combos = [c for c in combos if not ("skip" in c and "lookback" in c and c["skip"] >= c["lookback"])] | |
| if limit is not None and len(combos) > limit: | |
| step = len(combos) / limit | |
| combos = [combos[int(i * step)] for i in range(limit)] | |
| return combos | |
| XS_REGISTRY: Dict[str, CrossSectionalStrategy] = {} | |
| def _register(strategy: CrossSectionalStrategy) -> CrossSectionalStrategy: | |
| XS_REGISTRY[strategy.key] = strategy | |
| return strategy | |
| _register( | |
| CrossSectionalStrategy( | |
| key="equal_weight", | |
| name="Equal Weight", | |
| family="benchmark", | |
| description="Own every name equally. The bar a stock picker has to clear.", | |
| fn=_equal_weight, | |
| params=(ParamSpec("rebalance", "Rebalance (bars)", 21, (5, 21, 63), "int", 1, 252, 1),), | |
| ) | |
| ) | |
| _register( | |
| CrossSectionalStrategy( | |
| key="xs_momentum", | |
| name="Cross-Sectional Momentum", | |
| family="momentum", | |
| description="Long the past winners, short the past losers, skipping the most recent month.", | |
| fn=_xs_momentum, | |
| params=( | |
| ParamSpec("lookback", "Lookback", 250, (60, 120, 250), "int", 20, 750, 1), | |
| ParamSpec("skip", "Skip recent", 20, (0, 5, 20), "int", 0, 60, 1), | |
| ParamSpec("rebalance", "Rebalance (bars)", 21, (5, 21, 63), "int", 1, 252, 1), | |
| ParamSpec("long_frac", "Leg size", 0.3, (0.1, 0.2, 0.3), "float", 0.05, 0.5, 0.05), | |
| ), | |
| ) | |
| ) | |
| _register( | |
| CrossSectionalStrategy( | |
| key="xs_reversal", | |
| name="Short-Term Reversal", | |
| family="mean-reversion", | |
| description="Buy this week's losers and sell its winners.", | |
| fn=_xs_reversal, | |
| params=( | |
| ParamSpec("lookback", "Lookback", 5, (1, 3, 5, 10, 21), "int", 1, 60, 1), | |
| ParamSpec("rebalance", "Rebalance (bars)", 5, (1, 5, 21), "int", 1, 252, 1), | |
| ParamSpec("long_frac", "Leg size", 0.3, (0.1, 0.2, 0.3), "float", 0.05, 0.5, 0.05), | |
| ), | |
| ) | |
| ) | |
| _register( | |
| CrossSectionalStrategy( | |
| key="low_volatility", | |
| name="Low Volatility", | |
| family="risk", | |
| description="Long the calm names, short the volatile ones.", | |
| fn=_low_volatility, | |
| params=( | |
| ParamSpec("window", "Vol window", 60, (20, 60, 120), "int", 5, 252, 1), | |
| ParamSpec("rebalance", "Rebalance (bars)", 21, (5, 21, 63), "int", 1, 252, 1), | |
| ParamSpec("long_frac", "Leg size", 0.3, (0.1, 0.2, 0.3), "float", 0.05, 0.5, 0.05), | |
| ), | |
| ) | |
| ) | |
| _register( | |
| CrossSectionalStrategy( | |
| key="xs_value_proxy", | |
| name="Long-Horizon Reversion", | |
| family="value-ish", | |
| description="Long names trading below their long-run average, short those above.", | |
| fn=_xs_value_proxy, | |
| params=( | |
| ParamSpec("window", "Window", 250, (120, 250, 500), "int", 30, 1000, 1), | |
| ParamSpec("rebalance", "Rebalance (bars)", 21, (5, 21, 63), "int", 1, 252, 1), | |
| ParamSpec("long_frac", "Leg size", 0.3, (0.1, 0.2, 0.3), "float", 0.05, 0.5, 0.05), | |
| ), | |
| ) | |
| ) | |
| _register( | |
| CrossSectionalStrategy( | |
| key="xs_random", | |
| name="Random Book (control)", | |
| family="control", | |
| description="Random long-short positions. Anything that cannot beat this is noise.", | |
| fn=_xs_random, | |
| params=( | |
| ParamSpec("rebalance", "Rebalance (bars)", 21, (5, 21, 63), "int", 1, 252, 1), | |
| ParamSpec("seed", "Seed", 7, (1, 7, 42, 123), "int", 0, 9999, 1), | |
| ), | |
| ) | |
| ) | |
| def get_xs_strategy(key: str) -> CrossSectionalStrategy: | |
| try: | |
| return XS_REGISTRY[key] | |
| except KeyError: | |
| raise KeyError( | |
| f"Unknown cross-sectional strategy '{key}'. Available: {', '.join(sorted(XS_REGISTRY))}" | |
| ) from None | |
| def list_xs_strategies(exclude: Iterable[str] = ()) -> List[CrossSectionalStrategy]: | |
| skip = set(exclude) | |
| return [s for k, s in XS_REGISTRY.items() if k not in skip] | |