File size: 10,436 Bytes
2d2e42a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
"""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)


@dataclass(frozen=True)
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]