Add multi-asset portfolio support with a cross-sectional null
Browse filesSingle-asset backtesting caps this at hobbyist use: anyone doing real
cross-sectional work (rank N names, long the top decile, short the bottom)
could not express their strategy at all. This adds the portfolio path.
Panel (algotrader/panel.py): aligned T x N field frames with per-symbol
provenance. Missing data deliberately stays missing — forward-filling a symbol
through days it did not trade invents liquidity and lets a strategy hold a
delisted name forever. That choice is what makes survivorship measurable, and
Panel.survivorship() reports it: a universe where every name still trades after
a decade was chosen after the fact, and the Reality Score now caps at 60 when it
finds one.
Matrix engine (algotrader/portfolio.py): T x N weights with drift-aware
turnover. Hold 50% of a book in a name that doubles and you are at 67% without
trading, so charging |target[t] - target[t-1]| understates the cost of doing
nothing. Turnover is measured against the drifted weight instead. A rebalance
schedule (D/W/M/Q) lets a monthly book drift between dates rather than paying
daily to stand still — the one genuinely recursive step, everything else stays
vectorised.
The same drift correction went into the single-asset engine, which had the same
flaw for fractional and short positions: a 100% short whose asset falls 10%
drifts to -82% and genuinely needs rebalancing. Both engines now produce
bit-identical results on single-asset cases, verified across four target shapes.
Cross-sectional null (validation/cross_permutation.py): shuffling the price path
is the wrong test for a book that ranks names — it destroys the market's whole
correlation structure and almost any long-short book clears a null that weak.
Instead we permute weights across assets within each date. Calendar effects,
correlations, gross exposure, net exposure and position count all survive
exactly; only the link between choice and asset is destroyed.
Costs are neutralised inside that test, and this mattered. A real momentum book
holds many of the same names between rebalances; a shuffled book churns and pays
for it. Charging costs penalised the null for turnover the strategy never had,
biasing p-values downward — mean p under H0 was 0.445 instead of 0.50. With
costs neutralised it is 0.485, and implementability is left to the cost stress
test, which measures it directly.
Attribution (algotrader/attribution.py): regress returns on style factors built
from the panel itself — market, momentum, low-vol, reversal, liquidity — with
White standard errors, since return series are heteroskedastic and classical
errors would overstate alpha. Equal weight comes back R2 0.97, market beta 0.99,
no alpha: you have reinvented the index. Factors are proxies and labelled as
such; dollar volume is liquidity, not size.
Also: cross-sectional strategy zoo with equal-weight and random-book controls,
walk_forward_panel, a Portfolio tab in the Space, a `portfolio` CLI subcommand,
and verdict wording parameterised so the portfolio path stops talking about
"shuffled markets" and "buy & hold".
Two pandas 3.0 breakages fixed in the v1 tests, which would have hit CI as soon
as it resolved pandas 3.x: to_datetime now infers datetime64[us] rather than
[ns], and strings default to StringDtype rather than object.
183 tests, no network. The cross-sectional null is calibrated (p ~ 0.5 with no
structure, power rising monotonically with effect size) and the tests assert the
permutation preserves each date's exposure and position count exactly — if it
did not, the null would be measuring something else.
- .github/workflows/sync-hf-space.yml +1 -1
- README.md +69 -4
- SPACE_README.md +13 -0
- algotrader/__init__.py +25 -4
- algotrader/attribution.py +148 -0
- algotrader/charts.py +114 -7
- algotrader/cli.py +104 -5
- algotrader/cross_sectional.py +290 -0
- algotrader/engine.py +9 -2
- algotrader/panel.py +283 -0
- algotrader/portfolio.py +257 -0
- algotrader/portfolio_lab.py +319 -0
- algotrader/validation/cross_permutation.py +146 -0
- algotrader/validation/walkforward.py +105 -1
- algotrader/verdict.py +42 -8
- app.py +290 -0
- scripts/deploy_hf_space.sh +2 -1
- tests/test_data_ingestion.py +2 -2
- tests/test_synthetic_data_generator.py +2 -2
- tests/test_v2_portfolio.py +377 -0
|
@@ -37,7 +37,7 @@ jobs:
|
|
| 37 |
run: |
|
| 38 |
pip install --quiet -r requirements-space.txt pytest
|
| 39 |
python -m pytest tests/test_v2_engine.py tests/test_v2_validation.py \
|
| 40 |
-
tests/test_v2_strategies.py -q
|
| 41 |
ALGOTRADER_OFFLINE=1 python -c "import app; app.build_app(); print('Space builds OK')"
|
| 42 |
|
| 43 |
- name: Push to the Space
|
|
|
|
| 37 |
run: |
|
| 38 |
pip install --quiet -r requirements-space.txt pytest
|
| 39 |
python -m pytest tests/test_v2_engine.py tests/test_v2_validation.py \
|
| 40 |
+
tests/test_v2_strategies.py tests/test_v2_portfolio.py -q
|
| 41 |
ALGOTRADER_OFFLINE=1 python -c "import app; app.build_app(); print('Space builds OK')"
|
| 42 |
|
| 43 |
- name: Push to the Space
|
|
@@ -17,6 +17,12 @@ unchanged and still lives here — see [docs/AGENTIC_SYSTEM_V1.md](docs/AGENTIC_
|
|
| 17 |
|
| 18 |
---
|
| 19 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
## The four ways a backtest lies
|
| 21 |
|
| 22 |
| The lie | The test | Where |
|
|
@@ -55,6 +61,56 @@ Block mode resamples contiguous chunks instead of single bars, preserving
|
|
| 55 |
short-horizon momentum and volatility clustering — a harder null that trend
|
| 56 |
strategies deserve to be held to.
|
| 57 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
## No look-ahead, by construction
|
| 59 |
|
| 60 |
A strategy emits a target exposure at each bar's close using only data up to that
|
|
@@ -119,9 +175,12 @@ deterministic and network-free.
|
|
| 119 |
|
| 120 |
## The strategy zoo
|
| 121 |
|
| 122 |
-
`buy_and_hold` · `sma_cross` · `ema_cross` · `macd_trend` ·
|
| 123 |
-
`
|
| 124 |
-
`channel_trend` · `coin_flip`
|
|
|
|
|
|
|
|
|
|
| 125 |
|
| 126 |
Buy & hold and the coin flip are controls, and they stay in the arena on purpose: a
|
| 127 |
leaderboard without a control group is marketing, not measurement.
|
|
@@ -157,7 +216,7 @@ carries the full v1 stack for CI, Docker and the FinRL agents.
|
|
| 157 |
## Tests
|
| 158 |
|
| 159 |
```bash
|
| 160 |
-
python -m pytest tests/test_v2_*.py -q #
|
| 161 |
```
|
| 162 |
|
| 163 |
The validation tests check both directions, which is the part that matters: the
|
|
@@ -166,6 +225,12 @@ genuine serial correlation and assert that the permutation test finds it, that P
|
|
| 166 |
stays near 0.5 on pure noise and drops below 0.15 when one variant is genuinely
|
| 167 |
better, and that walk-forward efficiency survives.
|
| 168 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 169 |
## References
|
| 170 |
|
| 171 |
- Bailey & López de Prado (2014), *The Deflated Sharpe Ratio: Correcting for Selection
|
|
|
|
| 17 |
|
| 18 |
---
|
| 19 |
|
| 20 |
+
## Two labs
|
| 21 |
+
|
| 22 |
+
**The Lab** validates a timing rule on one asset. **The Portfolio Lab** validates a
|
| 23 |
+
cross-sectional book that ranks many names — and it asks three harder questions,
|
| 24 |
+
because a long-short book fails in ways a timing rule cannot.
|
| 25 |
+
|
| 26 |
## The four ways a backtest lies
|
| 27 |
|
| 28 |
| The lie | The test | Where |
|
|
|
|
| 61 |
short-horizon momentum and volatility clustering — a harder null that trend
|
| 62 |
strategies deserve to be held to.
|
| 63 |
|
| 64 |
+
## Cross-sectional books get a harder null
|
| 65 |
+
|
| 66 |
+
Shuffling the price path is the right null for a timing rule and the *wrong* one for
|
| 67 |
+
a book that ranks names: it destroys the market's whole correlation structure, and
|
| 68 |
+
almost any long-short book clears a null that weak.
|
| 69 |
+
|
| 70 |
+
So the Portfolio Lab permutes the **weights across assets within each date**. Every
|
| 71 |
+
calendar effect survives. Every correlation between names survives. Each date's gross
|
| 72 |
+
exposure, net exposure and position count survive *exactly*. The only thing destroyed
|
| 73 |
+
is the link between the strategy's choice and the asset it chose.
|
| 74 |
+
|
| 75 |
+
A book that beats that null is picking names. One that doesn't was being paid for
|
| 76 |
+
market exposure or a style tilt — which the factor regression measures directly:
|
| 77 |
+
|
| 78 |
+
| Question | Test |
|
| 79 |
+
|---|---|
|
| 80 |
+
| Did it pick the right names? | Within-date weight permutation |
|
| 81 |
+
| Is it alpha, or beta you can buy for 3bps? | Style regression (market, momentum, low-vol, reversal, liquidity) with White standard errors |
|
| 82 |
+
| Does the universe contain the losers? | Survivorship measured, not assumed |
|
| 83 |
+
|
| 84 |
+
That last one is not optional. A universe where every name is still trading after ten
|
| 85 |
+
years was chosen after the fact, and every result computed on it is an upper bound.
|
| 86 |
+
The Panel measures survival directly and the Reality Score caps at 60 when it finds
|
| 87 |
+
none.
|
| 88 |
+
|
| 89 |
+
```python
|
| 90 |
+
from algotrader import PortfolioLabConfig, run_portfolio_lab
|
| 91 |
+
|
| 92 |
+
report = run_portfolio_lab(PortfolioLabConfig(
|
| 93 |
+
symbols=["SPY", "QQQ", "AAPL", "MSFT", "NVDA", "GLD", "TLT"],
|
| 94 |
+
strategy="xs_momentum",
|
| 95 |
+
rebalance="M",
|
| 96 |
+
))
|
| 97 |
+
print(report.verdict["grade"], report.permutation.p_value)
|
| 98 |
+
print(report.attribution["note"])
|
| 99 |
+
print(report.survivorship.note)
|
| 100 |
+
```
|
| 101 |
+
|
| 102 |
+
```bash
|
| 103 |
+
python -m algotrader.cli portfolio --symbols SPY,QQQ,AAPL,MSFT,NVDA --strategy xs_momentum
|
| 104 |
+
```
|
| 105 |
+
|
| 106 |
+
## Turnover is measured against drift, not against the last target
|
| 107 |
+
|
| 108 |
+
Holding 50% of a book in a name that doubles leaves you at 67% without trading. A
|
| 109 |
+
backtest that charges turnover as `|target[t] - target[t-1]|` understates the cost of
|
| 110 |
+
doing nothing and overstates the cost of rebalancing. Both engines measure turnover
|
| 111 |
+
against the *drifted* weight instead, and a rebalance schedule (`D`/`W`/`M`/`Q`) lets a
|
| 112 |
+
monthly book drift between dates rather than paying daily to stand still.
|
| 113 |
+
|
| 114 |
## No look-ahead, by construction
|
| 115 |
|
| 116 |
A strategy emits a target exposure at each bar's close using only data up to that
|
|
|
|
| 175 |
|
| 176 |
## The strategy zoo
|
| 177 |
|
| 178 |
+
Single asset: `buy_and_hold` · `sma_cross` · `ema_cross` · `macd_trend` ·
|
| 179 |
+
`rsi_reversion` · `bollinger_reversion` · `donchian_breakout` · `momentum` ·
|
| 180 |
+
`vol_target_momentum` · `channel_trend` · `coin_flip`
|
| 181 |
+
|
| 182 |
+
Cross-sectional: `equal_weight` · `xs_momentum` · `xs_reversal` · `low_volatility` ·
|
| 183 |
+
`xs_value_proxy` · `xs_random`
|
| 184 |
|
| 185 |
Buy & hold and the coin flip are controls, and they stay in the arena on purpose: a
|
| 186 |
leaderboard without a control group is marketing, not measurement.
|
|
|
|
| 216 |
## Tests
|
| 217 |
|
| 218 |
```bash
|
| 219 |
+
python -m pytest tests/test_v2_*.py -q # 162 tests, ~25s, no network
|
| 220 |
```
|
| 221 |
|
| 222 |
The validation tests check both directions, which is the part that matters: the
|
|
|
|
| 225 |
stays near 0.5 on pure noise and drops below 0.15 when one variant is genuinely
|
| 226 |
better, and that walk-forward efficiency survives.
|
| 227 |
|
| 228 |
+
The cross-sectional null is held to the same standard, and it is calibrated: on a
|
| 229 |
+
universe with no cross-sectional structure it returns p ≈ 0.5, and its power rises
|
| 230 |
+
monotonically with the size of the injected effect. The tests also assert the
|
| 231 |
+
permutation preserves each date's gross exposure, net exposure and position count
|
| 232 |
+
exactly — if it did not, the null would be testing something else.
|
| 233 |
+
|
| 234 |
## References
|
| 235 |
|
| 236 |
- Bailey & López de Prado (2014), *The Deflated Sharpe Ratio: Correcting for Selection
|
|
@@ -41,6 +41,19 @@ you need before risking money: **how much of that was luck?**
|
|
| 41 |
Each contributes to a single **Reality Score** out of 100, with a grade from A to F.
|
| 42 |
The scale is deliberately harsh. Most strategies people post online score below 40.
|
| 43 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
## Try this first
|
| 45 |
|
| 46 |
Run the **Arena** tab on `SPY`. On most markets and most date ranges, plain
|
|
|
|
| 41 |
Each contributes to a single **Reality Score** out of 100, with a grade from A to F.
|
| 42 |
The scale is deliberately harsh. Most strategies people post online score below 40.
|
| 43 |
|
| 44 |
+
## Two labs
|
| 45 |
+
|
| 46 |
+
**The Lab** validates a timing rule on one asset. **The Portfolio Lab** validates a
|
| 47 |
+
book that ranks many names — and it gets a harder null: we keep every date's gross
|
| 48 |
+
exposure, net exposure and position count exactly as they were and randomise only
|
| 49 |
+
**which name got which weight**. A book that beats that is picking names. One that
|
| 50 |
+
doesn't was being paid for style exposure you can buy in an ETF, which the factor
|
| 51 |
+
regression measures directly.
|
| 52 |
+
|
| 53 |
+
It also measures survivorship rather than assuming it away. A universe where every
|
| 54 |
+
name is still trading after ten years was chosen after the fact, and every number
|
| 55 |
+
computed on it is an upper bound.
|
| 56 |
+
|
| 57 |
## Try this first
|
| 58 |
|
| 59 |
Run the **Arena** tab on `SPY`. On most markets and most date ranges, plain
|
|
@@ -12,29 +12,50 @@ Quick start::
|
|
| 12 |
print(report.verdict["verdict"])
|
| 13 |
"""
|
| 14 |
|
|
|
|
|
|
|
| 15 |
from .data import load_ohlcv, simulate_ohlcv
|
| 16 |
from .engine import run_backtest
|
| 17 |
from .lab import LabConfig, LabReport, run_arena, run_lab
|
| 18 |
from .metrics import compute_metrics
|
|
|
|
|
|
|
|
|
|
| 19 |
from .strategies import REGISTRY, get_strategy, list_strategies
|
| 20 |
from .types import BacktestResult, CostModel, MarketData
|
| 21 |
from .verdict import reality_score
|
| 22 |
|
| 23 |
-
__version__ = "2.
|
| 24 |
|
| 25 |
__all__ = [
|
| 26 |
"__version__",
|
|
|
|
| 27 |
"LabConfig",
|
| 28 |
"LabReport",
|
| 29 |
"run_lab",
|
| 30 |
"run_arena",
|
| 31 |
"run_backtest",
|
| 32 |
-
"compute_metrics",
|
| 33 |
-
"load_ohlcv",
|
| 34 |
-
"simulate_ohlcv",
|
| 35 |
"get_strategy",
|
| 36 |
"list_strategies",
|
| 37 |
"REGISTRY",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
"BacktestResult",
|
| 39 |
"CostModel",
|
| 40 |
"MarketData",
|
|
|
|
| 12 |
print(report.verdict["verdict"])
|
| 13 |
"""
|
| 14 |
|
| 15 |
+
from .attribution import build_style_factors, factor_attribution
|
| 16 |
+
from .cross_sectional import XS_REGISTRY, get_xs_strategy, list_xs_strategies
|
| 17 |
from .data import load_ohlcv, simulate_ohlcv
|
| 18 |
from .engine import run_backtest
|
| 19 |
from .lab import LabConfig, LabReport, run_arena, run_lab
|
| 20 |
from .metrics import compute_metrics
|
| 21 |
+
from .panel import Panel, load_panel
|
| 22 |
+
from .portfolio import rebalance_schedule, run_portfolio_backtest
|
| 23 |
+
from .portfolio_lab import PortfolioLabConfig, PortfolioLabReport, run_portfolio_arena, run_portfolio_lab
|
| 24 |
from .strategies import REGISTRY, get_strategy, list_strategies
|
| 25 |
from .types import BacktestResult, CostModel, MarketData
|
| 26 |
from .verdict import reality_score
|
| 27 |
|
| 28 |
+
__version__ = "2.1.0"
|
| 29 |
|
| 30 |
__all__ = [
|
| 31 |
"__version__",
|
| 32 |
+
# single asset
|
| 33 |
"LabConfig",
|
| 34 |
"LabReport",
|
| 35 |
"run_lab",
|
| 36 |
"run_arena",
|
| 37 |
"run_backtest",
|
|
|
|
|
|
|
|
|
|
| 38 |
"get_strategy",
|
| 39 |
"list_strategies",
|
| 40 |
"REGISTRY",
|
| 41 |
+
# multi asset
|
| 42 |
+
"Panel",
|
| 43 |
+
"load_panel",
|
| 44 |
+
"run_portfolio_backtest",
|
| 45 |
+
"rebalance_schedule",
|
| 46 |
+
"PortfolioLabConfig",
|
| 47 |
+
"PortfolioLabReport",
|
| 48 |
+
"run_portfolio_lab",
|
| 49 |
+
"run_portfolio_arena",
|
| 50 |
+
"get_xs_strategy",
|
| 51 |
+
"list_xs_strategies",
|
| 52 |
+
"XS_REGISTRY",
|
| 53 |
+
"build_style_factors",
|
| 54 |
+
"factor_attribution",
|
| 55 |
+
# shared
|
| 56 |
+
"compute_metrics",
|
| 57 |
+
"load_ohlcv",
|
| 58 |
+
"simulate_ohlcv",
|
| 59 |
"BacktestResult",
|
| 60 |
"CostModel",
|
| 61 |
"MarketData",
|
|
@@ -0,0 +1,148 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Style attribution: is this alpha, or is it beta you could have bought cheaply?
|
| 2 |
+
|
| 3 |
+
The most common way a strategy is oversold is not fraud or overfitting — it is
|
| 4 |
+
that the "edge" is a well-known risk premium wearing a new name. A book that
|
| 5 |
+
loads on market beta, or on momentum, or on low-volatility, will produce a
|
| 6 |
+
respectable Sharpe and an exciting story, and you can buy the same exposure in
|
| 7 |
+
an ETF for a few basis points.
|
| 8 |
+
|
| 9 |
+
So we regress the strategy's returns on style factors built from the panel
|
| 10 |
+
itself and ask what is left over. If the intercept is not distinguishable from
|
| 11 |
+
zero, the strategy has no alpha — however good its Sharpe looked.
|
| 12 |
+
|
| 13 |
+
Factors are constructed from the universe under test rather than downloaded,
|
| 14 |
+
which keeps this offline and self-consistent. The tradeoff is that they are
|
| 15 |
+
proxies: without fundamentals there is no true value or size factor, so those
|
| 16 |
+
are labelled honestly as what they are.
|
| 17 |
+
"""
|
| 18 |
+
|
| 19 |
+
from __future__ import annotations
|
| 20 |
+
|
| 21 |
+
from typing import Dict, Optional
|
| 22 |
+
|
| 23 |
+
import numpy as np
|
| 24 |
+
import pandas as pd
|
| 25 |
+
|
| 26 |
+
from .panel import Panel
|
| 27 |
+
from .portfolio import run_portfolio_backtest
|
| 28 |
+
from .types import CostModel
|
| 29 |
+
|
| 30 |
+
__all__ = ["build_style_factors", "factor_attribution"]
|
| 31 |
+
|
| 32 |
+
_FREE = CostModel(0.0, 0.0, 0.0) # factors are theoretical portfolios
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def _long_short(panel: Panel, scores: pd.DataFrame, rebalance: int = 21) -> pd.Series:
|
| 36 |
+
from .cross_sectional import _rebalance_hold, scores_to_weights
|
| 37 |
+
|
| 38 |
+
weights = _rebalance_hold(scores_to_weights(scores), rebalance)
|
| 39 |
+
return run_portfolio_backtest(panel, weights, costs=_FREE).returns
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def build_style_factors(panel: Panel, rebalance: int = 21) -> pd.DataFrame:
|
| 43 |
+
"""Construct market and style factor returns from the panel itself."""
|
| 44 |
+
close = panel.close
|
| 45 |
+
listed = close.notna()
|
| 46 |
+
counts = listed.sum(axis=1).replace(0, np.nan)
|
| 47 |
+
|
| 48 |
+
equal = listed.astype(float).div(counts, axis=0).fillna(0.0)
|
| 49 |
+
market = run_portfolio_backtest(panel, equal, costs=_FREE).returns
|
| 50 |
+
|
| 51 |
+
factors = {
|
| 52 |
+
"market": market,
|
| 53 |
+
"momentum": _long_short(panel, close.shift(20) / close.shift(250) - 1.0, rebalance),
|
| 54 |
+
"low_vol": _long_short(panel, -close.pct_change().rolling(60, min_periods=60).std(), rebalance),
|
| 55 |
+
"reversal": _long_short(panel, -close.pct_change(5), rebalance),
|
| 56 |
+
# Dollar volume is a liquidity proxy, not market cap. Named accordingly.
|
| 57 |
+
"liquidity": _long_short(panel, -np.log(panel.dollar_volume().replace(0, np.nan)), rebalance),
|
| 58 |
+
}
|
| 59 |
+
return pd.DataFrame(factors).reindex(panel.index).fillna(0.0)
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def _white_standard_errors(x: np.ndarray, residuals: np.ndarray, xtx_inv: np.ndarray) -> np.ndarray:
|
| 63 |
+
"""Heteroskedasticity-robust (White) standard errors.
|
| 64 |
+
|
| 65 |
+
Return series are famously heteroskedastic — volatility clusters — and
|
| 66 |
+
classical standard errors would overstate the significance of alpha.
|
| 67 |
+
"""
|
| 68 |
+
meat = x.T @ (x * (residuals**2)[:, None])
|
| 69 |
+
covariance = xtx_inv @ meat @ xtx_inv
|
| 70 |
+
return np.sqrt(np.maximum(np.diag(covariance), 0.0))
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def factor_attribution(
|
| 74 |
+
returns: pd.Series,
|
| 75 |
+
factors: pd.DataFrame,
|
| 76 |
+
periods_per_year: int = 252,
|
| 77 |
+
alpha_t_threshold: float = 2.0,
|
| 78 |
+
) -> Dict[str, object]:
|
| 79 |
+
"""Regress strategy returns on style factors; report annualised alpha and betas."""
|
| 80 |
+
aligned = pd.concat([returns.rename("strategy"), factors], axis=1).dropna()
|
| 81 |
+
if len(aligned) < 60 or factors.shape[1] == 0:
|
| 82 |
+
return {"available": False, "note": "Not enough overlapping observations for attribution."}
|
| 83 |
+
|
| 84 |
+
y = aligned["strategy"].to_numpy(dtype=float)
|
| 85 |
+
names = list(factors.columns)
|
| 86 |
+
x = np.column_stack([np.ones(len(aligned))] + [aligned[c].to_numpy(dtype=float) for c in names])
|
| 87 |
+
|
| 88 |
+
# Drop factors that are constant or collinear; a singular fit is worse than
|
| 89 |
+
# a smaller one.
|
| 90 |
+
keep = [0] + [i + 1 for i, c in enumerate(names) if aligned[c].std() > 1e-12]
|
| 91 |
+
x = x[:, keep]
|
| 92 |
+
names = [names[i - 1] for i in keep[1:]]
|
| 93 |
+
|
| 94 |
+
try:
|
| 95 |
+
xtx_inv = np.linalg.pinv(x.T @ x)
|
| 96 |
+
except np.linalg.LinAlgError: # pragma: no cover - pinv rarely fails
|
| 97 |
+
return {"available": False, "note": "Factor matrix is singular."}
|
| 98 |
+
|
| 99 |
+
beta = xtx_inv @ x.T @ y
|
| 100 |
+
fitted = x @ beta
|
| 101 |
+
residuals = y - fitted
|
| 102 |
+
errors = _white_standard_errors(x, residuals, xtx_inv)
|
| 103 |
+
t_stats = np.divide(beta, errors, out=np.zeros_like(beta), where=errors > 0)
|
| 104 |
+
|
| 105 |
+
ss_res = float(residuals @ residuals)
|
| 106 |
+
ss_tot = float(((y - y.mean()) ** 2).sum())
|
| 107 |
+
r_squared = 1.0 - ss_res / ss_tot if ss_tot > 0 else 0.0
|
| 108 |
+
|
| 109 |
+
alpha_period = float(beta[0])
|
| 110 |
+
alpha_annual = alpha_period * periods_per_year
|
| 111 |
+
alpha_t = float(t_stats[0])
|
| 112 |
+
significant = bool(abs(alpha_t) >= alpha_t_threshold and alpha_annual > 0)
|
| 113 |
+
|
| 114 |
+
betas = {name: float(b) for name, b in zip(names, beta[1:])}
|
| 115 |
+
beta_ts = {name: float(t) for name, t in zip(names, t_stats[1:])}
|
| 116 |
+
dominant = max(betas, key=lambda k: abs(betas[k])) if betas else None
|
| 117 |
+
|
| 118 |
+
if significant:
|
| 119 |
+
note = (
|
| 120 |
+
f"Alpha of {alpha_annual:.1%} a year survives the style regression "
|
| 121 |
+
f"(t = {alpha_t:.1f}). Something here is not explained by market, momentum, "
|
| 122 |
+
"low-volatility, reversal or liquidity exposure."
|
| 123 |
+
)
|
| 124 |
+
else:
|
| 125 |
+
explained = (
|
| 126 |
+
f" Most of the variation is {dominant} exposure (beta {betas[dominant]:.2f})."
|
| 127 |
+
if dominant
|
| 128 |
+
else ""
|
| 129 |
+
)
|
| 130 |
+
note = (
|
| 131 |
+
f"Alpha is {alpha_annual:.1%} a year with t = {alpha_t:.1f}, which is not "
|
| 132 |
+
f"distinguishable from zero. The style factors explain {r_squared:.0%} of the "
|
| 133 |
+
f"returns.{explained} You can buy that exposure far more cheaply than by "
|
| 134 |
+
"running this strategy."
|
| 135 |
+
)
|
| 136 |
+
|
| 137 |
+
return {
|
| 138 |
+
"available": True,
|
| 139 |
+
"alpha_annual": alpha_annual,
|
| 140 |
+
"alpha_t_stat": alpha_t,
|
| 141 |
+
"alpha_significant": significant,
|
| 142 |
+
"betas": betas,
|
| 143 |
+
"beta_t_stats": beta_ts,
|
| 144 |
+
"r_squared": float(r_squared),
|
| 145 |
+
"dominant_factor": dominant,
|
| 146 |
+
"n_obs": int(len(aligned)),
|
| 147 |
+
"note": note,
|
| 148 |
+
}
|
|
@@ -67,8 +67,8 @@ def empty_figure(message: str = _EMPTY_NOTE, height: int = 340) -> go.Figure:
|
|
| 67 |
return fig
|
| 68 |
|
| 69 |
|
| 70 |
-
def equity_chart(report) -> go.Figure:
|
| 71 |
-
"""Strategy equity against
|
| 72 |
bt = report.backtest
|
| 73 |
strat = bt.equity / bt.equity.iloc[0] * 100.0
|
| 74 |
bench = bt.benchmark_equity / bt.benchmark_equity.iloc[0] * 100.0
|
|
@@ -76,9 +76,9 @@ def equity_chart(report) -> go.Figure:
|
|
| 76 |
fig = go.Figure()
|
| 77 |
fig.add_trace(
|
| 78 |
go.Scatter(
|
| 79 |
-
x=bench.index, y=bench.to_numpy(), name=
|
| 80 |
line=dict(color=REFERENCE, width=2, dash="dash"),
|
| 81 |
-
hovertemplate=
|
| 82 |
)
|
| 83 |
)
|
| 84 |
fig.add_trace(
|
|
@@ -90,7 +90,7 @@ def equity_chart(report) -> go.Figure:
|
|
| 90 |
)
|
| 91 |
|
| 92 |
# Direct-label the two endpoints; the axis and tooltip carry everything else.
|
| 93 |
-
for series, color, label in ((strat, SUBJECT, report.strategy.name), (bench, REFERENCE,
|
| 94 |
fig.add_annotation(
|
| 95 |
x=series.index[-1], y=float(series.iloc[-1]),
|
| 96 |
text=f" {label}: {series.iloc[-1]:.0f}", showarrow=False,
|
|
@@ -203,14 +203,14 @@ def walkforward_chart(report) -> go.Figure:
|
|
| 203 |
return fig
|
| 204 |
|
| 205 |
|
| 206 |
-
def score_chart(verdict: Dict[str, object]) -> go.Figure:
|
| 207 |
"""The five components behind the Reality Score."""
|
| 208 |
components = verdict.get("components") or {}
|
| 209 |
if not components:
|
| 210 |
return empty_figure(height=260)
|
| 211 |
|
| 212 |
pretty = {
|
| 213 |
-
"significance":
|
| 214 |
"selection": "Survives selection bias",
|
| 215 |
"walk_forward": "Holds up walking forward",
|
| 216 |
"overfitting": "Not overfit (PBO)",
|
|
@@ -270,6 +270,113 @@ def arena_chart(table: pd.DataFrame) -> go.Figure:
|
|
| 270 |
return fig
|
| 271 |
|
| 272 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 273 |
def exposure_chart(report) -> go.Figure:
|
| 274 |
"""What the strategy was actually holding, over time."""
|
| 275 |
pos = report.backtest.position
|
|
|
|
| 67 |
return fig
|
| 68 |
|
| 69 |
|
| 70 |
+
def equity_chart(report, benchmark_label: str = "Buy & hold") -> go.Figure:
|
| 71 |
+
"""Strategy equity against its benchmark, both indexed to the same start."""
|
| 72 |
bt = report.backtest
|
| 73 |
strat = bt.equity / bt.equity.iloc[0] * 100.0
|
| 74 |
bench = bt.benchmark_equity / bt.benchmark_equity.iloc[0] * 100.0
|
|
|
|
| 76 |
fig = go.Figure()
|
| 77 |
fig.add_trace(
|
| 78 |
go.Scatter(
|
| 79 |
+
x=bench.index, y=bench.to_numpy(), name=benchmark_label, mode="lines",
|
| 80 |
line=dict(color=REFERENCE, width=2, dash="dash"),
|
| 81 |
+
hovertemplate=benchmark_label + " %{y:.1f}<extra></extra>",
|
| 82 |
)
|
| 83 |
)
|
| 84 |
fig.add_trace(
|
|
|
|
| 90 |
)
|
| 91 |
|
| 92 |
# Direct-label the two endpoints; the axis and tooltip carry everything else.
|
| 93 |
+
for series, color, label in ((strat, SUBJECT, report.strategy.name), (bench, REFERENCE, benchmark_label)):
|
| 94 |
fig.add_annotation(
|
| 95 |
x=series.index[-1], y=float(series.iloc[-1]),
|
| 96 |
text=f" {label}: {series.iloc[-1]:.0f}", showarrow=False,
|
|
|
|
| 203 |
return fig
|
| 204 |
|
| 205 |
|
| 206 |
+
def score_chart(verdict: Dict[str, object], significance_label: str = "Beats shuffled markets") -> go.Figure:
|
| 207 |
"""The five components behind the Reality Score."""
|
| 208 |
components = verdict.get("components") or {}
|
| 209 |
if not components:
|
| 210 |
return empty_figure(height=260)
|
| 211 |
|
| 212 |
pretty = {
|
| 213 |
+
"significance": significance_label,
|
| 214 |
"selection": "Survives selection bias",
|
| 215 |
"walk_forward": "Holds up walking forward",
|
| 216 |
"overfitting": "Not overfit (PBO)",
|
|
|
|
| 270 |
return fig
|
| 271 |
|
| 272 |
|
| 273 |
+
def cross_permutation_chart(report) -> go.Figure:
|
| 274 |
+
"""Sharpe against books of identical shape holding randomly chosen names."""
|
| 275 |
+
perm = report.permutation
|
| 276 |
+
if perm is None or perm.null.size == 0:
|
| 277 |
+
return empty_figure("Name-shuffle test was skipped.", height=320)
|
| 278 |
+
|
| 279 |
+
fig = go.Figure()
|
| 280 |
+
fig.add_trace(
|
| 281 |
+
go.Histogram(
|
| 282 |
+
x=perm.null, name="Same book, random names", nbinsx=40,
|
| 283 |
+
marker=dict(color="rgba(217,89,38,0.55)", line=dict(color=REFERENCE, width=1)),
|
| 284 |
+
hovertemplate="Sharpe %{x:.2f}<br>%{y} shuffles<extra></extra>",
|
| 285 |
+
)
|
| 286 |
+
)
|
| 287 |
+
top = np.histogram(perm.null, bins=40)[0].max() if perm.null.size else 1
|
| 288 |
+
fig.add_trace(
|
| 289 |
+
go.Scatter(
|
| 290 |
+
x=[perm.observed, perm.observed], y=[0, top * 1.08], mode="lines",
|
| 291 |
+
name="Your book", line=dict(color=SUBJECT, width=2),
|
| 292 |
+
hovertemplate="Your Sharpe %{x:.2f}<extra></extra>",
|
| 293 |
+
)
|
| 294 |
+
)
|
| 295 |
+
fig.add_annotation(
|
| 296 |
+
x=perm.observed, y=top * 1.08, text=f" your Sharpe {perm.observed:.2f}",
|
| 297 |
+
showarrow=False, xanchor="left", font=dict(color=SUBJECT, size=11),
|
| 298 |
+
)
|
| 299 |
+
beats = (perm.null >= perm.observed).mean() * 100.0
|
| 300 |
+
fig.update_layout(
|
| 301 |
+
**_base_layout(
|
| 302 |
+
f"Name-shuffle test — {beats:.0f}% of books with the same shape but random names "
|
| 303 |
+
f"did this well or better (p = {perm.p_value:.3f})",
|
| 304 |
+
height=320,
|
| 305 |
+
)
|
| 306 |
+
)
|
| 307 |
+
fig.update_layout(hovermode="closest", bargap=0.02)
|
| 308 |
+
fig.update_xaxes(title=dict(text="Annualised Sharpe ratio", font=dict(color=INK_MUTED, size=11)))
|
| 309 |
+
fig.update_yaxes(
|
| 310 |
+
title=dict(text="Shuffled books", font=dict(color=INK_MUTED, size=11)),
|
| 311 |
+
range=[0, top * 1.28],
|
| 312 |
+
)
|
| 313 |
+
return fig
|
| 314 |
+
|
| 315 |
+
|
| 316 |
+
def attribution_chart(attribution: Dict[str, object]) -> go.Figure:
|
| 317 |
+
"""Factor betas. One measure across categories, so one colour."""
|
| 318 |
+
if not attribution or not attribution.get("available"):
|
| 319 |
+
return empty_figure((attribution or {}).get("note", _EMPTY_NOTE), height=280)
|
| 320 |
+
|
| 321 |
+
betas = attribution.get("betas") or {}
|
| 322 |
+
if not betas:
|
| 323 |
+
return empty_figure("No factor exposures to show.", height=280)
|
| 324 |
+
|
| 325 |
+
names = list(betas)
|
| 326 |
+
values = [betas[n] for n in names]
|
| 327 |
+
fig = go.Figure(
|
| 328 |
+
go.Bar(
|
| 329 |
+
x=values, y=[n.replace("_", " ") for n in names], orientation="h",
|
| 330 |
+
marker=dict(color=SUBJECT, line=dict(color=SURFACE, width=2)),
|
| 331 |
+
text=[f"{v:+.2f}" for v in values], textposition="outside",
|
| 332 |
+
textfont=dict(color=INK_SECONDARY, size=11),
|
| 333 |
+
hovertemplate="%{y} beta %{x:.2f}<extra></extra>",
|
| 334 |
+
)
|
| 335 |
+
)
|
| 336 |
+
alpha = attribution.get("alpha_annual", 0.0)
|
| 337 |
+
t_stat = attribution.get("alpha_t_stat", 0.0)
|
| 338 |
+
fig.update_layout(
|
| 339 |
+
**_base_layout(
|
| 340 |
+
f"Style exposure — alpha {alpha:+.1%}/yr (t = {t_stat:.1f}), "
|
| 341 |
+
f"R² {attribution.get('r_squared', 0):.0%}",
|
| 342 |
+
height=280,
|
| 343 |
+
showlegend=False,
|
| 344 |
+
)
|
| 345 |
+
)
|
| 346 |
+
fig.update_layout(margin=dict(l=120, r=88, t=48, b=32), hovermode="closest")
|
| 347 |
+
# Outside labels need room or the widest beta reads as "+0".
|
| 348 |
+
span = max(abs(min(values)), abs(max(values)), 0.1)
|
| 349 |
+
fig.update_xaxes(range=[min(0, min(values)) - 0.25 * span, max(0, max(values)) + 0.35 * span])
|
| 350 |
+
fig.add_vline(x=0, line=dict(color=AXIS, width=1))
|
| 351 |
+
return fig
|
| 352 |
+
|
| 353 |
+
|
| 354 |
+
def weights_chart(report) -> go.Figure:
|
| 355 |
+
"""Gross and net exposure over time — is the book actually neutral?"""
|
| 356 |
+
held = report.backtest.held
|
| 357 |
+
gross = held.abs().sum(axis=1)
|
| 358 |
+
net = held.sum(axis=1)
|
| 359 |
+
|
| 360 |
+
fig = go.Figure()
|
| 361 |
+
fig.add_trace(
|
| 362 |
+
go.Scatter(
|
| 363 |
+
x=gross.index, y=gross.to_numpy(), name="Gross", mode="lines",
|
| 364 |
+
line=dict(color=REFERENCE, width=2, dash="dash"),
|
| 365 |
+
hovertemplate="Gross %{y:.2f}x<extra></extra>",
|
| 366 |
+
)
|
| 367 |
+
)
|
| 368 |
+
fig.add_trace(
|
| 369 |
+
go.Scatter(
|
| 370 |
+
x=net.index, y=net.to_numpy(), name="Net", mode="lines",
|
| 371 |
+
line=dict(color=SUBJECT, width=2),
|
| 372 |
+
hovertemplate="Net %{y:.2f}x<extra></extra>",
|
| 373 |
+
)
|
| 374 |
+
)
|
| 375 |
+
fig.update_layout(**_base_layout("Book exposure", height=240))
|
| 376 |
+
fig.add_hline(y=0, line=dict(color=AXIS, width=1))
|
| 377 |
+
return fig
|
| 378 |
+
|
| 379 |
+
|
| 380 |
def exposure_chart(report) -> go.Figure:
|
| 381 |
"""What the strategy was actually holding, over time."""
|
| 382 |
pos = report.backtest.position
|
|
@@ -138,12 +138,92 @@ def _cmd_arena(args: argparse.Namespace) -> int:
|
|
| 138 |
return 0
|
| 139 |
|
| 140 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 141 |
def _cmd_strategies(args: argparse.Namespace) -> int:
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
print(f" {''
|
| 146 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 147 |
return 0
|
| 148 |
|
| 149 |
|
|
@@ -174,6 +254,25 @@ def main(argv: List[str] | None = None) -> int:
|
|
| 174 |
arena.add_argument("--quiet", "-q", action="store_true")
|
| 175 |
arena.set_defaults(func=_cmd_arena)
|
| 176 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 177 |
listing = sub.add_parser("strategies", help="List the strategy zoo.")
|
| 178 |
listing.set_defaults(func=_cmd_strategies)
|
| 179 |
|
|
|
|
| 138 |
return 0
|
| 139 |
|
| 140 |
|
| 141 |
+
def _cmd_portfolio(args: argparse.Namespace) -> int:
|
| 142 |
+
from .portfolio_lab import DEFAULT_UNIVERSE, PortfolioLabConfig, run_portfolio_lab
|
| 143 |
+
|
| 144 |
+
symbols = [s.strip() for s in args.symbols.split(",") if s.strip()] if args.symbols else DEFAULT_UNIVERSE
|
| 145 |
+
cfg = PortfolioLabConfig(
|
| 146 |
+
symbols=symbols,
|
| 147 |
+
start=args.start,
|
| 148 |
+
end=args.end,
|
| 149 |
+
interval=args.interval,
|
| 150 |
+
source=args.source,
|
| 151 |
+
strategy=args.strategy,
|
| 152 |
+
params=_parse_params(args.param),
|
| 153 |
+
commission_bps=args.commission_bps,
|
| 154 |
+
slippage_bps=args.slippage_bps,
|
| 155 |
+
allow_short=not args.no_short,
|
| 156 |
+
rebalance=args.rebalance,
|
| 157 |
+
n_permutations=args.permutations,
|
| 158 |
+
wf_folds=args.folds,
|
| 159 |
+
)
|
| 160 |
+
progress = None if args.quiet else (lambda f, m: print(f" [{f:5.0%}] {m}", file=sys.stderr))
|
| 161 |
+
report = run_portfolio_lab(cfg, progress=progress)
|
| 162 |
+
|
| 163 |
+
if args.json:
|
| 164 |
+
print(json.dumps({
|
| 165 |
+
"symbols": report.panel.symbols,
|
| 166 |
+
"strategy": report.strategy.key,
|
| 167 |
+
"params": report.params,
|
| 168 |
+
"metrics": report.backtest.metrics,
|
| 169 |
+
"p_value": report.permutation.p_value if report.permutation else None,
|
| 170 |
+
"deflated_sharpe": report.dsr.get("dsr"),
|
| 171 |
+
"pbo": report.pbo.get("pbo"),
|
| 172 |
+
"walkforward_efficiency": report.walkforward.get("efficiency"),
|
| 173 |
+
"attribution": report.attribution,
|
| 174 |
+
"survivorship": report.survivorship.__dict__,
|
| 175 |
+
"verdict": dict(report.verdict),
|
| 176 |
+
}, indent=2, default=str))
|
| 177 |
+
return 0
|
| 178 |
+
|
| 179 |
+
v, m, b = report.verdict, report.backtest.metrics, report.backtest.benchmark_metrics
|
| 180 |
+
bar = "=" * 72
|
| 181 |
+
print(f"\n{bar}")
|
| 182 |
+
print(f" {report.strategy.name} on {len(report.panel.symbols)} symbols [{report.panel.interval}]")
|
| 183 |
+
print(f" {report.panel.index[0].date()} to {report.panel.index[-1].date()} · "
|
| 184 |
+
f"{len(report.panel):,} bars · rebalance {report.config.rebalance}")
|
| 185 |
+
print(bar)
|
| 186 |
+
print(f" REALITY SCORE {v['score']:.1f} / 100 GRADE {v['grade']}")
|
| 187 |
+
print(f" {v['headline']}")
|
| 188 |
+
print(bar)
|
| 189 |
+
print(f" Total return {m['total_return']:>9.1%} equal weight {b['total_return']:>8.1%}")
|
| 190 |
+
print(f" CAGR {m['cagr']:>9.1%} equal weight {b['cagr']:>8.1%}")
|
| 191 |
+
print(f" Sharpe {m['sharpe']:>9.2f} equal weight {b['sharpe']:>8.2f}")
|
| 192 |
+
print(f" Max drawdown {m['max_drawdown']:>9.1%}")
|
| 193 |
+
print(f" Gross / net exp. {m.get('gross_exposure', 0):>9.2f} / {m.get('net_exposure', 0):.2f}")
|
| 194 |
+
print(f" Avg positions {m.get('avg_positions', 0):>9.1f} turnover {m.get('turnover_ann', 0):.1f}x/yr")
|
| 195 |
+
print(bar)
|
| 196 |
+
if report.permutation:
|
| 197 |
+
print(f" Name-shuffle p {report.permutation.p_value:>9.3f} "
|
| 198 |
+
f"({report.permutation.n_permutations} shuffles of which names got which weights)")
|
| 199 |
+
print(f" Deflated Sharpe {report.dsr.get('dsr', 0):>9.2f} (after {report.trials.get('n', 1)} variants)")
|
| 200 |
+
pbo = report.pbo.get("pbo")
|
| 201 |
+
print(f" Overfit prob. {pbo:>9.2f}" if pbo == pbo else " Overfit prob. n/a")
|
| 202 |
+
print(f" Walk-forward eff. {report.walkforward.get('efficiency', 0):>9.2f}")
|
| 203 |
+
if report.attribution.get("available"):
|
| 204 |
+
print(f" Style alpha {report.attribution['alpha_annual']:>9.1%} "
|
| 205 |
+
f"t = {report.attribution['alpha_t_stat']:.2f}, R² = {report.attribution['r_squared']:.2f}")
|
| 206 |
+
print(f" Survivorship {report.survivorship.survival_rate:>9.0%} "
|
| 207 |
+
f"({report.survivorship.n_delisted} of {report.survivorship.n_symbols} stopped trading)")
|
| 208 |
+
print(bar)
|
| 209 |
+
for flag in v["flags"]:
|
| 210 |
+
print(f" ! {flag}")
|
| 211 |
+
if v["flags"]:
|
| 212 |
+
print(bar)
|
| 213 |
+
print(f" {v['verdict']}\n")
|
| 214 |
+
return 0
|
| 215 |
+
|
| 216 |
+
|
| 217 |
def _cmd_strategies(args: argparse.Namespace) -> int:
|
| 218 |
+
from .cross_sectional import XS_REGISTRY
|
| 219 |
+
|
| 220 |
+
for title, registry in (("Single asset", REGISTRY), ("Cross-sectional", XS_REGISTRY)):
|
| 221 |
+
print(f"\n {title}\n {'-' * len(title)}")
|
| 222 |
+
for key, strategy in registry.items():
|
| 223 |
+
params = ", ".join(f"{p.name}={p.default:g}" for p in strategy.params) or "no parameters"
|
| 224 |
+
print(f" {key:<22} {strategy.name:<28} [{strategy.family}]")
|
| 225 |
+
print(f" {'':<22} {strategy.description}")
|
| 226 |
+
print(f" {'':<22} defaults: {params}\n")
|
| 227 |
return 0
|
| 228 |
|
| 229 |
|
|
|
|
| 254 |
arena.add_argument("--quiet", "-q", action="store_true")
|
| 255 |
arena.set_defaults(func=_cmd_arena)
|
| 256 |
|
| 257 |
+
from .cross_sectional import XS_REGISTRY
|
| 258 |
+
|
| 259 |
+
portfolio = sub.add_parser(
|
| 260 |
+
"portfolio", help="Reality check for a cross-sectional (multi-asset) strategy."
|
| 261 |
+
)
|
| 262 |
+
_add_common(portfolio)
|
| 263 |
+
portfolio.add_argument(
|
| 264 |
+
"--symbols", default=None,
|
| 265 |
+
help="Comma-separated universe, e.g. SPY,QQQ,AAPL. Defaults to a 12-name universe.",
|
| 266 |
+
)
|
| 267 |
+
portfolio.add_argument("--strategy", default="xs_momentum", choices=sorted(XS_REGISTRY))
|
| 268 |
+
portfolio.add_argument("--param", action="append", metavar="NAME=VALUE")
|
| 269 |
+
portfolio.add_argument("--rebalance", default="M", help="D, W, M, Q, or a number of bars.")
|
| 270 |
+
portfolio.add_argument("--permutations", type=int, default=150)
|
| 271 |
+
portfolio.add_argument("--folds", type=int, default=4)
|
| 272 |
+
portfolio.add_argument("--json", action="store_true")
|
| 273 |
+
portfolio.add_argument("--quiet", "-q", action="store_true")
|
| 274 |
+
portfolio.set_defaults(func=_cmd_portfolio)
|
| 275 |
+
|
| 276 |
listing = sub.add_parser("strategies", help="List the strategy zoo.")
|
| 277 |
listing.set_defaults(func=_cmd_strategies)
|
| 278 |
|
|
@@ -0,0 +1,290 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Cross-sectional strategies.
|
| 2 |
+
|
| 3 |
+
Where a single-asset rule asks "should I be long this thing?", a
|
| 4 |
+
cross-sectional rule asks "which of these things should I be long, and which
|
| 5 |
+
short?". That difference matters for validation: a long-short book that ranks
|
| 6 |
+
names is exposed to entirely different failure modes than a timing rule, and it
|
| 7 |
+
needs its own null (see :mod:`algotrader.validation.cross_permutation`).
|
| 8 |
+
|
| 9 |
+
Each strategy is a pure function ``(panel, **params) -> T x N weights``, causal
|
| 10 |
+
by construction, with gross exposure of at most 1.
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
import itertools
|
| 16 |
+
from dataclasses import dataclass
|
| 17 |
+
from typing import Callable, Dict, Iterable, List
|
| 18 |
+
|
| 19 |
+
import numpy as np
|
| 20 |
+
import pandas as pd
|
| 21 |
+
|
| 22 |
+
from .panel import Panel
|
| 23 |
+
from .strategies import ParamSpec
|
| 24 |
+
|
| 25 |
+
__all__ = [
|
| 26 |
+
"CrossSectionalStrategy",
|
| 27 |
+
"XS_REGISTRY",
|
| 28 |
+
"get_xs_strategy",
|
| 29 |
+
"list_xs_strategies",
|
| 30 |
+
"scores_to_weights",
|
| 31 |
+
]
|
| 32 |
+
|
| 33 |
+
MIN_NAMES = 4 # below this, "cross-section" is not a meaningful word
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def scores_to_weights(
|
| 37 |
+
scores: pd.DataFrame,
|
| 38 |
+
long_frac: float = 0.3,
|
| 39 |
+
short_frac: float = 0.3,
|
| 40 |
+
long_only: bool = False,
|
| 41 |
+
min_names: int = MIN_NAMES,
|
| 42 |
+
) -> pd.DataFrame:
|
| 43 |
+
"""Turn a score matrix into a dollar-neutral (or long-only) weight matrix.
|
| 44 |
+
|
| 45 |
+
Ranks within each date, takes the top and bottom fractions, and equal-weights
|
| 46 |
+
each leg. Gross exposure is 1: half per leg when short-selling, all of it in
|
| 47 |
+
the long leg otherwise.
|
| 48 |
+
"""
|
| 49 |
+
valid = scores.notna()
|
| 50 |
+
counts = valid.sum(axis=1)
|
| 51 |
+
ranks = scores.rank(axis=1, pct=True, na_option="keep")
|
| 52 |
+
|
| 53 |
+
longs = (ranks > 1.0 - long_frac) & valid
|
| 54 |
+
n_long = longs.sum(axis=1).replace(0, np.nan)
|
| 55 |
+
|
| 56 |
+
if long_only:
|
| 57 |
+
weights = longs.astype(float).div(n_long, axis=0)
|
| 58 |
+
else:
|
| 59 |
+
shorts = (ranks <= short_frac) & valid
|
| 60 |
+
n_short = shorts.sum(axis=1).replace(0, np.nan)
|
| 61 |
+
weights = (
|
| 62 |
+
longs.astype(float).div(n_long, axis=0) * 0.5
|
| 63 |
+
- shorts.astype(float).div(n_short, axis=0) * 0.5
|
| 64 |
+
)
|
| 65 |
+
|
| 66 |
+
# A cross-section of two names is not a cross-section.
|
| 67 |
+
weights = weights.where(counts >= min_names, 0.0)
|
| 68 |
+
return weights.fillna(0.0)
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def _rebalance_hold(weights: pd.DataFrame, every: int) -> pd.DataFrame:
|
| 72 |
+
"""Refresh the target only every ``every`` bars, holding it in between."""
|
| 73 |
+
if every <= 1:
|
| 74 |
+
return weights
|
| 75 |
+
out = weights.copy()
|
| 76 |
+
keep = np.zeros(len(out), dtype=bool)
|
| 77 |
+
keep[::every] = True
|
| 78 |
+
out.iloc[~keep] = np.nan
|
| 79 |
+
return out.ffill().fillna(0.0)
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
# --------------------------------------------------------------------------
|
| 83 |
+
# Strategy implementations
|
| 84 |
+
# --------------------------------------------------------------------------
|
| 85 |
+
|
| 86 |
+
def _xs_momentum(
|
| 87 |
+
panel: Panel, lookback: int = 250, skip: int = 20, rebalance: int = 21, long_frac: float = 0.3
|
| 88 |
+
) -> pd.DataFrame:
|
| 89 |
+
"""Classic 12-1 momentum: rank on past return, skipping the most recent month.
|
| 90 |
+
|
| 91 |
+
The skip is not decoration -- including the last month mixes in short-term
|
| 92 |
+
reversal, which points the other way and muddies the signal.
|
| 93 |
+
"""
|
| 94 |
+
close = panel.close
|
| 95 |
+
scores = close.shift(skip) / close.shift(lookback) - 1.0
|
| 96 |
+
return _rebalance_hold(scores_to_weights(scores, long_frac, long_frac), rebalance)
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def _xs_reversal(
|
| 100 |
+
panel: Panel, lookback: int = 5, rebalance: int = 5, long_frac: float = 0.3
|
| 101 |
+
) -> pd.DataFrame:
|
| 102 |
+
"""Short-term reversal: buy the recent losers, sell the recent winners."""
|
| 103 |
+
scores = -(panel.close.pct_change(lookback))
|
| 104 |
+
return _rebalance_hold(scores_to_weights(scores, long_frac, long_frac), rebalance)
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def _low_volatility(
|
| 108 |
+
panel: Panel, window: int = 60, rebalance: int = 21, long_frac: float = 0.3
|
| 109 |
+
) -> pd.DataFrame:
|
| 110 |
+
"""The low-volatility anomaly: long the calm names, short the wild ones."""
|
| 111 |
+
scores = -(panel.close.pct_change().rolling(window, min_periods=window).std())
|
| 112 |
+
return _rebalance_hold(scores_to_weights(scores, long_frac, long_frac), rebalance)
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
def _xs_value_proxy(
|
| 116 |
+
panel: Panel, window: int = 250, rebalance: int = 21, long_frac: float = 0.3
|
| 117 |
+
) -> pd.DataFrame:
|
| 118 |
+
"""Distance below the long-run average price, as a crude cheapness proxy.
|
| 119 |
+
|
| 120 |
+
This is not book-to-market -- there are no fundamentals in the panel -- so
|
| 121 |
+
treat it as mean reversion over a long horizon rather than value investing.
|
| 122 |
+
"""
|
| 123 |
+
close = panel.close
|
| 124 |
+
scores = -(close / close.rolling(window, min_periods=window).mean() - 1.0)
|
| 125 |
+
return _rebalance_hold(scores_to_weights(scores, long_frac, long_frac), rebalance)
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
def _equal_weight(panel: Panel, rebalance: int = 21) -> pd.DataFrame:
|
| 129 |
+
"""Own everything tradable, equally. The bar a stock picker has to clear."""
|
| 130 |
+
listed = panel.close.notna()
|
| 131 |
+
counts = listed.sum(axis=1).replace(0, np.nan)
|
| 132 |
+
return _rebalance_hold(listed.astype(float).div(counts, axis=0).fillna(0.0), rebalance)
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
def _xs_random(panel: Panel, rebalance: int = 21, seed: int = 7) -> pd.DataFrame:
|
| 136 |
+
"""Random long-short book. The control group for cross-sectional claims."""
|
| 137 |
+
rng = np.random.default_rng(int(seed))
|
| 138 |
+
scores = pd.DataFrame(
|
| 139 |
+
rng.standard_normal(panel.close.shape), index=panel.index, columns=panel.symbols
|
| 140 |
+
).where(panel.close.notna())
|
| 141 |
+
return _rebalance_hold(scores_to_weights(scores), rebalance)
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
@dataclass(frozen=True)
|
| 145 |
+
class CrossSectionalStrategy:
|
| 146 |
+
key: str
|
| 147 |
+
name: str
|
| 148 |
+
family: str
|
| 149 |
+
description: str
|
| 150 |
+
fn: Callable[..., pd.DataFrame]
|
| 151 |
+
params: tuple = ()
|
| 152 |
+
|
| 153 |
+
def defaults(self) -> Dict[str, float]:
|
| 154 |
+
return {p.name: p.cast(p.default) for p in self.params}
|
| 155 |
+
|
| 156 |
+
def clean(self, params: Dict[str, float] | None) -> Dict[str, float]:
|
| 157 |
+
merged = self.defaults()
|
| 158 |
+
for spec in self.params:
|
| 159 |
+
if params and spec.name in params and params[spec.name] is not None:
|
| 160 |
+
merged[spec.name] = spec.cast(params[spec.name])
|
| 161 |
+
return merged
|
| 162 |
+
|
| 163 |
+
def generate(self, panel: Panel, params: Dict[str, float] | None = None) -> pd.DataFrame:
|
| 164 |
+
weights = self.fn(panel, **self.clean(params))
|
| 165 |
+
return (
|
| 166 |
+
weights.reindex(index=panel.index, columns=panel.symbols)
|
| 167 |
+
.astype(float)
|
| 168 |
+
.fillna(0.0)
|
| 169 |
+
.clip(-1.0, 1.0)
|
| 170 |
+
)
|
| 171 |
+
|
| 172 |
+
def grid(self, limit: int | None = None) -> List[Dict[str, float]]:
|
| 173 |
+
if not self.params:
|
| 174 |
+
return [{}]
|
| 175 |
+
names = [p.name for p in self.params]
|
| 176 |
+
combos = [dict(zip(names, v)) for v in itertools.product(*[p.grid for p in self.params])]
|
| 177 |
+
combos = [c for c in combos if not ("skip" in c and "lookback" in c and c["skip"] >= c["lookback"])]
|
| 178 |
+
if limit is not None and len(combos) > limit:
|
| 179 |
+
step = len(combos) / limit
|
| 180 |
+
combos = [combos[int(i * step)] for i in range(limit)]
|
| 181 |
+
return combos
|
| 182 |
+
|
| 183 |
+
|
| 184 |
+
XS_REGISTRY: Dict[str, CrossSectionalStrategy] = {}
|
| 185 |
+
|
| 186 |
+
|
| 187 |
+
def _register(strategy: CrossSectionalStrategy) -> CrossSectionalStrategy:
|
| 188 |
+
XS_REGISTRY[strategy.key] = strategy
|
| 189 |
+
return strategy
|
| 190 |
+
|
| 191 |
+
|
| 192 |
+
_register(
|
| 193 |
+
CrossSectionalStrategy(
|
| 194 |
+
key="equal_weight",
|
| 195 |
+
name="Equal Weight",
|
| 196 |
+
family="benchmark",
|
| 197 |
+
description="Own every name equally. The bar a stock picker has to clear.",
|
| 198 |
+
fn=_equal_weight,
|
| 199 |
+
params=(ParamSpec("rebalance", "Rebalance (bars)", 21, (5, 21, 63), "int", 1, 252, 1),),
|
| 200 |
+
)
|
| 201 |
+
)
|
| 202 |
+
|
| 203 |
+
_register(
|
| 204 |
+
CrossSectionalStrategy(
|
| 205 |
+
key="xs_momentum",
|
| 206 |
+
name="Cross-Sectional Momentum",
|
| 207 |
+
family="momentum",
|
| 208 |
+
description="Long the past winners, short the past losers, skipping the most recent month.",
|
| 209 |
+
fn=_xs_momentum,
|
| 210 |
+
params=(
|
| 211 |
+
ParamSpec("lookback", "Lookback", 250, (60, 120, 250), "int", 20, 750, 1),
|
| 212 |
+
ParamSpec("skip", "Skip recent", 20, (0, 5, 20), "int", 0, 60, 1),
|
| 213 |
+
ParamSpec("rebalance", "Rebalance (bars)", 21, (5, 21, 63), "int", 1, 252, 1),
|
| 214 |
+
ParamSpec("long_frac", "Leg size", 0.3, (0.1, 0.2, 0.3), "float", 0.05, 0.5, 0.05),
|
| 215 |
+
),
|
| 216 |
+
)
|
| 217 |
+
)
|
| 218 |
+
|
| 219 |
+
_register(
|
| 220 |
+
CrossSectionalStrategy(
|
| 221 |
+
key="xs_reversal",
|
| 222 |
+
name="Short-Term Reversal",
|
| 223 |
+
family="mean-reversion",
|
| 224 |
+
description="Buy this week's losers and sell its winners.",
|
| 225 |
+
fn=_xs_reversal,
|
| 226 |
+
params=(
|
| 227 |
+
ParamSpec("lookback", "Lookback", 5, (1, 3, 5, 10, 21), "int", 1, 60, 1),
|
| 228 |
+
ParamSpec("rebalance", "Rebalance (bars)", 5, (1, 5, 21), "int", 1, 252, 1),
|
| 229 |
+
ParamSpec("long_frac", "Leg size", 0.3, (0.1, 0.2, 0.3), "float", 0.05, 0.5, 0.05),
|
| 230 |
+
),
|
| 231 |
+
)
|
| 232 |
+
)
|
| 233 |
+
|
| 234 |
+
_register(
|
| 235 |
+
CrossSectionalStrategy(
|
| 236 |
+
key="low_volatility",
|
| 237 |
+
name="Low Volatility",
|
| 238 |
+
family="risk",
|
| 239 |
+
description="Long the calm names, short the volatile ones.",
|
| 240 |
+
fn=_low_volatility,
|
| 241 |
+
params=(
|
| 242 |
+
ParamSpec("window", "Vol window", 60, (20, 60, 120), "int", 5, 252, 1),
|
| 243 |
+
ParamSpec("rebalance", "Rebalance (bars)", 21, (5, 21, 63), "int", 1, 252, 1),
|
| 244 |
+
ParamSpec("long_frac", "Leg size", 0.3, (0.1, 0.2, 0.3), "float", 0.05, 0.5, 0.05),
|
| 245 |
+
),
|
| 246 |
+
)
|
| 247 |
+
)
|
| 248 |
+
|
| 249 |
+
_register(
|
| 250 |
+
CrossSectionalStrategy(
|
| 251 |
+
key="xs_value_proxy",
|
| 252 |
+
name="Long-Horizon Reversion",
|
| 253 |
+
family="value-ish",
|
| 254 |
+
description="Long names trading below their long-run average, short those above.",
|
| 255 |
+
fn=_xs_value_proxy,
|
| 256 |
+
params=(
|
| 257 |
+
ParamSpec("window", "Window", 250, (120, 250, 500), "int", 30, 1000, 1),
|
| 258 |
+
ParamSpec("rebalance", "Rebalance (bars)", 21, (5, 21, 63), "int", 1, 252, 1),
|
| 259 |
+
ParamSpec("long_frac", "Leg size", 0.3, (0.1, 0.2, 0.3), "float", 0.05, 0.5, 0.05),
|
| 260 |
+
),
|
| 261 |
+
)
|
| 262 |
+
)
|
| 263 |
+
|
| 264 |
+
_register(
|
| 265 |
+
CrossSectionalStrategy(
|
| 266 |
+
key="xs_random",
|
| 267 |
+
name="Random Book (control)",
|
| 268 |
+
family="control",
|
| 269 |
+
description="Random long-short positions. Anything that cannot beat this is noise.",
|
| 270 |
+
fn=_xs_random,
|
| 271 |
+
params=(
|
| 272 |
+
ParamSpec("rebalance", "Rebalance (bars)", 21, (5, 21, 63), "int", 1, 252, 1),
|
| 273 |
+
ParamSpec("seed", "Seed", 7, (1, 7, 42, 123), "int", 0, 9999, 1),
|
| 274 |
+
),
|
| 275 |
+
)
|
| 276 |
+
)
|
| 277 |
+
|
| 278 |
+
|
| 279 |
+
def get_xs_strategy(key: str) -> CrossSectionalStrategy:
|
| 280 |
+
try:
|
| 281 |
+
return XS_REGISTRY[key]
|
| 282 |
+
except KeyError:
|
| 283 |
+
raise KeyError(
|
| 284 |
+
f"Unknown cross-sectional strategy '{key}'. Available: {', '.join(sorted(XS_REGISTRY))}"
|
| 285 |
+
) from None
|
| 286 |
+
|
| 287 |
+
|
| 288 |
+
def list_xs_strategies(exclude: Iterable[str] = ()) -> List[CrossSectionalStrategy]:
|
| 289 |
+
skip = set(exclude)
|
| 290 |
+
return [s for k, s in XS_REGISTRY.items() if k not in skip]
|
|
@@ -62,8 +62,15 @@ def run_backtest(
|
|
| 62 |
|
| 63 |
gross = position * asset_ret
|
| 64 |
|
| 65 |
-
|
| 66 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 67 |
trade_cost = traded.abs() * (costs.one_way_bps / 1e4)
|
| 68 |
|
| 69 |
borrow_cost = position.clip(upper=0.0).abs() * (costs.short_borrow_bps / 1e4) / ppy
|
|
|
|
| 62 |
|
| 63 |
gross = position * asset_ret
|
| 64 |
|
| 65 |
+
# Turnover is measured against the *drifted* weight, not the previous
|
| 66 |
+
# target. Holding a full-notional long needs no rebalancing (the position
|
| 67 |
+
# and the portfolio grow together), but a short does: lose 10% on a 100%
|
| 68 |
+
# short and the weight drifts to -82%, so staying at -100% costs a trade.
|
| 69 |
+
# See portfolio.py for the same formula in matrix form.
|
| 70 |
+
growth = (1.0 + gross).replace(0.0, np.nan)
|
| 71 |
+
drifted = (position * (1.0 + asset_ret)) / growth
|
| 72 |
+
previous = drifted.shift(1).fillna(0.0)
|
| 73 |
+
traded = position - previous
|
| 74 |
trade_cost = traded.abs() * (costs.one_way_bps / 1e4)
|
| 75 |
|
| 76 |
borrow_cost = position.clip(upper=0.0).abs() * (costs.short_borrow_bps / 1e4) / ppy
|
|
@@ -0,0 +1,283 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Multi-asset price panels.
|
| 2 |
+
|
| 3 |
+
A :class:`Panel` is a set of aligned ``T x N`` frames -- one per OHLCV field,
|
| 4 |
+
one column per symbol. That is the shape cross-sectional work actually needs,
|
| 5 |
+
and it is what the portfolio engine consumes.
|
| 6 |
+
|
| 7 |
+
The important design choice here is that **missing data stays missing**. It is
|
| 8 |
+
tempting to forward-fill a symbol through the days it did not trade, but that
|
| 9 |
+
invents liquidity that never existed and quietly lets a strategy hold a
|
| 10 |
+
delisted stock forever. Instead the panel tracks exactly when each symbol was
|
| 11 |
+
tradable, which is also what makes survivorship measurable rather than assumed.
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
from dataclasses import dataclass, field
|
| 17 |
+
from typing import Dict, Iterable, List, Mapping, Optional, Sequence
|
| 18 |
+
|
| 19 |
+
import numpy as np
|
| 20 |
+
import pandas as pd
|
| 21 |
+
|
| 22 |
+
from .data import load_ohlcv
|
| 23 |
+
from .types import OHLCV_COLUMNS
|
| 24 |
+
|
| 25 |
+
__all__ = ["Panel", "load_panel", "SurvivorshipReport"]
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
@dataclass(frozen=True)
|
| 29 |
+
class SurvivorshipReport:
|
| 30 |
+
"""How much of this universe is made of winners we already know survived."""
|
| 31 |
+
|
| 32 |
+
n_symbols: int
|
| 33 |
+
n_alive_at_end: int
|
| 34 |
+
n_delisted: int
|
| 35 |
+
delisted_symbols: List[str]
|
| 36 |
+
late_starters: List[str]
|
| 37 |
+
survival_rate: float
|
| 38 |
+
biased: bool
|
| 39 |
+
note: str
|
| 40 |
+
|
| 41 |
+
def as_flag(self) -> Optional[str]:
|
| 42 |
+
return self.note if self.biased else None
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
@dataclass(frozen=True)
|
| 46 |
+
class Panel:
|
| 47 |
+
"""Aligned multi-asset OHLCV."""
|
| 48 |
+
|
| 49 |
+
fields: Mapping[str, pd.DataFrame]
|
| 50 |
+
sources: Mapping[str, str] = field(default_factory=dict)
|
| 51 |
+
interval: str = "1d"
|
| 52 |
+
note: str = ""
|
| 53 |
+
|
| 54 |
+
def __post_init__(self) -> None:
|
| 55 |
+
missing = [c for c in OHLCV_COLUMNS if c not in self.fields]
|
| 56 |
+
if missing:
|
| 57 |
+
raise ValueError(f"Panel is missing field(s): {', '.join(missing)}")
|
| 58 |
+
reference = self.fields["close"]
|
| 59 |
+
for name, frame in self.fields.items():
|
| 60 |
+
if not frame.index.equals(reference.index) or list(frame.columns) != list(reference.columns):
|
| 61 |
+
raise ValueError(f"Panel field '{name}' is not aligned with 'close'")
|
| 62 |
+
|
| 63 |
+
# -- accessors ---------------------------------------------------------
|
| 64 |
+
@property
|
| 65 |
+
def close(self) -> pd.DataFrame:
|
| 66 |
+
return self.fields["close"]
|
| 67 |
+
|
| 68 |
+
@property
|
| 69 |
+
def open(self) -> pd.DataFrame:
|
| 70 |
+
return self.fields["open"]
|
| 71 |
+
|
| 72 |
+
@property
|
| 73 |
+
def high(self) -> pd.DataFrame:
|
| 74 |
+
return self.fields["high"]
|
| 75 |
+
|
| 76 |
+
@property
|
| 77 |
+
def low(self) -> pd.DataFrame:
|
| 78 |
+
return self.fields["low"]
|
| 79 |
+
|
| 80 |
+
@property
|
| 81 |
+
def volume(self) -> pd.DataFrame:
|
| 82 |
+
return self.fields["volume"]
|
| 83 |
+
|
| 84 |
+
@property
|
| 85 |
+
def symbols(self) -> List[str]:
|
| 86 |
+
return list(self.close.columns)
|
| 87 |
+
|
| 88 |
+
@property
|
| 89 |
+
def index(self) -> pd.DatetimeIndex:
|
| 90 |
+
return self.close.index
|
| 91 |
+
|
| 92 |
+
@property
|
| 93 |
+
def is_real(self) -> bool:
|
| 94 |
+
return all(s in ("yfinance", "bundled") for s in self.sources.values())
|
| 95 |
+
|
| 96 |
+
def __len__(self) -> int:
|
| 97 |
+
return len(self.close)
|
| 98 |
+
|
| 99 |
+
@property
|
| 100 |
+
def shape(self) -> tuple:
|
| 101 |
+
return self.close.shape
|
| 102 |
+
|
| 103 |
+
# -- derived -----------------------------------------------------------
|
| 104 |
+
def returns(self) -> pd.DataFrame:
|
| 105 |
+
"""Per-asset close-to-close returns, NaN where the asset was untradable."""
|
| 106 |
+
rets = self.close.pct_change()
|
| 107 |
+
return rets.where(self.tradable())
|
| 108 |
+
|
| 109 |
+
def tradable(self) -> pd.DataFrame:
|
| 110 |
+
"""True where the asset had a price on this bar *and* the one before.
|
| 111 |
+
|
| 112 |
+
A position can only be held over a bar whose return is defined, so this
|
| 113 |
+
is the mask the engine uses to zero out impossible weights.
|
| 114 |
+
"""
|
| 115 |
+
listed = self.close.notna()
|
| 116 |
+
return listed & listed.shift(1, fill_value=False)
|
| 117 |
+
|
| 118 |
+
def dollar_volume(self) -> pd.DataFrame:
|
| 119 |
+
return (self.close * self.volume).where(self.close.notna())
|
| 120 |
+
|
| 121 |
+
def first_valid(self) -> pd.Series:
|
| 122 |
+
return self.close.apply(lambda col: col.first_valid_index())
|
| 123 |
+
|
| 124 |
+
def last_valid(self) -> pd.Series:
|
| 125 |
+
return self.close.apply(lambda col: col.last_valid_index())
|
| 126 |
+
|
| 127 |
+
# -- survivorship ------------------------------------------------------
|
| 128 |
+
def survivorship(self, tolerance_bars: int = 5) -> SurvivorshipReport:
|
| 129 |
+
"""Measure how many names survived to the end of the sample.
|
| 130 |
+
|
| 131 |
+
A universe picked today and backfilled contains only survivors, and
|
| 132 |
+
every backtest run on it is flattered by the companies that failed and
|
| 133 |
+
were quietly excluded. We cannot fix that here, but we can refuse to
|
| 134 |
+
hide it: if every single name is still trading at the end of a long
|
| 135 |
+
sample, that is itself the evidence.
|
| 136 |
+
"""
|
| 137 |
+
if not len(self):
|
| 138 |
+
return SurvivorshipReport(0, 0, 0, [], [], 1.0, False, "Empty panel.")
|
| 139 |
+
|
| 140 |
+
last = self.last_valid()
|
| 141 |
+
first = self.first_valid()
|
| 142 |
+
end = self.index[-1]
|
| 143 |
+
start = self.index[0]
|
| 144 |
+
cutoff = self.index[max(0, len(self) - 1 - tolerance_bars)]
|
| 145 |
+
entry_cutoff = self.index[min(len(self) - 1, tolerance_bars)]
|
| 146 |
+
|
| 147 |
+
delisted = sorted(str(s) for s in last.index[last < cutoff])
|
| 148 |
+
late = sorted(str(s) for s in first.index[first > entry_cutoff])
|
| 149 |
+
n = len(self.symbols)
|
| 150 |
+
alive = n - len(delisted)
|
| 151 |
+
rate = alive / n if n else 1.0
|
| 152 |
+
|
| 153 |
+
years = len(self) / 252.0
|
| 154 |
+
biased = rate >= 1.0 and years >= 3 and n >= 5
|
| 155 |
+
if biased:
|
| 156 |
+
note = (
|
| 157 |
+
f"All {n} symbols were still trading at the end of a {years:.1f}-year sample. "
|
| 158 |
+
"A universe with no failures in it was almost certainly chosen after the fact, "
|
| 159 |
+
"which means these results exclude every name that went to zero. Treat the "
|
| 160 |
+
"returns below as an upper bound."
|
| 161 |
+
)
|
| 162 |
+
elif n == 0:
|
| 163 |
+
note = "Empty panel."
|
| 164 |
+
else:
|
| 165 |
+
note = (
|
| 166 |
+
f"{len(delisted)} of {n} symbols stopped trading before the end of the sample "
|
| 167 |
+
f"({rate:.0%} survived), so the universe is not made purely of winners."
|
| 168 |
+
)
|
| 169 |
+
|
| 170 |
+
return SurvivorshipReport(
|
| 171 |
+
n_symbols=n,
|
| 172 |
+
n_alive_at_end=alive,
|
| 173 |
+
n_delisted=len(delisted),
|
| 174 |
+
delisted_symbols=delisted[:25],
|
| 175 |
+
late_starters=late[:25],
|
| 176 |
+
survival_rate=float(rate),
|
| 177 |
+
biased=bool(biased),
|
| 178 |
+
note=note,
|
| 179 |
+
)
|
| 180 |
+
|
| 181 |
+
# -- construction ------------------------------------------------------
|
| 182 |
+
@classmethod
|
| 183 |
+
def from_frames(
|
| 184 |
+
cls,
|
| 185 |
+
frames: Mapping[str, pd.DataFrame],
|
| 186 |
+
sources: Optional[Mapping[str, str]] = None,
|
| 187 |
+
interval: str = "1d",
|
| 188 |
+
note: str = "",
|
| 189 |
+
min_bars: int = 2,
|
| 190 |
+
) -> "Panel":
|
| 191 |
+
"""Build a panel from ``{symbol: ohlcv_frame}``, aligning on the union index."""
|
| 192 |
+
usable = {
|
| 193 |
+
str(symbol): frame
|
| 194 |
+
for symbol, frame in frames.items()
|
| 195 |
+
if frame is not None and len(frame) >= min_bars
|
| 196 |
+
}
|
| 197 |
+
if not usable:
|
| 198 |
+
raise ValueError("No symbol had enough data to build a panel")
|
| 199 |
+
|
| 200 |
+
index = pd.DatetimeIndex([])
|
| 201 |
+
for frame in usable.values():
|
| 202 |
+
index = index.union(pd.DatetimeIndex(frame.index))
|
| 203 |
+
index = index.sort_values()
|
| 204 |
+
|
| 205 |
+
fields: Dict[str, pd.DataFrame] = {}
|
| 206 |
+
for column in OHLCV_COLUMNS:
|
| 207 |
+
fields[column] = pd.DataFrame(
|
| 208 |
+
{
|
| 209 |
+
symbol: pd.to_numeric(frame[column], errors="coerce").reindex(index)
|
| 210 |
+
for symbol, frame in usable.items()
|
| 211 |
+
},
|
| 212 |
+
index=index,
|
| 213 |
+
)
|
| 214 |
+
|
| 215 |
+
return cls(
|
| 216 |
+
fields=fields,
|
| 217 |
+
sources=dict(sources or {s: "unknown" for s in usable}),
|
| 218 |
+
interval=interval,
|
| 219 |
+
note=note,
|
| 220 |
+
)
|
| 221 |
+
|
| 222 |
+
def select(self, symbols: Sequence[str]) -> "Panel":
|
| 223 |
+
keep = [s for s in symbols if s in self.close.columns]
|
| 224 |
+
if not keep:
|
| 225 |
+
raise ValueError("None of the requested symbols are in this panel")
|
| 226 |
+
return Panel(
|
| 227 |
+
fields={name: frame.loc[:, keep] for name, frame in self.fields.items()},
|
| 228 |
+
sources={s: self.sources.get(s, "unknown") for s in keep},
|
| 229 |
+
interval=self.interval,
|
| 230 |
+
note=self.note,
|
| 231 |
+
)
|
| 232 |
+
|
| 233 |
+
def slice(self, start=None, end=None) -> "Panel":
|
| 234 |
+
return Panel(
|
| 235 |
+
fields={name: frame.loc[start:end] for name, frame in self.fields.items()},
|
| 236 |
+
sources=dict(self.sources),
|
| 237 |
+
interval=self.interval,
|
| 238 |
+
note=self.note,
|
| 239 |
+
)
|
| 240 |
+
|
| 241 |
+
|
| 242 |
+
def load_panel(
|
| 243 |
+
symbols: Iterable[str],
|
| 244 |
+
start: str = "2015-01-01",
|
| 245 |
+
end: Optional[str] = None,
|
| 246 |
+
interval: str = "1d",
|
| 247 |
+
source: str = "auto",
|
| 248 |
+
min_bars: int = 120,
|
| 249 |
+
) -> Panel:
|
| 250 |
+
"""Load a panel for ``symbols``, skipping any that cannot supply enough history."""
|
| 251 |
+
symbols = [str(s).strip().upper() for s in symbols if str(s).strip()]
|
| 252 |
+
if not symbols:
|
| 253 |
+
raise ValueError("No symbols requested")
|
| 254 |
+
|
| 255 |
+
frames: Dict[str, pd.DataFrame] = {}
|
| 256 |
+
sources: Dict[str, str] = {}
|
| 257 |
+
skipped: List[str] = []
|
| 258 |
+
|
| 259 |
+
for symbol in dict.fromkeys(symbols): # de-duplicate, keep order
|
| 260 |
+
market = load_ohlcv(symbol, start, end, interval, source)
|
| 261 |
+
if len(market.df) < min_bars:
|
| 262 |
+
skipped.append(symbol)
|
| 263 |
+
continue
|
| 264 |
+
frames[symbol] = market.df
|
| 265 |
+
sources[symbol] = market.source
|
| 266 |
+
|
| 267 |
+
if not frames:
|
| 268 |
+
raise ValueError(
|
| 269 |
+
f"None of {len(symbols)} symbols returned at least {min_bars} bars."
|
| 270 |
+
)
|
| 271 |
+
|
| 272 |
+
simulated = sorted(s for s, src in sources.items() if src == "synthetic")
|
| 273 |
+
note = ""
|
| 274 |
+
if simulated:
|
| 275 |
+
note = (
|
| 276 |
+
f"{len(simulated)} of {len(frames)} symbols fell back to the market simulator "
|
| 277 |
+
f"({', '.join(simulated[:6])}{'...' if len(simulated) > 6 else ''}). "
|
| 278 |
+
"The statistics are still valid; they are measured on a simulated market."
|
| 279 |
+
)
|
| 280 |
+
if skipped:
|
| 281 |
+
note = (note + " " if note else "") + f"Skipped for insufficient history: {', '.join(skipped[:6])}."
|
| 282 |
+
|
| 283 |
+
return Panel.from_frames(frames, sources, interval=interval, note=note.strip())
|
|
@@ -0,0 +1,257 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Matrix portfolio engine.
|
| 2 |
+
|
| 3 |
+
The single-asset engine treats turnover as ``|target[t] - target[t-1]|``. That
|
| 4 |
+
is wrong the moment weights are fractional, because a position you did not
|
| 5 |
+
touch still *drifts*: hold 50% of your book in a name that doubles and you are
|
| 6 |
+
now at 67% without trading. Charging costs against the previous target instead
|
| 7 |
+
of the previous *actual* weight understates the cost of doing nothing and
|
| 8 |
+
overstates the cost of rebalancing.
|
| 9 |
+
|
| 10 |
+
This module models the drift explicitly and closes the gap:
|
| 11 |
+
|
| 12 |
+
w_start[t] = target[t - lag] what we want to hold
|
| 13 |
+
r_p[t] = sum(w_start[t] * R[t]) portfolio return that bar
|
| 14 |
+
w_end[t] = w_start[t] * (1 + R[t]) / (1 + r_p[t]) drifted by the bar
|
| 15 |
+
turnover[t] = sum |w_start[t] - w_end[t - 1]| what we actually traded
|
| 16 |
+
|
| 17 |
+
Every step is a function of the current bar and the one before, so the whole
|
| 18 |
+
thing stays vectorised -- no Python loop over time.
|
| 19 |
+
"""
|
| 20 |
+
|
| 21 |
+
from __future__ import annotations
|
| 22 |
+
|
| 23 |
+
from typing import Optional
|
| 24 |
+
|
| 25 |
+
import numpy as np
|
| 26 |
+
import pandas as pd
|
| 27 |
+
|
| 28 |
+
from .metrics import compute_metrics, infer_periods_per_year
|
| 29 |
+
from .panel import Panel
|
| 30 |
+
from .types import BacktestResult, CostModel
|
| 31 |
+
|
| 32 |
+
__all__ = ["run_portfolio_backtest", "PortfolioResult", "normalise_weights"]
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
class PortfolioResult(BacktestResult):
|
| 36 |
+
"""A :class:`BacktestResult` that also keeps the per-asset weight history."""
|
| 37 |
+
|
| 38 |
+
def __init__(self, *args, weights: pd.DataFrame, held: pd.DataFrame, panel: Panel, **kwargs):
|
| 39 |
+
super().__init__(*args, **kwargs)
|
| 40 |
+
self.weights = weights # requested, post-constraint
|
| 41 |
+
self.held = held # actually held during each bar
|
| 42 |
+
self.panel = panel
|
| 43 |
+
|
| 44 |
+
def attribution(self) -> pd.Series:
|
| 45 |
+
"""Total return contribution per symbol, largest first."""
|
| 46 |
+
contrib = (self.held * self.panel.returns().fillna(0.0)).sum(axis=0)
|
| 47 |
+
return contrib.sort_values(ascending=False)
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def normalise_weights(
|
| 51 |
+
weights: pd.DataFrame,
|
| 52 |
+
listed: pd.DataFrame,
|
| 53 |
+
gross_leverage: float = 1.0,
|
| 54 |
+
max_weight: Optional[float] = None,
|
| 55 |
+
allow_short: bool = True,
|
| 56 |
+
) -> pd.DataFrame:
|
| 57 |
+
"""Apply the constraints a real book has, in the order a real book applies them.
|
| 58 |
+
|
| 59 |
+
``listed`` is "did this name have a price when the decision was made" --
|
| 60 |
+
deliberately not the stricter "is a return defined over this bar" mask. A
|
| 61 |
+
decision taken at Monday's close only needs Monday's price to exist; whether
|
| 62 |
+
the position can actually be carried is enforced after the lag shift.
|
| 63 |
+
"""
|
| 64 |
+
w = weights.reindex(index=listed.index, columns=listed.columns).astype(float).fillna(0.0)
|
| 65 |
+
|
| 66 |
+
# You cannot ask for exposure to something that is not listed yet.
|
| 67 |
+
w = w.where(listed, 0.0)
|
| 68 |
+
|
| 69 |
+
if not allow_short:
|
| 70 |
+
w = w.clip(lower=0.0)
|
| 71 |
+
if max_weight is not None:
|
| 72 |
+
w = w.clip(-abs(max_weight), abs(max_weight))
|
| 73 |
+
|
| 74 |
+
# Scale down (never up) so gross exposure respects the leverage cap.
|
| 75 |
+
gross = w.abs().sum(axis=1)
|
| 76 |
+
scale = np.minimum(1.0, gross_leverage / gross.replace(0.0, np.nan))
|
| 77 |
+
return w.mul(scale.fillna(1.0), axis=0)
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def run_portfolio_backtest(
|
| 81 |
+
panel: Panel,
|
| 82 |
+
weights: pd.DataFrame,
|
| 83 |
+
costs: Optional[CostModel] = None,
|
| 84 |
+
lag: int = 1,
|
| 85 |
+
gross_leverage: float = 1.0,
|
| 86 |
+
max_weight: Optional[float] = None,
|
| 87 |
+
allow_short: bool = True,
|
| 88 |
+
initial_capital: float = 100_000.0,
|
| 89 |
+
periods_per_year: Optional[int] = None,
|
| 90 |
+
rf: float = 0.0,
|
| 91 |
+
benchmark: Optional[pd.Series] = None,
|
| 92 |
+
rebalance_on: Optional[pd.Series] = None,
|
| 93 |
+
meta: Optional[dict] = None,
|
| 94 |
+
) -> PortfolioResult:
|
| 95 |
+
"""Backtest a ``T x N`` weight matrix against a panel.
|
| 96 |
+
|
| 97 |
+
``weights[t]`` is the exposure decided using information up to the close of
|
| 98 |
+
bar ``t``; it is held from bar ``t + lag``. The default benchmark is the
|
| 99 |
+
equal-weight universe, which is a far more honest comparison for a
|
| 100 |
+
cross-sectional strategy than any single ticker.
|
| 101 |
+
"""
|
| 102 |
+
if len(panel) < 2:
|
| 103 |
+
raise ValueError("Cannot backtest a panel with fewer than two bars")
|
| 104 |
+
if lag < 1:
|
| 105 |
+
raise ValueError("lag must be >= 1; lag=0 would trade on unavailable information")
|
| 106 |
+
|
| 107 |
+
costs = costs or CostModel()
|
| 108 |
+
ppy = periods_per_year or infer_periods_per_year(panel.index)
|
| 109 |
+
|
| 110 |
+
asset_returns = panel.returns().fillna(0.0)
|
| 111 |
+
tradable = panel.tradable()
|
| 112 |
+
listed = panel.close.notna()
|
| 113 |
+
|
| 114 |
+
target = normalise_weights(weights, listed, gross_leverage, max_weight, allow_short)
|
| 115 |
+
held = target.shift(lag).fillna(0.0)
|
| 116 |
+
# Re-apply tradability after the shift: a name can delist between the
|
| 117 |
+
# decision and the fill, and we must not be holding it when it does.
|
| 118 |
+
held = held.where(tradable, 0.0)
|
| 119 |
+
|
| 120 |
+
if rebalance_on is not None:
|
| 121 |
+
held = _apply_rebalance_schedule(held, asset_returns, tradable, rebalance_on)
|
| 122 |
+
|
| 123 |
+
gross_return = (held * asset_returns).sum(axis=1)
|
| 124 |
+
|
| 125 |
+
# Weights after the bar's move, renormalised to the new portfolio value.
|
| 126 |
+
growth = (1.0 + gross_return).replace(0.0, np.nan)
|
| 127 |
+
drifted = (held * (1.0 + asset_returns)).div(growth, axis=0).fillna(0.0)
|
| 128 |
+
previous = drifted.shift(1).fillna(0.0)
|
| 129 |
+
|
| 130 |
+
traded = (held - previous).abs().sum(axis=1)
|
| 131 |
+
trade_cost = traded * (costs.one_way_bps / 1e4)
|
| 132 |
+
borrow_cost = held.clip(upper=0.0).abs().sum(axis=1) * (costs.short_borrow_bps / 1e4) / ppy
|
| 133 |
+
total_cost = trade_cost + borrow_cost
|
| 134 |
+
|
| 135 |
+
net = gross_return - total_cost
|
| 136 |
+
equity = initial_capital * (1.0 + net).cumprod()
|
| 137 |
+
|
| 138 |
+
if benchmark is None:
|
| 139 |
+
# Equal weight across whatever was tradable on each bar.
|
| 140 |
+
counts = tradable.sum(axis=1).replace(0, np.nan)
|
| 141 |
+
equal = tradable.astype(float).div(counts, axis=0).fillna(0.0)
|
| 142 |
+
benchmark = (equal.shift(lag).fillna(0.0) * asset_returns).sum(axis=1)
|
| 143 |
+
benchmark = benchmark.reindex(panel.index).fillna(0.0)
|
| 144 |
+
benchmark_equity = initial_capital * (1.0 + benchmark).cumprod()
|
| 145 |
+
|
| 146 |
+
exposure = held.abs().sum(axis=1)
|
| 147 |
+
metrics = compute_metrics(net, equity, exposure, ppy, rf)
|
| 148 |
+
metrics.update(_portfolio_metrics(held, traded, metrics.get("years", 1.0)))
|
| 149 |
+
|
| 150 |
+
survivorship = panel.survivorship()
|
| 151 |
+
|
| 152 |
+
result = PortfolioResult(
|
| 153 |
+
equity=equity,
|
| 154 |
+
returns=net,
|
| 155 |
+
gross_returns=gross_return,
|
| 156 |
+
position=exposure,
|
| 157 |
+
target=target.abs().sum(axis=1),
|
| 158 |
+
costs=total_cost,
|
| 159 |
+
benchmark_equity=benchmark_equity,
|
| 160 |
+
metrics=metrics,
|
| 161 |
+
benchmark_metrics=compute_metrics(benchmark, benchmark_equity, None, ppy, rf),
|
| 162 |
+
meta={
|
| 163 |
+
"lag": lag,
|
| 164 |
+
"gross_leverage": gross_leverage,
|
| 165 |
+
"max_weight": max_weight,
|
| 166 |
+
"allow_short": allow_short,
|
| 167 |
+
"initial_capital": initial_capital,
|
| 168 |
+
"periods_per_year": ppy,
|
| 169 |
+
"n_symbols": len(panel.symbols),
|
| 170 |
+
"survivorship": survivorship,
|
| 171 |
+
**(meta or {}),
|
| 172 |
+
},
|
| 173 |
+
weights=target,
|
| 174 |
+
held=held,
|
| 175 |
+
panel=panel,
|
| 176 |
+
)
|
| 177 |
+
return result
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
def _apply_rebalance_schedule(
|
| 181 |
+
held: pd.DataFrame,
|
| 182 |
+
asset_returns: pd.DataFrame,
|
| 183 |
+
tradable: pd.DataFrame,
|
| 184 |
+
rebalance_on: pd.Series,
|
| 185 |
+
) -> pd.DataFrame:
|
| 186 |
+
"""Trade only on rebalance bars; let the book drift in between.
|
| 187 |
+
|
| 188 |
+
Without this, a monthly strategy whose target is constant between
|
| 189 |
+
rebalances gets charged turnover every single bar for holding still --
|
| 190 |
+
the model would be paying to *prevent* drift that a real book simply lets
|
| 191 |
+
happen. This is the one genuinely recursive step in the engine: today's
|
| 192 |
+
holding depends on yesterday's drifted holding.
|
| 193 |
+
"""
|
| 194 |
+
schedule = rebalance_on.reindex(held.index).fillna(False).to_numpy(dtype=bool)
|
| 195 |
+
target = held.to_numpy(dtype=float)
|
| 196 |
+
returns = asset_returns.to_numpy(dtype=float)
|
| 197 |
+
can_hold = tradable.to_numpy(dtype=bool)
|
| 198 |
+
|
| 199 |
+
n_bars, n_assets = target.shape
|
| 200 |
+
out = np.zeros((n_bars, n_assets), dtype=float)
|
| 201 |
+
carried = np.zeros(n_assets, dtype=float)
|
| 202 |
+
|
| 203 |
+
for t in range(n_bars):
|
| 204 |
+
current = target[t] if schedule[t] else carried
|
| 205 |
+
current = np.where(can_hold[t], current, 0.0)
|
| 206 |
+
out[t] = current
|
| 207 |
+
# Drift into the next bar, renormalised to the new portfolio value.
|
| 208 |
+
portfolio_return = float(current @ returns[t])
|
| 209 |
+
growth = 1.0 + portfolio_return
|
| 210 |
+
carried = current * (1.0 + returns[t]) / growth if abs(growth) > 1e-12 else current
|
| 211 |
+
|
| 212 |
+
return pd.DataFrame(out, index=held.index, columns=held.columns)
|
| 213 |
+
|
| 214 |
+
|
| 215 |
+
def rebalance_schedule(index: pd.DatetimeIndex, frequency: str = "M") -> pd.Series:
|
| 216 |
+
"""Boolean per-bar mask marking rebalance dates.
|
| 217 |
+
|
| 218 |
+
``frequency`` is ``D`` (every bar), ``W``, ``M``, ``Q``, or an integer
|
| 219 |
+
number of bars as a string.
|
| 220 |
+
"""
|
| 221 |
+
frequency = str(frequency).upper().strip()
|
| 222 |
+
if frequency in ("D", "1", "B", ""):
|
| 223 |
+
return pd.Series(True, index=index)
|
| 224 |
+
if frequency.isdigit():
|
| 225 |
+
step = max(1, int(frequency))
|
| 226 |
+
mask = np.zeros(len(index), dtype=bool)
|
| 227 |
+
mask[::step] = True
|
| 228 |
+
return pd.Series(mask, index=index)
|
| 229 |
+
|
| 230 |
+
periods = {"W": index.to_period("W"), "M": index.to_period("M"), "Q": index.to_period("Q")}
|
| 231 |
+
if frequency not in periods:
|
| 232 |
+
raise ValueError(f"Unknown rebalance frequency '{frequency}'")
|
| 233 |
+
period = periods[frequency]
|
| 234 |
+
# First bar of each period -- known at the time, unlike the last bar.
|
| 235 |
+
return pd.Series(period != pd.Series(period, index=index).shift(1).to_numpy(), index=index)
|
| 236 |
+
|
| 237 |
+
|
| 238 |
+
def _portfolio_metrics(held: pd.DataFrame, traded: pd.Series, years: float) -> dict:
|
| 239 |
+
"""Book-level statistics a portfolio manager will look for first."""
|
| 240 |
+
absolute = held.abs()
|
| 241 |
+
gross = absolute.sum(axis=1)
|
| 242 |
+
active = (absolute > 1e-9).sum(axis=1)
|
| 243 |
+
|
| 244 |
+
# Herfindahl on the gross book: 1.0 is everything in one name, 1/n is even.
|
| 245 |
+
shares = absolute.div(gross.replace(0.0, np.nan), axis=0)
|
| 246 |
+
hhi = (shares**2).sum(axis=1)
|
| 247 |
+
|
| 248 |
+
return {
|
| 249 |
+
"gross_exposure": float(gross.mean()),
|
| 250 |
+
"net_exposure": float(held.sum(axis=1).mean()),
|
| 251 |
+
"max_gross_exposure": float(gross.max()),
|
| 252 |
+
"avg_positions": float(active.mean()),
|
| 253 |
+
"max_positions": float(active.max()),
|
| 254 |
+
"concentration_hhi": float(hhi.mean(skipna=True)) if hhi.notna().any() else float("nan"),
|
| 255 |
+
"turnover_ann": float(traded.sum() / years) if years > 0 else 0.0,
|
| 256 |
+
"n_trades": float((traded > 1e-9).sum()),
|
| 257 |
+
}
|
|
@@ -0,0 +1,319 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""The Portfolio Lab: the honesty pipeline for cross-sectional strategies.
|
| 2 |
+
|
| 3 |
+
Same idea as :mod:`algotrader.lab`, but the questions change when you move from
|
| 4 |
+
one asset to many. A timing rule has to prove the market had structure. A book
|
| 5 |
+
that ranks names has to prove three harder things:
|
| 6 |
+
|
| 7 |
+
1. it picked the right names (cross-sectional permutation);
|
| 8 |
+
2. what it picked is not just a style you could buy in an ETF (attribution);
|
| 9 |
+
3. the universe it picked from contains the losers as well as the winners
|
| 10 |
+
(survivorship).
|
| 11 |
+
|
| 12 |
+
All three are wired into the Reality Score alongside the usual selection-bias
|
| 13 |
+
and walk-forward machinery.
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
from __future__ import annotations
|
| 17 |
+
|
| 18 |
+
import logging
|
| 19 |
+
from dataclasses import dataclass, field
|
| 20 |
+
from typing import Callable, Dict, List, Optional, Sequence
|
| 21 |
+
|
| 22 |
+
import numpy as np
|
| 23 |
+
import pandas as pd
|
| 24 |
+
|
| 25 |
+
from .attribution import build_style_factors, factor_attribution
|
| 26 |
+
from .cross_sectional import CrossSectionalStrategy, get_xs_strategy, list_xs_strategies
|
| 27 |
+
from .metrics import infer_periods_per_year
|
| 28 |
+
from .panel import Panel, load_panel
|
| 29 |
+
from .portfolio import PortfolioResult, rebalance_schedule, run_portfolio_backtest
|
| 30 |
+
from .types import CostModel
|
| 31 |
+
from .validation.cross_permutation import CrossPermutationResult, cross_sectional_permutation_test
|
| 32 |
+
from .validation.deflated_sharpe import deflated_sharpe_ratio
|
| 33 |
+
from .validation.pbo import probability_of_backtest_overfitting
|
| 34 |
+
from .validation.walkforward import walk_forward_panel
|
| 35 |
+
from .verdict import reality_score
|
| 36 |
+
|
| 37 |
+
logger = logging.getLogger(__name__)
|
| 38 |
+
|
| 39 |
+
__all__ = ["PortfolioLabConfig", "PortfolioLabReport", "run_portfolio_lab", "run_portfolio_arena"]
|
| 40 |
+
|
| 41 |
+
ProgressFn = Optional[Callable[[float, str], None]]
|
| 42 |
+
|
| 43 |
+
DEFAULT_UNIVERSE = [
|
| 44 |
+
"SPY", "QQQ", "AAPL", "MSFT", "NVDA", "AMZN",
|
| 45 |
+
"META", "TSLA", "GOOGL", "GLD", "TLT", "BTC-USD",
|
| 46 |
+
]
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
@dataclass
|
| 50 |
+
class PortfolioLabConfig:
|
| 51 |
+
symbols: Sequence[str] = tuple(DEFAULT_UNIVERSE)
|
| 52 |
+
start: str = "2015-01-01"
|
| 53 |
+
end: Optional[str] = None
|
| 54 |
+
interval: str = "1d"
|
| 55 |
+
source: str = "auto"
|
| 56 |
+
|
| 57 |
+
strategy: str = "xs_momentum"
|
| 58 |
+
params: Dict[str, float] = field(default_factory=dict)
|
| 59 |
+
|
| 60 |
+
commission_bps: float = 1.0
|
| 61 |
+
slippage_bps: float = 2.0
|
| 62 |
+
short_borrow_bps: float = 50.0
|
| 63 |
+
lag: int = 1
|
| 64 |
+
gross_leverage: float = 1.0
|
| 65 |
+
max_weight: Optional[float] = 0.25
|
| 66 |
+
allow_short: bool = True
|
| 67 |
+
rebalance: str = "M"
|
| 68 |
+
capital: float = 1_000_000.0
|
| 69 |
+
|
| 70 |
+
n_permutations: int = 150
|
| 71 |
+
wf_folds: int = 4
|
| 72 |
+
pbo_splits: int = 8
|
| 73 |
+
grid_limit: int = 24
|
| 74 |
+
seed: int = 0
|
| 75 |
+
|
| 76 |
+
def costs(self, multiplier: float = 1.0) -> CostModel:
|
| 77 |
+
return CostModel(
|
| 78 |
+
commission_bps=self.commission_bps * multiplier,
|
| 79 |
+
slippage_bps=self.slippage_bps * multiplier,
|
| 80 |
+
short_borrow_bps=self.short_borrow_bps * multiplier,
|
| 81 |
+
)
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
@dataclass
|
| 85 |
+
class PortfolioLabReport:
|
| 86 |
+
config: PortfolioLabConfig
|
| 87 |
+
panel: Panel
|
| 88 |
+
strategy: CrossSectionalStrategy
|
| 89 |
+
params: Dict[str, float]
|
| 90 |
+
backtest: PortfolioResult
|
| 91 |
+
permutation: Optional[CrossPermutationResult] = None
|
| 92 |
+
dsr: Dict[str, float] = field(default_factory=dict)
|
| 93 |
+
pbo: Dict[str, object] = field(default_factory=dict)
|
| 94 |
+
walkforward: Dict[str, object] = field(default_factory=dict)
|
| 95 |
+
attribution: Dict[str, object] = field(default_factory=dict)
|
| 96 |
+
trials: Dict[str, object] = field(default_factory=dict)
|
| 97 |
+
verdict: Dict[str, object] = field(default_factory=dict)
|
| 98 |
+
cost_stress: Dict[str, float] = field(default_factory=dict)
|
| 99 |
+
|
| 100 |
+
@property
|
| 101 |
+
def survivorship(self):
|
| 102 |
+
return self.panel.survivorship()
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
def _trial_matrix(
|
| 106 |
+
panel: Panel,
|
| 107 |
+
strategy: CrossSectionalStrategy,
|
| 108 |
+
cfg: PortfolioLabConfig,
|
| 109 |
+
schedule: pd.Series,
|
| 110 |
+
progress: ProgressFn = None,
|
| 111 |
+
) -> tuple[np.ndarray, List[float], List[str]]:
|
| 112 |
+
"""Backtest every parameter variant, for the Deflated Sharpe and PBO inputs."""
|
| 113 |
+
grid = strategy.grid(limit=cfg.grid_limit)
|
| 114 |
+
costs = cfg.costs()
|
| 115 |
+
columns, sharpes, labels = [], [], []
|
| 116 |
+
|
| 117 |
+
for i, params in enumerate(grid):
|
| 118 |
+
weights = strategy.generate(panel, params)
|
| 119 |
+
result = run_portfolio_backtest(
|
| 120 |
+
panel, weights, costs=costs, lag=cfg.lag,
|
| 121 |
+
gross_leverage=cfg.gross_leverage, max_weight=cfg.max_weight,
|
| 122 |
+
allow_short=cfg.allow_short, rebalance_on=schedule,
|
| 123 |
+
)
|
| 124 |
+
columns.append(result.returns.to_numpy(dtype=float))
|
| 125 |
+
sharpes.append(result.sharpe)
|
| 126 |
+
labels.append(", ".join(f"{k}={v}" for k, v in params.items()) or "default")
|
| 127 |
+
if progress is not None and i % 3 == 0:
|
| 128 |
+
progress((i + 1) / max(len(grid), 1), f"Variant {i + 1}/{len(grid)}")
|
| 129 |
+
|
| 130 |
+
matrix = np.column_stack(columns) if columns else np.zeros((len(panel), 0))
|
| 131 |
+
return matrix, sharpes, labels
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
def run_portfolio_lab(cfg: PortfolioLabConfig, progress: ProgressFn = None) -> PortfolioLabReport:
|
| 135 |
+
"""Run the full cross-sectional honesty pipeline."""
|
| 136 |
+
|
| 137 |
+
def step(fraction: float, message: str) -> None:
|
| 138 |
+
if progress is not None:
|
| 139 |
+
progress(min(max(fraction, 0.0), 1.0), message)
|
| 140 |
+
|
| 141 |
+
step(0.02, f"Loading {len(cfg.symbols)} symbols")
|
| 142 |
+
panel = load_panel(cfg.symbols, cfg.start, cfg.end, cfg.interval, cfg.source)
|
| 143 |
+
if len(panel) < 250:
|
| 144 |
+
raise ValueError(
|
| 145 |
+
f"Only {len(panel)} bars available. Widen the date range — a cross-sectional "
|
| 146 |
+
"book cannot be validated on less than a year of data."
|
| 147 |
+
)
|
| 148 |
+
|
| 149 |
+
strategy = get_xs_strategy(cfg.strategy)
|
| 150 |
+
params = strategy.clean(cfg.params)
|
| 151 |
+
ppy = infer_periods_per_year(panel.index)
|
| 152 |
+
schedule = rebalance_schedule(panel.index, cfg.rebalance)
|
| 153 |
+
|
| 154 |
+
step(0.10, "Running the backtest")
|
| 155 |
+
weights = strategy.generate(panel, params)
|
| 156 |
+
backtest = run_portfolio_backtest(
|
| 157 |
+
panel, weights, costs=cfg.costs(), lag=cfg.lag,
|
| 158 |
+
gross_leverage=cfg.gross_leverage, max_weight=cfg.max_weight,
|
| 159 |
+
allow_short=cfg.allow_short, initial_capital=cfg.capital,
|
| 160 |
+
periods_per_year=ppy, rebalance_on=schedule,
|
| 161 |
+
meta={"strategy": strategy.key, "params": params, "rebalance": cfg.rebalance},
|
| 162 |
+
)
|
| 163 |
+
|
| 164 |
+
step(0.16, "Stress-testing costs")
|
| 165 |
+
stressed = run_portfolio_backtest(
|
| 166 |
+
panel, weights, costs=cfg.costs(3.0), lag=cfg.lag,
|
| 167 |
+
gross_leverage=cfg.gross_leverage, max_weight=cfg.max_weight,
|
| 168 |
+
allow_short=cfg.allow_short, periods_per_year=ppy, rebalance_on=schedule,
|
| 169 |
+
)
|
| 170 |
+
base_sharpe = backtest.sharpe
|
| 171 |
+
cost_stress = {
|
| 172 |
+
"sharpe_1x": base_sharpe,
|
| 173 |
+
"sharpe_3x": stressed.sharpe,
|
| 174 |
+
"ratio": float(stressed.sharpe / base_sharpe) if base_sharpe > 1e-9 else 0.0,
|
| 175 |
+
"return_3x": float(stressed.metrics.get("total_return", 0.0)),
|
| 176 |
+
}
|
| 177 |
+
|
| 178 |
+
step(0.22, "Backtesting every parameter variant")
|
| 179 |
+
matrix, trial_sharpes, labels = _trial_matrix(
|
| 180 |
+
panel, strategy, cfg, schedule, lambda f, m: step(0.22 + 0.14 * f, m)
|
| 181 |
+
)
|
| 182 |
+
n_trials = max(len(trial_sharpes), 1)
|
| 183 |
+
|
| 184 |
+
step(0.38, "Deflating the Sharpe ratio for selection bias")
|
| 185 |
+
dsr = deflated_sharpe_ratio(
|
| 186 |
+
backtest.returns.to_numpy(dtype=float),
|
| 187 |
+
sharpe_annual=base_sharpe,
|
| 188 |
+
periods_per_year=ppy,
|
| 189 |
+
n_trials=n_trials,
|
| 190 |
+
trial_sharpes=trial_sharpes if n_trials > 1 else None,
|
| 191 |
+
)
|
| 192 |
+
|
| 193 |
+
step(0.42, "Measuring backtest overfitting")
|
| 194 |
+
pbo = probability_of_backtest_overfitting(matrix, n_splits=cfg.pbo_splits, labels=labels)
|
| 195 |
+
|
| 196 |
+
step(0.46, "Shuffling names within each date")
|
| 197 |
+
permutation = None
|
| 198 |
+
if cfg.n_permutations > 0:
|
| 199 |
+
permutation = cross_sectional_permutation_test(
|
| 200 |
+
panel, weights, n_permutations=cfg.n_permutations, lag=cfg.lag,
|
| 201 |
+
gross_leverage=cfg.gross_leverage, max_weight=cfg.max_weight,
|
| 202 |
+
allow_short=cfg.allow_short, rebalance_on=schedule, seed=cfg.seed,
|
| 203 |
+
progress=lambda f, m: step(0.46 + 0.30 * f, m),
|
| 204 |
+
)
|
| 205 |
+
|
| 206 |
+
step(0.78, "Attributing returns to style factors")
|
| 207 |
+
try:
|
| 208 |
+
factors = build_style_factors(panel)
|
| 209 |
+
attribution = factor_attribution(backtest.returns, factors, ppy)
|
| 210 |
+
except Exception as exc: # noqa: BLE001 - attribution must never sink a run
|
| 211 |
+
logger.warning("Attribution failed: %s", exc)
|
| 212 |
+
attribution = {"available": False, "note": f"Attribution unavailable: {exc}"}
|
| 213 |
+
|
| 214 |
+
step(0.86, "Walking the strategy forward")
|
| 215 |
+
wf = walk_forward_panel(
|
| 216 |
+
panel, strategy, n_folds=cfg.wf_folds, costs=cfg.costs(), lag=cfg.lag,
|
| 217 |
+
gross_leverage=cfg.gross_leverage, allow_short=cfg.allow_short,
|
| 218 |
+
rebalance=cfg.rebalance, grid_limit=min(cfg.grid_limit, 12),
|
| 219 |
+
progress=lambda f, m: step(0.86 + 0.10 * f, m),
|
| 220 |
+
)
|
| 221 |
+
|
| 222 |
+
step(0.98, "Grading")
|
| 223 |
+
survivorship = panel.survivorship()
|
| 224 |
+
verdict = reality_score(
|
| 225 |
+
metrics=backtest.metrics,
|
| 226 |
+
benchmark_metrics=backtest.benchmark_metrics,
|
| 227 |
+
p_value=permutation.p_value if permutation else None,
|
| 228 |
+
dsr=dsr.get("dsr"),
|
| 229 |
+
pbo=pbo.get("pbo"),
|
| 230 |
+
wf_efficiency=wf.get("efficiency"),
|
| 231 |
+
wf_win_rate=wf.get("oos_win_rate"),
|
| 232 |
+
cost_stress_ratio=cost_stress["ratio"],
|
| 233 |
+
attribution=attribution,
|
| 234 |
+
survivorship=survivorship,
|
| 235 |
+
benchmark_name="The equal-weight universe",
|
| 236 |
+
permutation_label="books with the same shape but randomly chosen names",
|
| 237 |
+
)
|
| 238 |
+
|
| 239 |
+
step(1.0, "Done")
|
| 240 |
+
return PortfolioLabReport(
|
| 241 |
+
config=cfg,
|
| 242 |
+
panel=panel,
|
| 243 |
+
strategy=strategy,
|
| 244 |
+
params=params,
|
| 245 |
+
backtest=backtest,
|
| 246 |
+
permutation=permutation,
|
| 247 |
+
dsr=dsr,
|
| 248 |
+
pbo=pbo,
|
| 249 |
+
walkforward=wf,
|
| 250 |
+
attribution=attribution,
|
| 251 |
+
trials={"n": n_trials, "sharpes": trial_sharpes, "labels": labels},
|
| 252 |
+
verdict=verdict,
|
| 253 |
+
cost_stress=cost_stress,
|
| 254 |
+
)
|
| 255 |
+
|
| 256 |
+
|
| 257 |
+
def run_portfolio_arena(
|
| 258 |
+
cfg: PortfolioLabConfig,
|
| 259 |
+
strategy_keys: Optional[List[str]] = None,
|
| 260 |
+
n_permutations: int = 80,
|
| 261 |
+
progress: ProgressFn = None,
|
| 262 |
+
) -> tuple[pd.DataFrame, Panel, Dict[str, PortfolioResult]]:
|
| 263 |
+
"""Race every cross-sectional strategy on one universe, ranked by evidence."""
|
| 264 |
+
panel = load_panel(cfg.symbols, cfg.start, cfg.end, cfg.interval, cfg.source)
|
| 265 |
+
ppy = infer_periods_per_year(panel.index)
|
| 266 |
+
schedule = rebalance_schedule(panel.index, cfg.rebalance)
|
| 267 |
+
costs = cfg.costs()
|
| 268 |
+
|
| 269 |
+
keys = strategy_keys or [s.key for s in list_xs_strategies()]
|
| 270 |
+
factors = build_style_factors(panel)
|
| 271 |
+
rows, books = [], {}
|
| 272 |
+
|
| 273 |
+
for i, key in enumerate(keys):
|
| 274 |
+
strategy = get_xs_strategy(key)
|
| 275 |
+
weights = strategy.generate(panel, strategy.defaults())
|
| 276 |
+
result = run_portfolio_backtest(
|
| 277 |
+
panel, weights, costs=costs, lag=cfg.lag, gross_leverage=cfg.gross_leverage,
|
| 278 |
+
max_weight=cfg.max_weight, allow_short=cfg.allow_short,
|
| 279 |
+
initial_capital=cfg.capital, periods_per_year=ppy, rebalance_on=schedule,
|
| 280 |
+
)
|
| 281 |
+
books[key] = result
|
| 282 |
+
|
| 283 |
+
p_value = float("nan")
|
| 284 |
+
if n_permutations > 0:
|
| 285 |
+
p_value = cross_sectional_permutation_test(
|
| 286 |
+
panel, weights, n_permutations=n_permutations, lag=cfg.lag,
|
| 287 |
+
gross_leverage=cfg.gross_leverage, max_weight=cfg.max_weight,
|
| 288 |
+
allow_short=cfg.allow_short, rebalance_on=schedule, seed=cfg.seed,
|
| 289 |
+
observed=result.sharpe,
|
| 290 |
+
).p_value
|
| 291 |
+
|
| 292 |
+
dsr = deflated_sharpe_ratio(
|
| 293 |
+
result.returns.to_numpy(dtype=float), sharpe_annual=result.sharpe,
|
| 294 |
+
periods_per_year=ppy, n_trials=len(strategy.grid(limit=cfg.grid_limit)),
|
| 295 |
+
)
|
| 296 |
+
attr = factor_attribution(result.returns, factors, ppy)
|
| 297 |
+
|
| 298 |
+
rows.append({
|
| 299 |
+
"Strategy": strategy.name,
|
| 300 |
+
"key": key,
|
| 301 |
+
"Family": strategy.family,
|
| 302 |
+
"Return": result.metrics.get("total_return", 0.0),
|
| 303 |
+
"CAGR": result.metrics.get("cagr", 0.0),
|
| 304 |
+
"Sharpe": result.sharpe,
|
| 305 |
+
"MaxDD": result.metrics.get("max_drawdown", 0.0),
|
| 306 |
+
"Turnover": result.metrics.get("turnover_ann", 0.0),
|
| 307 |
+
"p-value": p_value,
|
| 308 |
+
"DSR": dsr["dsr"],
|
| 309 |
+
"Alpha t": attr.get("alpha_t_stat", float("nan")) if attr.get("available") else float("nan"),
|
| 310 |
+
})
|
| 311 |
+
if progress is not None:
|
| 312 |
+
progress((i + 1) / len(keys), f"{strategy.name} ({i + 1}/{len(keys)})")
|
| 313 |
+
|
| 314 |
+
table = pd.DataFrame(rows)
|
| 315 |
+
if not table.empty:
|
| 316 |
+
table["Evidence"] = (1.0 - table["p-value"].fillna(0.5)) * table["DSR"]
|
| 317 |
+
table = table.sort_values("Evidence", ascending=False).reset_index(drop=True)
|
| 318 |
+
table.insert(0, "#", table.index + 1)
|
| 319 |
+
return table, panel, books
|
|
@@ -0,0 +1,146 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""The cross-sectional null.
|
| 2 |
+
|
| 3 |
+
For a timing rule, shuffling the price path is the right null. For a rule that
|
| 4 |
+
*ranks names*, it is the wrong test entirely: shuffling time destroys the
|
| 5 |
+
market's whole correlation structure, and the resulting null is so weak that
|
| 6 |
+
almost any long-short book clears it.
|
| 7 |
+
|
| 8 |
+
The question a cross-sectional strategy has to answer is narrower. Not "does
|
| 9 |
+
this market have structure?" but: **given these dates, these assets and this
|
| 10 |
+
book's shape, does the strategy put its weight on the right names?**
|
| 11 |
+
|
| 12 |
+
So we permute the *weights across assets within each date*. Every calendar
|
| 13 |
+
effect survives. Every correlation between names survives. The gross and net
|
| 14 |
+
exposure of the book on each date survives exactly. The one thing destroyed is
|
| 15 |
+
the link between the strategy's choice and the asset it chose.
|
| 16 |
+
|
| 17 |
+
A momentum book that beats this null is picking names. One that does not was
|
| 18 |
+
being paid for its market exposure, its sector tilt, or the calendar -- all of
|
| 19 |
+
which are available far more cheaply.
|
| 20 |
+
"""
|
| 21 |
+
|
| 22 |
+
from __future__ import annotations
|
| 23 |
+
|
| 24 |
+
from dataclasses import dataclass
|
| 25 |
+
from typing import Callable, Optional
|
| 26 |
+
|
| 27 |
+
import numpy as np
|
| 28 |
+
import pandas as pd
|
| 29 |
+
|
| 30 |
+
from ..panel import Panel
|
| 31 |
+
from ..portfolio import run_portfolio_backtest
|
| 32 |
+
from ..types import CostModel
|
| 33 |
+
|
| 34 |
+
__all__ = ["cross_sectional_permutation_test", "permute_within_dates", "CrossPermutationResult"]
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
@dataclass
|
| 38 |
+
class CrossPermutationResult:
|
| 39 |
+
observed: float
|
| 40 |
+
null: np.ndarray
|
| 41 |
+
p_value: float
|
| 42 |
+
n_permutations: int
|
| 43 |
+
|
| 44 |
+
@property
|
| 45 |
+
def null_mean(self) -> float:
|
| 46 |
+
return float(np.mean(self.null)) if self.null.size else 0.0
|
| 47 |
+
|
| 48 |
+
@property
|
| 49 |
+
def percentile(self) -> float:
|
| 50 |
+
if not self.null.size:
|
| 51 |
+
return 50.0
|
| 52 |
+
return float((self.null < self.observed).mean() * 100.0)
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def permute_within_dates(
|
| 56 |
+
weights: pd.DataFrame,
|
| 57 |
+
investable: pd.DataFrame,
|
| 58 |
+
rng: np.random.Generator,
|
| 59 |
+
) -> pd.DataFrame:
|
| 60 |
+
"""Reassign each date's weights among that date's investable assets.
|
| 61 |
+
|
| 62 |
+
The multiset of weights on every row is preserved exactly -- so gross
|
| 63 |
+
exposure, net exposure, leg sizes and position counts are all identical to
|
| 64 |
+
the real book -- but which asset receives which weight is randomised.
|
| 65 |
+
|
| 66 |
+
Vectorised across all dates at once: sorting each row puts the investable
|
| 67 |
+
weights first and pushes non-investable slots to NaN, then a random rank per
|
| 68 |
+
investable slot picks each weight exactly once.
|
| 69 |
+
"""
|
| 70 |
+
values = weights.to_numpy(dtype=float, copy=True)
|
| 71 |
+
mask = investable.to_numpy(dtype=bool)
|
| 72 |
+
|
| 73 |
+
masked = np.where(mask, values, np.nan)
|
| 74 |
+
# NaNs sort last, so the first k entries of each row are that row's real weights.
|
| 75 |
+
ordered = np.sort(masked, axis=1)
|
| 76 |
+
|
| 77 |
+
noise = np.where(mask, rng.random(values.shape), np.inf)
|
| 78 |
+
# Double argsort turns random values into ranks 0..k-1 for investable slots,
|
| 79 |
+
# and k..n-1 for the rest, which then index into the NaN tail.
|
| 80 |
+
random_rank = np.argsort(np.argsort(noise, axis=1), axis=1)
|
| 81 |
+
|
| 82 |
+
shuffled = np.take_along_axis(ordered, random_rank, axis=1)
|
| 83 |
+
return pd.DataFrame(
|
| 84 |
+
np.nan_to_num(shuffled, nan=0.0), index=weights.index, columns=weights.columns
|
| 85 |
+
)
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def cross_sectional_permutation_test(
|
| 89 |
+
panel: Panel,
|
| 90 |
+
weights: pd.DataFrame,
|
| 91 |
+
n_permutations: int = 200,
|
| 92 |
+
costs: Optional[CostModel] = None,
|
| 93 |
+
lag: int = 1,
|
| 94 |
+
gross_leverage: float = 1.0,
|
| 95 |
+
max_weight: Optional[float] = None,
|
| 96 |
+
allow_short: bool = True,
|
| 97 |
+
rebalance_on: Optional[pd.Series] = None,
|
| 98 |
+
seed: int = 0,
|
| 99 |
+
observed: Optional[float] = None,
|
| 100 |
+
neutralise_costs: bool = True,
|
| 101 |
+
progress: Optional[Callable[[float, str], None]] = None,
|
| 102 |
+
) -> CrossPermutationResult:
|
| 103 |
+
"""Test whether a book's Sharpe survives randomising which names it picked.
|
| 104 |
+
|
| 105 |
+
``neutralise_costs`` defaults to True, and it matters more than it looks.
|
| 106 |
+
A real momentum book holds many of the same names from one rebalance to the
|
| 107 |
+
next, so it churns slowly. A shuffled book reassigns names at random every
|
| 108 |
+
date, so it churns furiously and pays for it. Charging costs would penalise
|
| 109 |
+
the null for turnover the strategy never had, and the strategy would look
|
| 110 |
+
good by comparison for reasons that have nothing to do with skill.
|
| 111 |
+
|
| 112 |
+
So this test asks only "did it pick the right names?" and leaves "can you
|
| 113 |
+
afford to trade it?" to the cost stress test, which measures that directly.
|
| 114 |
+
"""
|
| 115 |
+
costs = CostModel(0.0, 0.0, 0.0) if neutralise_costs else (costs or CostModel())
|
| 116 |
+
rng = np.random.default_rng(seed)
|
| 117 |
+
investable = panel.close.notna()
|
| 118 |
+
|
| 119 |
+
def sharpe_of(w: pd.DataFrame) -> float:
|
| 120 |
+
return run_portfolio_backtest(
|
| 121 |
+
panel,
|
| 122 |
+
w,
|
| 123 |
+
costs=costs,
|
| 124 |
+
lag=lag,
|
| 125 |
+
gross_leverage=gross_leverage,
|
| 126 |
+
max_weight=max_weight,
|
| 127 |
+
allow_short=allow_short,
|
| 128 |
+
rebalance_on=rebalance_on,
|
| 129 |
+
).sharpe
|
| 130 |
+
|
| 131 |
+
if observed is None:
|
| 132 |
+
observed = sharpe_of(weights)
|
| 133 |
+
|
| 134 |
+
null = np.empty(n_permutations, dtype=float)
|
| 135 |
+
for i in range(n_permutations):
|
| 136 |
+
null[i] = sharpe_of(permute_within_dates(weights, investable, rng))
|
| 137 |
+
if progress is not None and (i % 10 == 0 or i == n_permutations - 1):
|
| 138 |
+
progress((i + 1) / n_permutations, f"Cross-sectional shuffle {i + 1}/{n_permutations}")
|
| 139 |
+
|
| 140 |
+
p_value = float((1 + np.sum(null >= observed)) / (n_permutations + 1))
|
| 141 |
+
return CrossPermutationResult(
|
| 142 |
+
observed=float(observed),
|
| 143 |
+
null=null,
|
| 144 |
+
p_value=p_value,
|
| 145 |
+
n_permutations=n_permutations,
|
| 146 |
+
)
|
|
@@ -16,7 +16,7 @@ from ..engine import run_backtest
|
|
| 16 |
from ..strategies import Strategy
|
| 17 |
from ..types import CostModel
|
| 18 |
|
| 19 |
-
__all__ = ["walk_forward"]
|
| 20 |
|
| 21 |
|
| 22 |
def walk_forward(
|
|
@@ -127,3 +127,107 @@ def walk_forward(
|
|
| 127 |
"oos_equity": (1.0 + stitched).cumprod() if len(stitched) else stitched,
|
| 128 |
"note": "",
|
| 129 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
from ..strategies import Strategy
|
| 17 |
from ..types import CostModel
|
| 18 |
|
| 19 |
+
__all__ = ["walk_forward", "walk_forward_panel"]
|
| 20 |
|
| 21 |
|
| 22 |
def walk_forward(
|
|
|
|
| 127 |
"oos_equity": (1.0 + stitched).cumprod() if len(stitched) else stitched,
|
| 128 |
"note": "",
|
| 129 |
}
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
def walk_forward_panel(
|
| 133 |
+
panel,
|
| 134 |
+
strategy,
|
| 135 |
+
n_folds: int = 4,
|
| 136 |
+
train_ratio: float = 0.7,
|
| 137 |
+
costs: Optional[CostModel] = None,
|
| 138 |
+
lag: int = 1,
|
| 139 |
+
gross_leverage: float = 1.0,
|
| 140 |
+
allow_short: bool = True,
|
| 141 |
+
rebalance: str = "M",
|
| 142 |
+
grid_limit: int = 16,
|
| 143 |
+
progress: Optional[Callable[[float, str], None]] = None,
|
| 144 |
+
) -> Dict[str, object]:
|
| 145 |
+
"""Walk-forward for cross-sectional strategies over a :class:`Panel`.
|
| 146 |
+
|
| 147 |
+
Same contract as :func:`walk_forward`: tune on the training window, trade
|
| 148 |
+
the next window blind, roll on. Signals are generated over train+test
|
| 149 |
+
together so the indicators are warm at the fold boundary, then evaluated
|
| 150 |
+
only on the test slice -- the panel equivalent of the single-asset path.
|
| 151 |
+
"""
|
| 152 |
+
from ..portfolio import rebalance_schedule, run_portfolio_backtest
|
| 153 |
+
|
| 154 |
+
costs = costs or CostModel()
|
| 155 |
+
grid = strategy.grid(limit=grid_limit)
|
| 156 |
+
n = len(panel)
|
| 157 |
+
|
| 158 |
+
if n < 250 or n_folds < 2:
|
| 159 |
+
return {"folds": [], "note": "Not enough history for walk-forward analysis."}
|
| 160 |
+
|
| 161 |
+
block = min(int(n / (1 + (n_folds - 1) * (1 - train_ratio))), n)
|
| 162 |
+
train_len = int(block * train_ratio)
|
| 163 |
+
test_len = block - train_len
|
| 164 |
+
if train_len < 100 or test_len < 20:
|
| 165 |
+
return {"folds": [], "note": "Not enough history for walk-forward analysis."}
|
| 166 |
+
|
| 167 |
+
folds: List[Dict[str, object]] = []
|
| 168 |
+
oos_returns: List[pd.Series] = []
|
| 169 |
+
|
| 170 |
+
for k in range(n_folds):
|
| 171 |
+
start = k * test_len
|
| 172 |
+
train = panel.slice(panel.index[start], panel.index[min(start + train_len - 1, n - 1)])
|
| 173 |
+
test_start = start + train_len
|
| 174 |
+
if test_start + 20 > n:
|
| 175 |
+
break
|
| 176 |
+
test_end = min(test_start + test_len, n) - 1
|
| 177 |
+
test = panel.slice(panel.index[test_start], panel.index[test_end])
|
| 178 |
+
if len(test) < 20:
|
| 179 |
+
break
|
| 180 |
+
|
| 181 |
+
best_params, best_sharpe = None, -np.inf
|
| 182 |
+
for params in grid:
|
| 183 |
+
weights = strategy.generate(train, params)
|
| 184 |
+
sharpe = run_portfolio_backtest(
|
| 185 |
+
train, weights, costs=costs, lag=lag, gross_leverage=gross_leverage,
|
| 186 |
+
allow_short=allow_short, rebalance_on=rebalance_schedule(train.index, rebalance),
|
| 187 |
+
).sharpe
|
| 188 |
+
if sharpe > best_sharpe:
|
| 189 |
+
best_params, best_sharpe = params, sharpe
|
| 190 |
+
|
| 191 |
+
combined = panel.slice(panel.index[start], panel.index[test_end])
|
| 192 |
+
weights = strategy.generate(combined, best_params).loc[test.index]
|
| 193 |
+
oos = run_portfolio_backtest(
|
| 194 |
+
test, weights, costs=costs, lag=lag, gross_leverage=gross_leverage,
|
| 195 |
+
allow_short=allow_short, rebalance_on=rebalance_schedule(test.index, rebalance),
|
| 196 |
+
)
|
| 197 |
+
|
| 198 |
+
folds.append({
|
| 199 |
+
"fold": k + 1,
|
| 200 |
+
"train_start": str(train.index[0].date()),
|
| 201 |
+
"train_end": str(train.index[-1].date()),
|
| 202 |
+
"test_start": str(test.index[0].date()),
|
| 203 |
+
"test_end": str(test.index[-1].date()),
|
| 204 |
+
"params": best_params,
|
| 205 |
+
"is_sharpe": float(best_sharpe),
|
| 206 |
+
"oos_sharpe": float(oos.sharpe),
|
| 207 |
+
"oos_return": float(oos.metrics.get("total_return", 0.0)),
|
| 208 |
+
"oos_max_dd": float(oos.metrics.get("max_drawdown", 0.0)),
|
| 209 |
+
})
|
| 210 |
+
oos_returns.append(oos.returns)
|
| 211 |
+
if progress is not None:
|
| 212 |
+
progress((k + 1) / n_folds, f"Walk-forward fold {k + 1}/{n_folds}")
|
| 213 |
+
|
| 214 |
+
if not folds:
|
| 215 |
+
return {"folds": [], "note": "Not enough history for walk-forward analysis."}
|
| 216 |
+
|
| 217 |
+
is_sharpes = np.array([f["is_sharpe"] for f in folds], dtype=float)
|
| 218 |
+
oos_sharpes = np.array([f["oos_sharpe"] for f in folds], dtype=float)
|
| 219 |
+
stitched = pd.concat(oos_returns) if oos_returns else pd.Series(dtype=float)
|
| 220 |
+
stitched = stitched[~stitched.index.duplicated(keep="first")].sort_index()
|
| 221 |
+
mean_is, mean_oos = float(np.mean(is_sharpes)), float(np.mean(oos_sharpes))
|
| 222 |
+
|
| 223 |
+
return {
|
| 224 |
+
"folds": folds,
|
| 225 |
+
"mean_is_sharpe": mean_is,
|
| 226 |
+
"mean_oos_sharpe": mean_oos,
|
| 227 |
+
"efficiency": float(mean_oos / mean_is) if mean_is > 1e-9 else 0.0,
|
| 228 |
+
"oos_win_rate": float(np.mean(oos_sharpes > 0)),
|
| 229 |
+
"param_instability": float(len({str(f["params"]) for f in folds}) / max(len(folds), 1)),
|
| 230 |
+
"oos_returns": stitched,
|
| 231 |
+
"oos_equity": (1.0 + stitched).cumprod() if len(stitched) else stitched,
|
| 232 |
+
"note": "",
|
| 233 |
+
}
|
|
@@ -49,8 +49,16 @@ def reality_score(
|
|
| 49 |
wf_win_rate: Optional[float] = None,
|
| 50 |
cost_stress_ratio: Optional[float] = None,
|
| 51 |
benchmark_correlation: Optional[float] = None,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
) -> Dict[str, object]:
|
| 53 |
-
"""Combine the validation panel into a 0-100 score, a grade and warnings.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 54 |
components: Dict[str, float] = {}
|
| 55 |
|
| 56 |
components["significance"] = _ramp(p_value, good=0.01, bad=0.50) if p_value is not None else 50.0
|
|
@@ -106,8 +114,8 @@ def reality_score(
|
|
| 106 |
score = min(score, 55.0)
|
| 107 |
if p_value is not None and p_value > 0.10:
|
| 108 |
flags.append(
|
| 109 |
-
f"Permutation p-value is {p_value:.2f}: roughly {p_value * 100:.0f}% of
|
| 110 |
-
"
|
| 111 |
)
|
| 112 |
if dsr is not None and dsr < 0.5:
|
| 113 |
flags.append(
|
|
@@ -131,12 +139,13 @@ def reality_score(
|
|
| 131 |
)
|
| 132 |
if benchmark_correlation is not None and benchmark_correlation > 0.95:
|
| 133 |
flags.append(
|
| 134 |
-
f"Returns are {benchmark_correlation:.0%} correlated with
|
| 135 |
"this is mostly a repackaged long position."
|
| 136 |
)
|
| 137 |
if sharpe < bench_sharpe:
|
| 138 |
flags.append(
|
| 139 |
-
f"
|
|
|
|
| 140 |
)
|
| 141 |
if float(metrics.get("turnover_ann", 0.0)) > 100:
|
| 142 |
flags.append(
|
|
@@ -144,11 +153,36 @@ def reality_score(
|
|
| 144 |
"retail execution can absorb without moving the modelled fills."
|
| 145 |
)
|
| 146 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 147 |
grade, headline = next((g, h) for threshold, g, h in GRADES if score >= threshold)
|
| 148 |
|
| 149 |
if score >= 70:
|
| 150 |
verdict = (
|
| 151 |
-
f"Grade {grade}. {headline}. The edge is still there after
|
| 152 |
"after charging for every variant tried, and after walking it forward."
|
| 153 |
)
|
| 154 |
elif score >= 55:
|
|
@@ -163,8 +197,8 @@ def reality_score(
|
|
| 163 |
)
|
| 164 |
else:
|
| 165 |
verdict = (
|
| 166 |
-
f"Grade {grade}. {headline}.
|
| 167 |
-
"often enough that there is nothing here to trade."
|
| 168 |
)
|
| 169 |
|
| 170 |
return {
|
|
|
|
| 49 |
wf_win_rate: Optional[float] = None,
|
| 50 |
cost_stress_ratio: Optional[float] = None,
|
| 51 |
benchmark_correlation: Optional[float] = None,
|
| 52 |
+
attribution: Optional[Dict[str, object]] = None,
|
| 53 |
+
survivorship: Optional[object] = None,
|
| 54 |
+
benchmark_name: str = "Buy & hold",
|
| 55 |
+
permutation_label: str = "shuffled, structure-free markets",
|
| 56 |
) -> Dict[str, object]:
|
| 57 |
+
"""Combine the validation panel into a 0-100 score, a grade and warnings.
|
| 58 |
+
|
| 59 |
+
``attribution`` and ``survivorship`` are the portfolio-only inputs; both are
|
| 60 |
+
optional so the single-asset path is unaffected.
|
| 61 |
+
"""
|
| 62 |
components: Dict[str, float] = {}
|
| 63 |
|
| 64 |
components["significance"] = _ramp(p_value, good=0.01, bad=0.50) if p_value is not None else 50.0
|
|
|
|
| 114 |
score = min(score, 55.0)
|
| 115 |
if p_value is not None and p_value > 0.10:
|
| 116 |
flags.append(
|
| 117 |
+
f"Permutation p-value is {p_value:.2f}: roughly {p_value * 100:.0f}% of "
|
| 118 |
+
f"{permutation_label} did this well or better."
|
| 119 |
)
|
| 120 |
if dsr is not None and dsr < 0.5:
|
| 121 |
flags.append(
|
|
|
|
| 139 |
)
|
| 140 |
if benchmark_correlation is not None and benchmark_correlation > 0.95:
|
| 141 |
flags.append(
|
| 142 |
+
f"Returns are {benchmark_correlation:.0%} correlated with {benchmark_name.lower()} — "
|
| 143 |
"this is mostly a repackaged long position."
|
| 144 |
)
|
| 145 |
if sharpe < bench_sharpe:
|
| 146 |
flags.append(
|
| 147 |
+
f"{benchmark_name} beat it on risk-adjusted return "
|
| 148 |
+
f"({bench_sharpe:.2f} vs {sharpe:.2f} Sharpe)."
|
| 149 |
)
|
| 150 |
if float(metrics.get("turnover_ann", 0.0)) > 100:
|
| 151 |
flags.append(
|
|
|
|
| 153 |
"retail execution can absorb without moving the modelled fills."
|
| 154 |
)
|
| 155 |
|
| 156 |
+
# Portfolio-only checks. Style exposure you could buy in an ETF is not
|
| 157 |
+
# alpha, and a universe with no failures in it is not a universe.
|
| 158 |
+
if attribution and attribution.get("available"):
|
| 159 |
+
if not attribution.get("alpha_significant"):
|
| 160 |
+
flags.append(
|
| 161 |
+
f"Style regression leaves no significant alpha (t = "
|
| 162 |
+
f"{attribution.get('alpha_t_stat', 0):.1f}); the factors explain "
|
| 163 |
+
f"{attribution.get('r_squared', 0):.0%} of returns"
|
| 164 |
+
+ (
|
| 165 |
+
f", mostly {attribution['dominant_factor']} exposure."
|
| 166 |
+
if attribution.get("dominant_factor")
|
| 167 |
+
else "."
|
| 168 |
+
)
|
| 169 |
+
)
|
| 170 |
+
score = min(score, 65.0)
|
| 171 |
+
if float(attribution.get("r_squared", 0.0)) > 0.9:
|
| 172 |
+
flags.append(
|
| 173 |
+
"Over 90% of the return variation is explained by simple style factors — "
|
| 174 |
+
"this book is a repackaged index."
|
| 175 |
+
)
|
| 176 |
+
if survivorship is not None and getattr(survivorship, "biased", False):
|
| 177 |
+
flags.append(getattr(survivorship, "note", "Universe appears survivorship-biased."))
|
| 178 |
+
score = min(score, 60.0)
|
| 179 |
+
|
| 180 |
+
score = float(np.clip(score, 0.0, 100.0))
|
| 181 |
grade, headline = next((g, h) for threshold, g, h in GRADES if score >= threshold)
|
| 182 |
|
| 183 |
if score >= 70:
|
| 184 |
verdict = (
|
| 185 |
+
f"Grade {grade}. {headline}. The edge is still there after the permutation test, "
|
| 186 |
"after charging for every variant tried, and after walking it forward."
|
| 187 |
)
|
| 188 |
elif score >= 55:
|
|
|
|
| 197 |
)
|
| 198 |
else:
|
| 199 |
verdict = (
|
| 200 |
+
f"Grade {grade}. {headline}. The null — {permutation_label} — produces results "
|
| 201 |
+
"like this often enough that there is nothing here to trade."
|
| 202 |
)
|
| 203 |
|
| 204 |
return {
|
|
@@ -19,6 +19,8 @@ import pandas as pd
|
|
| 19 |
from algotrader import __version__
|
| 20 |
from algotrader.charts import (
|
| 21 |
arena_chart,
|
|
|
|
|
|
|
| 22 |
drawdown_chart,
|
| 23 |
empty_figure,
|
| 24 |
equity_chart,
|
|
@@ -26,16 +28,22 @@ from algotrader.charts import (
|
|
| 26 |
permutation_chart,
|
| 27 |
score_chart,
|
| 28 |
walkforward_chart,
|
|
|
|
| 29 |
)
|
|
|
|
| 30 |
from algotrader.data import DEFAULT_UNIVERSE
|
| 31 |
from algotrader.lab import LabConfig, run_arena, run_lab
|
|
|
|
|
|
|
| 32 |
from algotrader.strategies import REGISTRY, get_strategy
|
| 33 |
|
| 34 |
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
|
| 35 |
logger = logging.getLogger("app")
|
| 36 |
|
| 37 |
MAX_PARAMS = 3
|
|
|
|
| 38 |
STRATEGY_CHOICES = [(s.name, key) for key, s in REGISTRY.items()]
|
|
|
|
| 39 |
|
| 40 |
GRADE_COLORS = {
|
| 41 |
"A": "#0ca30c",
|
|
@@ -165,6 +173,207 @@ def _tiles(report) -> str:
|
|
| 165 |
return f'<div class="tiles">{tiles}</div>'
|
| 166 |
|
| 167 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 168 |
def _detail_markdown(report) -> str:
|
| 169 |
dsr, wf, pbo, stress = report.dsr, report.walkforward, report.pbo, report.cost_stress
|
| 170 |
mtr = dsr.get("min_track_record_years", float("inf"))
|
|
@@ -512,6 +721,86 @@ def build_app() -> gr.Blocks:
|
|
| 512 |
],
|
| 513 |
)
|
| 514 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 515 |
with gr.Tab("Arena"):
|
| 516 |
gr.Markdown(
|
| 517 |
"Race every strategy on the same market, ranked by **evidence** rather than "
|
|
@@ -544,6 +833,7 @@ def build_app() -> gr.Blocks:
|
|
| 544 |
)
|
| 545 |
|
| 546 |
demo.load(fn=param_controls, inputs=strategy, outputs=param_sliders)
|
|
|
|
| 547 |
|
| 548 |
return demo
|
| 549 |
|
|
|
|
| 19 |
from algotrader import __version__
|
| 20 |
from algotrader.charts import (
|
| 21 |
arena_chart,
|
| 22 |
+
attribution_chart,
|
| 23 |
+
cross_permutation_chart,
|
| 24 |
drawdown_chart,
|
| 25 |
empty_figure,
|
| 26 |
equity_chart,
|
|
|
|
| 28 |
permutation_chart,
|
| 29 |
score_chart,
|
| 30 |
walkforward_chart,
|
| 31 |
+
weights_chart,
|
| 32 |
)
|
| 33 |
+
from algotrader.cross_sectional import XS_REGISTRY, get_xs_strategy
|
| 34 |
from algotrader.data import DEFAULT_UNIVERSE
|
| 35 |
from algotrader.lab import LabConfig, run_arena, run_lab
|
| 36 |
+
from algotrader.portfolio_lab import DEFAULT_UNIVERSE as PORTFOLIO_UNIVERSE
|
| 37 |
+
from algotrader.portfolio_lab import PortfolioLabConfig, run_portfolio_lab
|
| 38 |
from algotrader.strategies import REGISTRY, get_strategy
|
| 39 |
|
| 40 |
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
|
| 41 |
logger = logging.getLogger("app")
|
| 42 |
|
| 43 |
MAX_PARAMS = 3
|
| 44 |
+
MAX_XS_PARAMS = 4
|
| 45 |
STRATEGY_CHOICES = [(s.name, key) for key, s in REGISTRY.items()]
|
| 46 |
+
XS_STRATEGY_CHOICES = [(s.name, key) for key, s in XS_REGISTRY.items()]
|
| 47 |
|
| 48 |
GRADE_COLORS = {
|
| 49 |
"A": "#0ca30c",
|
|
|
|
| 173 |
return f'<div class="tiles">{tiles}</div>'
|
| 174 |
|
| 175 |
|
| 176 |
+
def xs_param_controls(strategy_key: str):
|
| 177 |
+
"""Re-label the shared portfolio sliders for the selected strategy."""
|
| 178 |
+
strategy = get_xs_strategy(strategy_key)
|
| 179 |
+
updates = []
|
| 180 |
+
for i in range(MAX_XS_PARAMS):
|
| 181 |
+
if i < len(strategy.params):
|
| 182 |
+
spec = strategy.params[i]
|
| 183 |
+
updates.append(
|
| 184 |
+
gr.update(
|
| 185 |
+
visible=True,
|
| 186 |
+
label=spec.label,
|
| 187 |
+
value=spec.cast(spec.default),
|
| 188 |
+
minimum=spec.minimum if spec.minimum is not None else min(spec.grid),
|
| 189 |
+
maximum=spec.maximum if spec.maximum is not None else max(spec.grid),
|
| 190 |
+
step=spec.step or (1 if spec.kind == "int" else 0.05),
|
| 191 |
+
)
|
| 192 |
+
)
|
| 193 |
+
else:
|
| 194 |
+
updates.append(gr.update(visible=False))
|
| 195 |
+
return tuple(updates)
|
| 196 |
+
|
| 197 |
+
|
| 198 |
+
def _portfolio_card(report) -> str:
|
| 199 |
+
v = report.verdict
|
| 200 |
+
color = GRADE_COLORS.get(v["grade"], "#898781")
|
| 201 |
+
panel = report.panel
|
| 202 |
+
survivorship = report.survivorship
|
| 203 |
+
|
| 204 |
+
provenance = (
|
| 205 |
+
f'<div class="provenance">{len(panel.symbols)} symbols · '
|
| 206 |
+
f"{panel.index[0].date()} to {panel.index[-1].date()} · {len(panel):,} bars · "
|
| 207 |
+
f"rebalance {report.config.rebalance}</div>"
|
| 208 |
+
)
|
| 209 |
+
if panel.note:
|
| 210 |
+
provenance += f'<div class="provenance sim">⚠ {panel.note}</div>'
|
| 211 |
+
# The survivorship note is already in the flag list when it is a problem;
|
| 212 |
+
# repeating it under the tiles just makes the card noisy.
|
| 213 |
+
|
| 214 |
+
flags = "".join(f"<li>{f}</li>" for f in v["flags"])
|
| 215 |
+
flags_html = f'<ul class="flags">{flags}</ul>' if flags else ""
|
| 216 |
+
|
| 217 |
+
return f"""
|
| 218 |
+
<div class="score-card">
|
| 219 |
+
<div class="score-badge">
|
| 220 |
+
<div class="grade" style="color:{color}">{v['grade']}</div>
|
| 221 |
+
<div class="num">{v['score']} / 100</div>
|
| 222 |
+
</div>
|
| 223 |
+
<div class="score-body">
|
| 224 |
+
<h3>{report.strategy.name} across {len(panel.symbols)} names</h3>
|
| 225 |
+
<p>{v['verdict']}</p>
|
| 226 |
+
</div>
|
| 227 |
+
</div>
|
| 228 |
+
{flags_html}
|
| 229 |
+
{provenance}
|
| 230 |
+
"""
|
| 231 |
+
|
| 232 |
+
|
| 233 |
+
def _portfolio_tiles(report) -> str:
|
| 234 |
+
m = report.backtest.metrics
|
| 235 |
+
b = report.backtest.benchmark_metrics
|
| 236 |
+
perm = report.permutation
|
| 237 |
+
attribution = report.attribution
|
| 238 |
+
pbo = report.pbo.get("pbo")
|
| 239 |
+
|
| 240 |
+
cells = [
|
| 241 |
+
("Total return", _fmt_pct(m.get("total_return", 0)), f"equal weight {_fmt_pct(b.get('total_return', 0))}"),
|
| 242 |
+
("Sharpe", f"{m.get('sharpe', 0):.2f}", f"equal weight {b.get('sharpe', 0):.2f}"),
|
| 243 |
+
("Max drawdown", _fmt_pct(m.get("max_drawdown", 0)), f"{m.get('time_under_water_yrs', 0):.1f}y under water"),
|
| 244 |
+
("Name-shuffle p", f"{perm.p_value:.3f}" if perm else "—", "vs same book, random names"),
|
| 245 |
+
("Deflated Sharpe", f"{report.dsr.get('dsr', 0):.2f}", f"after {report.trials.get('n', 1)} variants"),
|
| 246 |
+
(
|
| 247 |
+
"Style alpha",
|
| 248 |
+
f"{attribution.get('alpha_annual', 0) * 100:,.1f}%" if attribution.get("available") else "n/a",
|
| 249 |
+
f"t = {attribution.get('alpha_t_stat', 0):.1f}" if attribution.get("available") else "not available",
|
| 250 |
+
),
|
| 251 |
+
("Overfit prob.", f"{pbo:.0%}" if pbo is not None and pbo == pbo else "n/a", "in-sample winner fails OOS"),
|
| 252 |
+
("Gross / net", f"{m.get('gross_exposure', 0):.2f}", f"net {m.get('net_exposure', 0):+.2f}"),
|
| 253 |
+
("Avg positions", f"{m.get('avg_positions', 0):.1f}", f"{m.get('turnover_ann', 0):.1f}x turnover/yr"),
|
| 254 |
+
(
|
| 255 |
+
"Survivorship",
|
| 256 |
+
f"{report.survivorship.survival_rate:.0%}",
|
| 257 |
+
f"{report.survivorship.n_delisted} of {report.survivorship.n_symbols} delisted",
|
| 258 |
+
),
|
| 259 |
+
]
|
| 260 |
+
tiles = "".join(
|
| 261 |
+
f'<div class="tile"><div class="label">{label}</div>'
|
| 262 |
+
f'<div class="value">{value}</div><div class="sub">{sub}</div></div>'
|
| 263 |
+
for label, value, sub in cells
|
| 264 |
+
)
|
| 265 |
+
return f'<div class="tiles">{tiles}</div>'
|
| 266 |
+
|
| 267 |
+
|
| 268 |
+
def _portfolio_detail(report) -> str:
|
| 269 |
+
attribution, survivorship, wf = report.attribution, report.survivorship, report.walkforward
|
| 270 |
+
lines = ["### Reading the evidence", ""]
|
| 271 |
+
|
| 272 |
+
if report.permutation:
|
| 273 |
+
lines += [
|
| 274 |
+
f"**Did it pick the right names?** We rebuilt the book "
|
| 275 |
+
f"{report.permutation.n_permutations} times, keeping every date's gross exposure, net "
|
| 276 |
+
f"exposure and position count exactly as they were, and only randomising *which* asset "
|
| 277 |
+
f"got which weight. The real book's Sharpe of {report.permutation.observed:.2f} sits at "
|
| 278 |
+
f"the {report.permutation.percentile:.0f}th percentile of that null "
|
| 279 |
+
f"(p = {report.permutation.p_value:.3f}). This test deliberately ignores trading costs: "
|
| 280 |
+
"a shuffled book churns far more than a real one, and charging it for that would "
|
| 281 |
+
"flatter the strategy for reasons unrelated to skill.",
|
| 282 |
+
"",
|
| 283 |
+
]
|
| 284 |
+
if attribution.get("available"):
|
| 285 |
+
lines += [f"**Is it alpha or is it beta?** {attribution['note']}", ""]
|
| 286 |
+
lines += [f"**Survivorship.** {survivorship.note}", ""]
|
| 287 |
+
if survivorship.delisted_symbols:
|
| 288 |
+
lines += [f"Stopped trading during the sample: `{'`, `'.join(survivorship.delisted_symbols)}`.", ""]
|
| 289 |
+
if wf.get("folds"):
|
| 290 |
+
lines += [
|
| 291 |
+
f"**Walk-forward.** Across {len(wf['folds'])} folds the tuned in-sample Sharpe averaged "
|
| 292 |
+
f"{wf.get('mean_is_sharpe', 0):.2f} against {wf.get('mean_oos_sharpe', 0):.2f} blind out "
|
| 293 |
+
f"of sample — {wf.get('efficiency', 0):.0%} efficiency, with "
|
| 294 |
+
f"{wf.get('oos_win_rate', 0):.0%} of folds profitable.",
|
| 295 |
+
"",
|
| 296 |
+
]
|
| 297 |
+
lines += [
|
| 298 |
+
f"**Costs.** Sharpe is {report.cost_stress.get('sharpe_1x', 0):.2f} at the modelled friction "
|
| 299 |
+
f"and {report.cost_stress.get('sharpe_3x', 0):.2f} at triple it "
|
| 300 |
+
f"({report.cost_stress.get('ratio', 0):.0%} retained), on turnover of "
|
| 301 |
+
f"{report.backtest.metrics.get('turnover_ann', 0):.1f}x a year.",
|
| 302 |
+
"",
|
| 303 |
+
"> Past performance, simulated or otherwise, does not predict future returns. "
|
| 304 |
+
"This is research tooling, not investment advice.",
|
| 305 |
+
]
|
| 306 |
+
return "\n".join(lines)
|
| 307 |
+
|
| 308 |
+
|
| 309 |
+
def analyse_portfolio(
|
| 310 |
+
symbols: str,
|
| 311 |
+
start: str,
|
| 312 |
+
end: str,
|
| 313 |
+
strategy_key: str,
|
| 314 |
+
p1: float,
|
| 315 |
+
p2: float,
|
| 316 |
+
p3: float,
|
| 317 |
+
p4: float,
|
| 318 |
+
rebalance: str,
|
| 319 |
+
commission: float,
|
| 320 |
+
slippage: float,
|
| 321 |
+
allow_short: bool,
|
| 322 |
+
gross_leverage: float,
|
| 323 |
+
max_weight: float,
|
| 324 |
+
n_permutations: int,
|
| 325 |
+
progress=gr.Progress(),
|
| 326 |
+
):
|
| 327 |
+
"""Portfolio Lab handler. Like the single-asset one, it never raises into the UI."""
|
| 328 |
+
try:
|
| 329 |
+
universe = [s.strip().upper() for s in (symbols or "").replace("\n", ",").split(",") if s.strip()]
|
| 330 |
+
if len(universe) < 4:
|
| 331 |
+
raise ValueError(
|
| 332 |
+
"A cross-sectional strategy needs at least 4 symbols — it ranks names against "
|
| 333 |
+
"each other, and there is nothing to rank in a list this short."
|
| 334 |
+
)
|
| 335 |
+
strategy = get_xs_strategy(strategy_key)
|
| 336 |
+
values = (p1, p2, p3, p4)
|
| 337 |
+
params = {
|
| 338 |
+
spec.name: spec.cast(values[i])
|
| 339 |
+
for i, spec in enumerate(strategy.params[:MAX_XS_PARAMS])
|
| 340 |
+
}
|
| 341 |
+
cfg = PortfolioLabConfig(
|
| 342 |
+
symbols=universe,
|
| 343 |
+
start=start or "2015-01-01",
|
| 344 |
+
end=end or None,
|
| 345 |
+
strategy=strategy_key,
|
| 346 |
+
params=params,
|
| 347 |
+
commission_bps=float(commission),
|
| 348 |
+
slippage_bps=float(slippage),
|
| 349 |
+
allow_short=bool(allow_short),
|
| 350 |
+
gross_leverage=float(gross_leverage),
|
| 351 |
+
max_weight=float(max_weight) if max_weight else None,
|
| 352 |
+
rebalance=rebalance,
|
| 353 |
+
n_permutations=int(n_permutations),
|
| 354 |
+
)
|
| 355 |
+
report = run_portfolio_lab(cfg, progress=lambda f, m: progress(f, desc=m))
|
| 356 |
+
except Exception as exc: # noqa: BLE001
|
| 357 |
+
logger.exception("Portfolio lab run failed")
|
| 358 |
+
message = (
|
| 359 |
+
f'<div class="score-card"><div class="score-body"><h3>Could not run that</h3>'
|
| 360 |
+
f"<p>{exc}</p></div></div>"
|
| 361 |
+
)
|
| 362 |
+
blank = empty_figure("No results.")
|
| 363 |
+
return message, "", blank, blank, blank, blank, blank, ""
|
| 364 |
+
|
| 365 |
+
return (
|
| 366 |
+
_portfolio_card(report),
|
| 367 |
+
_portfolio_tiles(report),
|
| 368 |
+
cross_permutation_chart(report),
|
| 369 |
+
equity_chart(report, benchmark_label="Equal weight"),
|
| 370 |
+
attribution_chart(report.attribution),
|
| 371 |
+
weights_chart(report),
|
| 372 |
+
score_chart(report.verdict, significance_label="Picks the right names"),
|
| 373 |
+
_portfolio_detail(report),
|
| 374 |
+
)
|
| 375 |
+
|
| 376 |
+
|
| 377 |
def _detail_markdown(report) -> str:
|
| 378 |
dsr, wf, pbo, stress = report.dsr, report.walkforward, report.pbo, report.cost_stress
|
| 379 |
mtr = dsr.get("min_track_record_years", float("inf"))
|
|
|
|
| 721 |
],
|
| 722 |
)
|
| 723 |
|
| 724 |
+
with gr.Tab("Portfolio"):
|
| 725 |
+
gr.Markdown(
|
| 726 |
+
"Cross-sectional strategies rank names against each other, so they get a "
|
| 727 |
+
"harder null: we keep every date's gross exposure, net exposure and position "
|
| 728 |
+
"count exactly as they were and randomise only **which name got which "
|
| 729 |
+
"weight**. A book that beats that is picking names. One that doesn't was "
|
| 730 |
+
"being paid for style exposure you can buy in an ETF — which the factor "
|
| 731 |
+
"regression below measures directly."
|
| 732 |
+
)
|
| 733 |
+
with gr.Row():
|
| 734 |
+
with gr.Column(scale=1):
|
| 735 |
+
xs_symbols = gr.Textbox(
|
| 736 |
+
value=", ".join(PORTFOLIO_UNIVERSE),
|
| 737 |
+
label="Universe",
|
| 738 |
+
lines=3,
|
| 739 |
+
info="Comma-separated. At least 4 names — fewer cannot be ranked.",
|
| 740 |
+
)
|
| 741 |
+
with gr.Row():
|
| 742 |
+
xs_start = gr.Textbox(value="2015-01-01", label="Start", scale=1)
|
| 743 |
+
xs_end = gr.Textbox(value="", label="End", scale=1)
|
| 744 |
+
|
| 745 |
+
xs_strategy = gr.Dropdown(
|
| 746 |
+
choices=XS_STRATEGY_CHOICES, value="xs_momentum", label="Strategy"
|
| 747 |
+
)
|
| 748 |
+
xs_note = gr.Markdown(get_xs_strategy("xs_momentum").description)
|
| 749 |
+
xs_sliders = [
|
| 750 |
+
gr.Slider(label=f"Parameter {i + 1}", visible=False, minimum=0, maximum=100)
|
| 751 |
+
for i in range(MAX_XS_PARAMS)
|
| 752 |
+
]
|
| 753 |
+
xs_rebalance = gr.Radio(
|
| 754 |
+
["D", "W", "M", "Q"], value="M", label="Rebalance",
|
| 755 |
+
info="Daily rebalancing of a real book is rarely affordable.",
|
| 756 |
+
)
|
| 757 |
+
|
| 758 |
+
with gr.Accordion("Costs, limits and testing", open=False):
|
| 759 |
+
xs_commission = gr.Slider(0, 20, value=1, step=0.5, label="Commission (bps)")
|
| 760 |
+
xs_slippage = gr.Slider(0, 50, value=2, step=0.5, label="Slippage (bps)")
|
| 761 |
+
xs_short = gr.Checkbox(value=True, label="Allow short positions")
|
| 762 |
+
xs_leverage = gr.Slider(0.1, 3.0, value=1.0, step=0.1, label="Gross leverage cap")
|
| 763 |
+
xs_maxw = gr.Slider(0.0, 1.0, value=0.25, step=0.05, label="Max weight per name")
|
| 764 |
+
xs_perms = gr.Slider(
|
| 765 |
+
0, 500, value=150, step=25, label="Name shuffles",
|
| 766 |
+
)
|
| 767 |
+
|
| 768 |
+
xs_button = gr.Button("Run portfolio check", variant="primary", size="lg")
|
| 769 |
+
|
| 770 |
+
with gr.Column(scale=2):
|
| 771 |
+
xs_verdict = gr.HTML(
|
| 772 |
+
'<div class="score-card"><div class="score-body">'
|
| 773 |
+
"<h3>Nothing tested yet</h3><p>Pick a universe and a ranking rule, then "
|
| 774 |
+
"hit <b>Run portfolio check</b>.</p></div></div>"
|
| 775 |
+
)
|
| 776 |
+
xs_tiles = gr.HTML("")
|
| 777 |
+
|
| 778 |
+
xs_perm_plot = gr.Plot(value=empty_figure("The name-shuffle test appears here.", height=320))
|
| 779 |
+
xs_equity_plot = gr.Plot(value=empty_figure())
|
| 780 |
+
with gr.Row():
|
| 781 |
+
xs_attr_plot = gr.Plot(value=empty_figure(height=280))
|
| 782 |
+
xs_weights_plot = gr.Plot(value=empty_figure(height=240))
|
| 783 |
+
xs_components_plot = gr.Plot(value=empty_figure(height=260))
|
| 784 |
+
xs_detail = gr.Markdown("")
|
| 785 |
+
|
| 786 |
+
xs_strategy.change(
|
| 787 |
+
fn=xs_param_controls, inputs=xs_strategy, outputs=xs_sliders
|
| 788 |
+
).then(
|
| 789 |
+
fn=lambda k: get_xs_strategy(k).description, inputs=xs_strategy, outputs=xs_note
|
| 790 |
+
)
|
| 791 |
+
xs_button.click(
|
| 792 |
+
fn=analyse_portfolio,
|
| 793 |
+
inputs=[
|
| 794 |
+
xs_symbols, xs_start, xs_end, xs_strategy, *xs_sliders,
|
| 795 |
+
xs_rebalance, xs_commission, xs_slippage, xs_short,
|
| 796 |
+
xs_leverage, xs_maxw, xs_perms,
|
| 797 |
+
],
|
| 798 |
+
outputs=[
|
| 799 |
+
xs_verdict, xs_tiles, xs_perm_plot, xs_equity_plot,
|
| 800 |
+
xs_attr_plot, xs_weights_plot, xs_components_plot, xs_detail,
|
| 801 |
+
],
|
| 802 |
+
)
|
| 803 |
+
|
| 804 |
with gr.Tab("Arena"):
|
| 805 |
gr.Markdown(
|
| 806 |
"Race every strategy on the same market, ranked by **evidence** rather than "
|
|
|
|
| 833 |
)
|
| 834 |
|
| 835 |
demo.load(fn=param_controls, inputs=strategy, outputs=param_sliders)
|
| 836 |
+
demo.load(fn=xs_param_controls, inputs=xs_strategy, outputs=xs_sliders)
|
| 837 |
|
| 838 |
return demo
|
| 839 |
|
|
@@ -46,7 +46,8 @@ find ./algotrader -name '__pycache__' -type d -exec rm -rf {} + 2>/dev/null || t
|
|
| 46 |
mkdir -p tests
|
| 47 |
cp "$REPO_ROOT/tests/test_v2_engine.py" \
|
| 48 |
"$REPO_ROOT/tests/test_v2_validation.py" \
|
| 49 |
-
"$REPO_ROOT/tests/test_v2_strategies.py"
|
|
|
|
| 50 |
|
| 51 |
echo "==> Files staged:"
|
| 52 |
find . -path ./.git -prune -o -type f -print | sed 's|^\./| |'
|
|
|
|
| 46 |
mkdir -p tests
|
| 47 |
cp "$REPO_ROOT/tests/test_v2_engine.py" \
|
| 48 |
"$REPO_ROOT/tests/test_v2_validation.py" \
|
| 49 |
+
"$REPO_ROOT/tests/test_v2_strategies.py" \
|
| 50 |
+
"$REPO_ROOT/tests/test_v2_portfolio.py" tests/
|
| 51 |
|
| 52 |
echo "==> Files staged:"
|
| 53 |
find . -path ./.git -prune -o -type f -print | sed 's|^\./| |'
|
|
@@ -132,7 +132,7 @@ class TestDataIngestion:
|
|
| 132 |
|
| 133 |
assert isinstance(result, pd.DataFrame)
|
| 134 |
assert len(result) == len(sample_csv_data)
|
| 135 |
-
assert result['timestamp']
|
| 136 |
|
| 137 |
finally:
|
| 138 |
os.unlink(tmp_file.name)
|
|
@@ -289,7 +289,7 @@ class TestDataIngestion:
|
|
| 289 |
result = _load_csv_data(config)
|
| 290 |
|
| 291 |
# Check that timestamp is converted to datetime
|
| 292 |
-
assert result['timestamp']
|
| 293 |
|
| 294 |
finally:
|
| 295 |
os.unlink(tmp_file.name)
|
|
|
|
| 132 |
|
| 133 |
assert isinstance(result, pd.DataFrame)
|
| 134 |
assert len(result) == len(sample_csv_data)
|
| 135 |
+
assert pd.api.types.is_datetime64_any_dtype(result['timestamp'])
|
| 136 |
|
| 137 |
finally:
|
| 138 |
os.unlink(tmp_file.name)
|
|
|
|
| 289 |
result = _load_csv_data(config)
|
| 290 |
|
| 291 |
# Check that timestamp is converted to datetime
|
| 292 |
+
assert pd.api.types.is_datetime64_any_dtype(result['timestamp'])
|
| 293 |
|
| 294 |
finally:
|
| 295 |
os.unlink(tmp_file.name)
|
|
@@ -56,8 +56,8 @@ class TestSyntheticDataGenerator:
|
|
| 56 |
assert col in df.columns
|
| 57 |
|
| 58 |
# Check data types
|
| 59 |
-
assert df['timestamp']
|
| 60 |
-
assert df['symbol']
|
| 61 |
assert df['open'].dtype in ['float64', 'float32']
|
| 62 |
assert df['high'].dtype in ['float64', 'float32']
|
| 63 |
assert df['low'].dtype in ['float64', 'float32']
|
|
|
|
| 56 |
assert col in df.columns
|
| 57 |
|
| 58 |
# Check data types
|
| 59 |
+
assert pd.api.types.is_datetime64_any_dtype(df['timestamp'])
|
| 60 |
+
assert pd.api.types.is_string_dtype(df['symbol'])
|
| 61 |
assert df['open'].dtype in ['float64', 'float32']
|
| 62 |
assert df['high'].dtype in ['float64', 'float32']
|
| 63 |
assert df['low'].dtype in ['float64', 'float32']
|
|
@@ -0,0 +1,377 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Multi-asset engine, panel, cross-sectional strategies and their null."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import numpy as np
|
| 6 |
+
import pandas as pd
|
| 7 |
+
import pytest
|
| 8 |
+
|
| 9 |
+
from algotrader.attribution import build_style_factors, factor_attribution
|
| 10 |
+
from algotrader.cross_sectional import XS_REGISTRY, get_xs_strategy, scores_to_weights
|
| 11 |
+
from algotrader.data import simulate_ohlcv
|
| 12 |
+
from algotrader.engine import run_backtest
|
| 13 |
+
from algotrader.panel import Panel, load_panel
|
| 14 |
+
from algotrader.portfolio import (
|
| 15 |
+
normalise_weights,
|
| 16 |
+
rebalance_schedule,
|
| 17 |
+
run_portfolio_backtest,
|
| 18 |
+
)
|
| 19 |
+
from algotrader.types import CostModel
|
| 20 |
+
from algotrader.validation.cross_permutation import (
|
| 21 |
+
cross_sectional_permutation_test,
|
| 22 |
+
permute_within_dates,
|
| 23 |
+
)
|
| 24 |
+
|
| 25 |
+
XS_KEYS = sorted(XS_REGISTRY)
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def make_panel(n_assets=8, n=1200, drift_sd=0.0, seed=1, common_vol=0.008, idio=0.012) -> Panel:
|
| 29 |
+
"""A synthetic universe. ``drift_sd`` controls genuine cross-sectional structure."""
|
| 30 |
+
rng = np.random.default_rng(seed)
|
| 31 |
+
index = pd.date_range("2016-01-01", periods=n, freq="B")
|
| 32 |
+
drift = rng.normal(0, drift_sd, n_assets)
|
| 33 |
+
returns = (
|
| 34 |
+
rng.normal(0.0002, common_vol, n)[:, None]
|
| 35 |
+
+ rng.normal(0, idio, (n, n_assets))
|
| 36 |
+
+ drift[None, :]
|
| 37 |
+
)
|
| 38 |
+
close = 100 * np.exp(np.cumsum(returns, axis=0))
|
| 39 |
+
columns = [f"A{i}" for i in range(n_assets)]
|
| 40 |
+
|
| 41 |
+
def frame(values):
|
| 42 |
+
return pd.DataFrame(values, index=index, columns=columns)
|
| 43 |
+
|
| 44 |
+
return Panel(
|
| 45 |
+
fields={
|
| 46 |
+
"open": frame(close),
|
| 47 |
+
"high": frame(close * 1.004),
|
| 48 |
+
"low": frame(close * 0.996),
|
| 49 |
+
"close": frame(close),
|
| 50 |
+
"volume": frame(np.full_like(close, 1e6)),
|
| 51 |
+
},
|
| 52 |
+
sources={c: "synthetic" for c in columns},
|
| 53 |
+
)
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
@pytest.fixture(scope="module")
|
| 57 |
+
def panel() -> Panel:
|
| 58 |
+
return make_panel()
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
class TestPanel:
|
| 62 |
+
def test_fields_are_aligned(self, panel):
|
| 63 |
+
assert panel.shape == (1200, 8)
|
| 64 |
+
for name in ("open", "high", "low", "close", "volume"):
|
| 65 |
+
assert panel.fields[name].index.equals(panel.index)
|
| 66 |
+
assert list(panel.fields[name].columns) == panel.symbols
|
| 67 |
+
|
| 68 |
+
def test_misaligned_fields_are_rejected(self, panel):
|
| 69 |
+
broken = dict(panel.fields)
|
| 70 |
+
broken["high"] = broken["high"].iloc[:-5]
|
| 71 |
+
with pytest.raises(ValueError, match="not aligned"):
|
| 72 |
+
Panel(fields=broken)
|
| 73 |
+
|
| 74 |
+
def test_missing_field_is_rejected(self, panel):
|
| 75 |
+
with pytest.raises(ValueError, match="missing field"):
|
| 76 |
+
Panel(fields={"close": panel.close})
|
| 77 |
+
|
| 78 |
+
def test_missing_data_is_not_forward_filled(self):
|
| 79 |
+
"""Filling a gap invents liquidity that never existed."""
|
| 80 |
+
df = simulate_ohlcv("AAPL", "2018-01-01", "2022-01-01")
|
| 81 |
+
gapped = df.drop(df.index[100:140])
|
| 82 |
+
built = Panel.from_frames({"AAPL": gapped, "MSFT": df})
|
| 83 |
+
assert built.close["AAPL"].isna().sum() == 40
|
| 84 |
+
|
| 85 |
+
def test_tradable_requires_two_consecutive_prices(self, panel):
|
| 86 |
+
assert not panel.tradable().iloc[0].any()
|
| 87 |
+
assert panel.tradable().iloc[1:].all().all()
|
| 88 |
+
|
| 89 |
+
def test_returns_are_masked_where_untradable(self, panel):
|
| 90 |
+
assert panel.returns().iloc[0].isna().all()
|
| 91 |
+
|
| 92 |
+
def test_delisting_is_detected(self):
|
| 93 |
+
df = simulate_ohlcv("AAPL", "2016-01-01", "2022-01-01")
|
| 94 |
+
dead = df.iloc[: len(df) // 2]
|
| 95 |
+
built = Panel.from_frames({"ALIVE": df, "DEAD": dead})
|
| 96 |
+
report = built.survivorship()
|
| 97 |
+
assert report.n_delisted == 1
|
| 98 |
+
assert "DEAD" in report.delisted_symbols
|
| 99 |
+
assert not report.biased
|
| 100 |
+
|
| 101 |
+
def test_all_survivors_is_flagged_as_biased(self, panel):
|
| 102 |
+
report = panel.survivorship()
|
| 103 |
+
assert report.survival_rate == 1.0
|
| 104 |
+
assert report.biased
|
| 105 |
+
assert "upper bound" in report.note
|
| 106 |
+
|
| 107 |
+
def test_load_panel_skips_symbols_without_history(self):
|
| 108 |
+
built = load_panel(["SPY", "AAPL"], "2018-01-01", "2022-01-01", source="synthetic")
|
| 109 |
+
assert set(built.symbols) == {"SPY", "AAPL"}
|
| 110 |
+
assert all(s == "synthetic" for s in built.sources.values())
|
| 111 |
+
|
| 112 |
+
def test_select_and_slice(self, panel):
|
| 113 |
+
subset = panel.select(["A0", "A1"])
|
| 114 |
+
assert subset.symbols == ["A0", "A1"]
|
| 115 |
+
sliced = panel.slice(panel.index[10], panel.index[50])
|
| 116 |
+
assert len(sliced) == 41
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
class TestPortfolioEngine:
|
| 120 |
+
def test_matches_single_asset_engine_exactly(self):
|
| 121 |
+
"""One column through the matrix engine must equal the fast path."""
|
| 122 |
+
df = simulate_ohlcv("SPY", "2018-01-01", "2023-01-01")
|
| 123 |
+
single = Panel.from_frames({"SPY": df})
|
| 124 |
+
costs = CostModel(1, 2, 50)
|
| 125 |
+
n = len(df)
|
| 126 |
+
cases = {
|
| 127 |
+
"buy_and_hold": np.ones(n),
|
| 128 |
+
"flip": np.tile([1.0, -1.0], n // 2 + 1)[:n],
|
| 129 |
+
"long_flat": np.tile([1.0, 0.0], n // 2 + 1)[:n],
|
| 130 |
+
"fractional": np.full(n, 0.5),
|
| 131 |
+
}
|
| 132 |
+
for name, target in cases.items():
|
| 133 |
+
portfolio = run_portfolio_backtest(
|
| 134 |
+
single, pd.DataFrame({"SPY": target}, index=df.index), costs=costs
|
| 135 |
+
)
|
| 136 |
+
direct = run_backtest(df, pd.Series(target, index=df.index), costs=costs)
|
| 137 |
+
np.testing.assert_allclose(
|
| 138 |
+
portfolio.returns.to_numpy(), direct.returns.to_numpy(),
|
| 139 |
+
atol=1e-12, err_msg=f"mismatch for {name}",
|
| 140 |
+
)
|
| 141 |
+
|
| 142 |
+
def test_holding_still_costs_nothing_but_drift_does(self, panel):
|
| 143 |
+
"""A full-notional long needs no rebalancing; a short does."""
|
| 144 |
+
costs = CostModel(10, 10, 0)
|
| 145 |
+
long_only = pd.DataFrame(1.0 / len(panel.symbols), index=panel.index, columns=panel.symbols)
|
| 146 |
+
result = run_portfolio_backtest(panel, long_only, costs=costs, gross_leverage=1.0)
|
| 147 |
+
# Equal-weight across many names still drifts apart, so turnover > 0...
|
| 148 |
+
assert result.metrics["turnover_ann"] > 0
|
| 149 |
+
# ...but a single full-notional position does not.
|
| 150 |
+
one = pd.DataFrame(0.0, index=panel.index, columns=panel.symbols)
|
| 151 |
+
one["A0"] = 1.0
|
| 152 |
+
assert run_portfolio_backtest(panel, one, costs=costs).costs.sum() == pytest.approx(
|
| 153 |
+
20 / 1e4, rel=1e-6
|
| 154 |
+
)
|
| 155 |
+
|
| 156 |
+
def test_rebalancing_less_often_lowers_turnover(self, panel):
|
| 157 |
+
weights = get_xs_strategy("equal_weight").generate(panel)
|
| 158 |
+
turnovers = []
|
| 159 |
+
for frequency in ("D", "M", "Q"):
|
| 160 |
+
result = run_portfolio_backtest(
|
| 161 |
+
panel, weights, costs=CostModel(1, 2, 0),
|
| 162 |
+
rebalance_on=rebalance_schedule(panel.index, frequency),
|
| 163 |
+
)
|
| 164 |
+
turnovers.append(result.metrics["turnover_ann"])
|
| 165 |
+
assert turnovers[0] > turnovers[1] > turnovers[2]
|
| 166 |
+
|
| 167 |
+
def test_gross_leverage_is_capped(self, panel):
|
| 168 |
+
weights = pd.DataFrame(1.0, index=panel.index, columns=panel.symbols)
|
| 169 |
+
result = run_portfolio_backtest(panel, weights, gross_leverage=1.0)
|
| 170 |
+
assert result.weights.abs().sum(axis=1).max() <= 1.0 + 1e-9
|
| 171 |
+
|
| 172 |
+
def test_leverage_is_scaled_down_never_up(self, panel):
|
| 173 |
+
small = pd.DataFrame(0.01, index=panel.index, columns=panel.symbols)
|
| 174 |
+
result = run_portfolio_backtest(panel, small, gross_leverage=1.0)
|
| 175 |
+
assert result.weights.abs().sum(axis=1).max() < 0.2
|
| 176 |
+
|
| 177 |
+
def test_per_name_cap_is_applied(self, panel):
|
| 178 |
+
weights = pd.DataFrame(0.0, index=panel.index, columns=panel.symbols)
|
| 179 |
+
weights["A0"] = 1.0
|
| 180 |
+
result = run_portfolio_backtest(panel, weights, max_weight=0.1)
|
| 181 |
+
assert result.weights.abs().max().max() <= 0.1 + 1e-9
|
| 182 |
+
|
| 183 |
+
def test_shorts_are_blocked_when_disallowed(self, panel):
|
| 184 |
+
weights = pd.DataFrame(-0.1, index=panel.index, columns=panel.symbols)
|
| 185 |
+
result = run_portfolio_backtest(panel, weights, allow_short=False)
|
| 186 |
+
assert (result.weights >= 0).all().all()
|
| 187 |
+
|
| 188 |
+
def test_delisted_names_cannot_be_held(self):
|
| 189 |
+
df = simulate_ohlcv("AAPL", "2016-01-01", "2022-01-01")
|
| 190 |
+
built = Panel.from_frames({"ALIVE": df, "DEAD": df.iloc[: len(df) // 2]})
|
| 191 |
+
weights = pd.DataFrame(0.5, index=built.index, columns=built.symbols)
|
| 192 |
+
result = run_portfolio_backtest(built, weights)
|
| 193 |
+
after_death = built.close["DEAD"].last_valid_index()
|
| 194 |
+
assert result.held.loc[result.held.index > after_death, "DEAD"].abs().max() == 0.0
|
| 195 |
+
|
| 196 |
+
def test_lag_zero_is_rejected(self, panel):
|
| 197 |
+
with pytest.raises(ValueError, match="lag"):
|
| 198 |
+
run_portfolio_backtest(panel, pd.DataFrame(0.1, index=panel.index, columns=panel.symbols), lag=0)
|
| 199 |
+
|
| 200 |
+
def test_future_bars_cannot_change_past_equity(self, panel):
|
| 201 |
+
weights = get_xs_strategy("xs_momentum").generate(panel)
|
| 202 |
+
full = run_portfolio_backtest(panel, weights)
|
| 203 |
+
cut = run_portfolio_backtest(
|
| 204 |
+
panel.slice(panel.index[0], panel.index[799]), weights.iloc[:800]
|
| 205 |
+
)
|
| 206 |
+
np.testing.assert_allclose(
|
| 207 |
+
full.equity.iloc[:800].to_numpy(), cut.equity.to_numpy(), rtol=1e-10
|
| 208 |
+
)
|
| 209 |
+
|
| 210 |
+
def test_attribution_sums_to_gross_return(self, panel):
|
| 211 |
+
weights = get_xs_strategy("equal_weight").generate(panel)
|
| 212 |
+
result = run_portfolio_backtest(panel, weights, costs=CostModel(0, 0, 0))
|
| 213 |
+
assert result.attribution().sum() == pytest.approx(result.gross_returns.sum(), rel=1e-9)
|
| 214 |
+
|
| 215 |
+
def test_portfolio_metrics_are_reported(self, panel):
|
| 216 |
+
result = run_portfolio_backtest(panel, get_xs_strategy("xs_momentum").generate(panel))
|
| 217 |
+
for key in ("gross_exposure", "net_exposure", "avg_positions", "concentration_hhi"):
|
| 218 |
+
assert key in result.metrics
|
| 219 |
+
|
| 220 |
+
|
| 221 |
+
class TestCrossSectionalStrategies:
|
| 222 |
+
@pytest.mark.parametrize("key", XS_KEYS)
|
| 223 |
+
def test_weights_are_well_formed(self, panel, key):
|
| 224 |
+
weights = get_xs_strategy(key).generate(panel)
|
| 225 |
+
assert weights.shape == panel.shape
|
| 226 |
+
assert weights.notna().all().all()
|
| 227 |
+
assert weights.abs().sum(axis=1).max() <= 1.0 + 1e-9
|
| 228 |
+
|
| 229 |
+
@pytest.mark.parametrize("key", XS_KEYS)
|
| 230 |
+
def test_weights_are_causal(self, panel, key):
|
| 231 |
+
cut = 700
|
| 232 |
+
full = get_xs_strategy(key).generate(panel).iloc[:cut]
|
| 233 |
+
partial = get_xs_strategy(key).generate(panel.slice(panel.index[0], panel.index[cut - 1]))
|
| 234 |
+
pd.testing.assert_frame_equal(full, partial, rtol=1e-9)
|
| 235 |
+
|
| 236 |
+
def test_long_short_books_are_dollar_neutral(self, panel):
|
| 237 |
+
weights = get_xs_strategy("xs_momentum").generate(panel)
|
| 238 |
+
active = weights[weights.abs().sum(axis=1) > 1e-9]
|
| 239 |
+
assert active.sum(axis=1).abs().max() < 1e-9
|
| 240 |
+
|
| 241 |
+
def test_long_only_uses_the_whole_book(self):
|
| 242 |
+
scores = pd.DataFrame(np.arange(40).reshape(4, 10).astype(float))
|
| 243 |
+
weights = scores_to_weights(scores, long_frac=0.3, long_only=True)
|
| 244 |
+
assert weights.sum(axis=1).round(9).eq(1.0).all()
|
| 245 |
+
assert (weights >= 0).all().all()
|
| 246 |
+
|
| 247 |
+
def test_thin_cross_sections_are_skipped(self):
|
| 248 |
+
scores = pd.DataFrame(np.random.default_rng(0).normal(size=(10, 3)))
|
| 249 |
+
assert scores_to_weights(scores).abs().sum(axis=1).max() == 0.0
|
| 250 |
+
|
| 251 |
+
def test_equal_weight_holds_everything(self, panel):
|
| 252 |
+
weights = get_xs_strategy("equal_weight").generate(panel)
|
| 253 |
+
assert (weights.iloc[-1] > 0).all()
|
| 254 |
+
|
| 255 |
+
def test_unknown_strategy_names_alternatives(self):
|
| 256 |
+
with pytest.raises(KeyError, match="Available"):
|
| 257 |
+
get_xs_strategy("nope")
|
| 258 |
+
|
| 259 |
+
|
| 260 |
+
class TestCrossSectionalNull:
|
| 261 |
+
def test_permutation_preserves_the_shape_of_the_book(self, panel):
|
| 262 |
+
weights = get_xs_strategy("xs_momentum").generate(panel)
|
| 263 |
+
investable = panel.close.notna()
|
| 264 |
+
shuffled = permute_within_dates(weights, investable, np.random.default_rng(0))
|
| 265 |
+
|
| 266 |
+
np.testing.assert_allclose(
|
| 267 |
+
np.sort(weights.to_numpy(), axis=1), np.sort(shuffled.to_numpy(), axis=1), atol=1e-12
|
| 268 |
+
)
|
| 269 |
+
np.testing.assert_allclose(
|
| 270 |
+
weights.abs().sum(axis=1).to_numpy(), shuffled.abs().sum(axis=1).to_numpy(), atol=1e-12
|
| 271 |
+
)
|
| 272 |
+
np.testing.assert_allclose(
|
| 273 |
+
weights.sum(axis=1).to_numpy(), shuffled.sum(axis=1).to_numpy(), atol=1e-12
|
| 274 |
+
)
|
| 275 |
+
|
| 276 |
+
def test_permutation_actually_reassigns(self, panel):
|
| 277 |
+
weights = get_xs_strategy("xs_momentum").generate(panel)
|
| 278 |
+
shuffled = permute_within_dates(weights, panel.close.notna(), np.random.default_rng(0))
|
| 279 |
+
assert not np.allclose(weights.to_numpy(), shuffled.to_numpy())
|
| 280 |
+
|
| 281 |
+
def test_non_investable_names_stay_empty(self):
|
| 282 |
+
df = simulate_ohlcv("AAPL", "2016-01-01", "2022-01-01")
|
| 283 |
+
built = Panel.from_frames({"ALIVE": df, "DEAD": df.iloc[: len(df) // 2]})
|
| 284 |
+
weights = pd.DataFrame(0.5, index=built.index, columns=built.symbols)
|
| 285 |
+
shuffled = permute_within_dates(weights, built.close.notna(), np.random.default_rng(1))
|
| 286 |
+
after_death = built.close["DEAD"].last_valid_index()
|
| 287 |
+
assert shuffled.loc[shuffled.index > after_death, "DEAD"].abs().max() == 0.0
|
| 288 |
+
|
| 289 |
+
def test_real_cross_sectional_skill_is_detected(self):
|
| 290 |
+
strong = make_panel(drift_sd=0.003, seed=11)
|
| 291 |
+
weights = get_xs_strategy("xs_momentum").generate(strong)
|
| 292 |
+
result = cross_sectional_permutation_test(
|
| 293 |
+
strong, weights, n_permutations=100,
|
| 294 |
+
rebalance_on=rebalance_schedule(strong.index, "M"), seed=2,
|
| 295 |
+
)
|
| 296 |
+
assert result.observed > result.null_mean
|
| 297 |
+
assert result.p_value < 0.05
|
| 298 |
+
|
| 299 |
+
def test_no_structure_is_not_significant(self):
|
| 300 |
+
flat = make_panel(drift_sd=0.0, seed=12)
|
| 301 |
+
weights = get_xs_strategy("xs_momentum").generate(flat)
|
| 302 |
+
result = cross_sectional_permutation_test(
|
| 303 |
+
flat, weights, n_permutations=100,
|
| 304 |
+
rebalance_on=rebalance_schedule(flat.index, "M"), seed=4,
|
| 305 |
+
)
|
| 306 |
+
assert result.p_value > 0.05
|
| 307 |
+
|
| 308 |
+
def test_p_value_can_never_be_zero(self, panel):
|
| 309 |
+
weights = get_xs_strategy("xs_momentum").generate(panel)
|
| 310 |
+
result = cross_sectional_permutation_test(panel, weights, n_permutations=20, seed=0)
|
| 311 |
+
assert result.p_value >= 1 / 21
|
| 312 |
+
|
| 313 |
+
|
| 314 |
+
class TestAttribution:
|
| 315 |
+
def test_equal_weight_is_explained_by_the_market(self, panel):
|
| 316 |
+
factors = build_style_factors(panel)
|
| 317 |
+
weights = get_xs_strategy("equal_weight").generate(panel)
|
| 318 |
+
result = run_portfolio_backtest(panel, weights)
|
| 319 |
+
report = factor_attribution(result.returns, factors)
|
| 320 |
+
|
| 321 |
+
assert report["available"]
|
| 322 |
+
assert report["r_squared"] > 0.85
|
| 323 |
+
assert report["dominant_factor"] == "market"
|
| 324 |
+
assert not report["alpha_significant"]
|
| 325 |
+
assert "more cheaply" in report["note"]
|
| 326 |
+
|
| 327 |
+
def test_a_factor_regressed_on_itself_has_no_alpha(self, panel):
|
| 328 |
+
factors = build_style_factors(panel)
|
| 329 |
+
report = factor_attribution(factors["market"], factors)
|
| 330 |
+
assert abs(report["alpha_annual"]) < 0.05
|
| 331 |
+
assert report["r_squared"] > 0.99
|
| 332 |
+
|
| 333 |
+
def test_pure_noise_has_no_alpha_and_no_fit(self, panel):
|
| 334 |
+
rng = np.random.default_rng(5)
|
| 335 |
+
noise = pd.Series(rng.normal(0, 0.01, len(panel)), index=panel.index)
|
| 336 |
+
report = factor_attribution(noise, build_style_factors(panel))
|
| 337 |
+
assert not report["alpha_significant"]
|
| 338 |
+
assert report["r_squared"] < 0.2
|
| 339 |
+
|
| 340 |
+
def test_short_series_degrade_gracefully(self, panel):
|
| 341 |
+
factors = build_style_factors(panel)
|
| 342 |
+
report = factor_attribution(factors["market"].iloc[:20], factors)
|
| 343 |
+
assert not report["available"]
|
| 344 |
+
assert report["note"]
|
| 345 |
+
|
| 346 |
+
|
| 347 |
+
class TestPortfolioLab:
|
| 348 |
+
def test_full_pipeline(self):
|
| 349 |
+
from algotrader.portfolio_lab import PortfolioLabConfig, run_portfolio_lab
|
| 350 |
+
|
| 351 |
+
report = run_portfolio_lab(
|
| 352 |
+
PortfolioLabConfig(
|
| 353 |
+
symbols=["SPY", "QQQ", "AAPL", "MSFT", "NVDA", "GLD"],
|
| 354 |
+
start="2017-01-01", end="2023-01-01", source="synthetic",
|
| 355 |
+
strategy="xs_momentum", n_permutations=20, wf_folds=2, grid_limit=6,
|
| 356 |
+
)
|
| 357 |
+
)
|
| 358 |
+
assert 0.0 <= report.verdict["score"] <= 100.0
|
| 359 |
+
assert report.verdict["grade"] in {"A", "B", "C", "D", "F"}
|
| 360 |
+
assert 0 < report.permutation.p_value <= 1
|
| 361 |
+
assert report.attribution["available"]
|
| 362 |
+
assert report.survivorship.n_symbols == 6
|
| 363 |
+
assert report.cost_stress["sharpe_3x"] <= report.cost_stress["sharpe_1x"] + 1e-9
|
| 364 |
+
|
| 365 |
+
def test_survivorship_bias_reaches_the_verdict(self):
|
| 366 |
+
from algotrader.portfolio_lab import PortfolioLabConfig, run_portfolio_lab
|
| 367 |
+
|
| 368 |
+
report = run_portfolio_lab(
|
| 369 |
+
PortfolioLabConfig(
|
| 370 |
+
symbols=["SPY", "QQQ", "AAPL", "MSFT", "NVDA", "GLD"],
|
| 371 |
+
start="2015-01-01", end="2023-01-01", source="synthetic",
|
| 372 |
+
strategy="equal_weight", n_permutations=0, wf_folds=2, grid_limit=3,
|
| 373 |
+
)
|
| 374 |
+
)
|
| 375 |
+
assert report.survivorship.biased
|
| 376 |
+
assert any("still trading" in f for f in report.verdict["flags"])
|
| 377 |
+
assert report.verdict["score"] <= 60
|