File size: 5,511 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 | """The cross-sectional null.
For a timing rule, shuffling the price path is the right null. For a rule that
*ranks names*, it is the wrong test entirely: shuffling time destroys the
market's whole correlation structure, and the resulting null is so weak that
almost any long-short book clears it.
The question a cross-sectional strategy has to answer is narrower. Not "does
this market have structure?" but: **given these dates, these assets and this
book's shape, does the strategy put its weight on the right names?**
So we permute the *weights across assets within each date*. Every calendar
effect survives. Every correlation between names survives. The gross and net
exposure of the book on each date survives exactly. The one thing destroyed is
the link between the strategy's choice and the asset it chose.
A momentum book that beats this null is picking names. One that does not was
being paid for its market exposure, its sector tilt, or the calendar -- all of
which are available far more cheaply.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Callable, Optional
import numpy as np
import pandas as pd
from ..panel import Panel
from ..portfolio import run_portfolio_backtest
from ..types import CostModel
__all__ = ["cross_sectional_permutation_test", "permute_within_dates", "CrossPermutationResult"]
@dataclass
class CrossPermutationResult:
observed: float
null: np.ndarray
p_value: float
n_permutations: int
@property
def null_mean(self) -> float:
return float(np.mean(self.null)) if self.null.size else 0.0
@property
def percentile(self) -> float:
if not self.null.size:
return 50.0
return float((self.null < self.observed).mean() * 100.0)
def permute_within_dates(
weights: pd.DataFrame,
investable: pd.DataFrame,
rng: np.random.Generator,
) -> pd.DataFrame:
"""Reassign each date's weights among that date's investable assets.
The multiset of weights on every row is preserved exactly -- so gross
exposure, net exposure, leg sizes and position counts are all identical to
the real book -- but which asset receives which weight is randomised.
Vectorised across all dates at once: sorting each row puts the investable
weights first and pushes non-investable slots to NaN, then a random rank per
investable slot picks each weight exactly once.
"""
values = weights.to_numpy(dtype=float, copy=True)
mask = investable.to_numpy(dtype=bool)
masked = np.where(mask, values, np.nan)
# NaNs sort last, so the first k entries of each row are that row's real weights.
ordered = np.sort(masked, axis=1)
noise = np.where(mask, rng.random(values.shape), np.inf)
# Double argsort turns random values into ranks 0..k-1 for investable slots,
# and k..n-1 for the rest, which then index into the NaN tail.
random_rank = np.argsort(np.argsort(noise, axis=1), axis=1)
shuffled = np.take_along_axis(ordered, random_rank, axis=1)
return pd.DataFrame(
np.nan_to_num(shuffled, nan=0.0), index=weights.index, columns=weights.columns
)
def cross_sectional_permutation_test(
panel: Panel,
weights: pd.DataFrame,
n_permutations: int = 200,
costs: Optional[CostModel] = None,
lag: int = 1,
gross_leverage: float = 1.0,
max_weight: Optional[float] = None,
allow_short: bool = True,
rebalance_on: Optional[pd.Series] = None,
seed: int = 0,
observed: Optional[float] = None,
neutralise_costs: bool = True,
progress: Optional[Callable[[float, str], None]] = None,
) -> CrossPermutationResult:
"""Test whether a book's Sharpe survives randomising which names it picked.
``neutralise_costs`` defaults to True, and it matters more than it looks.
A real momentum book holds many of the same names from one rebalance to the
next, so it churns slowly. A shuffled book reassigns names at random every
date, so it churns furiously and pays for it. Charging costs would penalise
the null for turnover the strategy never had, and the strategy would look
good by comparison for reasons that have nothing to do with skill.
So this test asks only "did it pick the right names?" and leaves "can you
afford to trade it?" to the cost stress test, which measures that directly.
"""
costs = CostModel(0.0, 0.0, 0.0) if neutralise_costs else (costs or CostModel())
rng = np.random.default_rng(seed)
investable = panel.close.notna()
def sharpe_of(w: pd.DataFrame) -> float:
return run_portfolio_backtest(
panel,
w,
costs=costs,
lag=lag,
gross_leverage=gross_leverage,
max_weight=max_weight,
allow_short=allow_short,
rebalance_on=rebalance_on,
).sharpe
if observed is None:
observed = sharpe_of(weights)
null = np.empty(n_permutations, dtype=float)
for i in range(n_permutations):
null[i] = sharpe_of(permute_within_dates(weights, investable, rng))
if progress is not None and (i % 10 == 0 or i == n_permutations - 1):
progress((i + 1) / n_permutations, f"Cross-sectional shuffle {i + 1}/{n_permutations}")
p_value = float((1 + np.sum(null >= observed)) / (n_permutations + 1))
return CrossPermutationResult(
observed=float(observed),
null=null,
p_value=p_value,
n_permutations=n_permutations,
)
|