Claude commited on
Commit
2d2e42a
·
unverified ·
1 Parent(s): 9d67baa

Add multi-asset portfolio support with a cross-sectional null

Browse files

Single-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 CHANGED
@@ -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
README.md CHANGED
@@ -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` · `rsi_reversion` ·
123
- `bollinger_reversion` · `donchian_breakout` · `momentum` · `vol_target_momentum` ·
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 # 111 tests, ~15s, no network
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
SPACE_README.md CHANGED
@@ -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
algotrader/__init__.py CHANGED
@@ -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.0.0"
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",
algotrader/attribution.py ADDED
@@ -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
+ }
algotrader/charts.py CHANGED
@@ -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 buy & hold, 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,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="Buy & hold", mode="lines",
80
  line=dict(color=REFERENCE, width=2, dash="dash"),
81
- hovertemplate="Buy & hold %{y:.1f}<extra></extra>",
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, "Buy & hold")):
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": "Beats shuffled markets",
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
algotrader/cli.py CHANGED
@@ -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
- for key, strategy in REGISTRY.items():
143
- params = ", ".join(f"{p.name}={p.default:g}" for p in strategy.params) or "no parameters"
144
- print(f" {key:<22} {strategy.name:<26} [{strategy.family}]")
145
- print(f" {'':<22} {strategy.description}")
146
- print(f" {'':<22} defaults: {params}\n")
 
 
 
 
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
 
algotrader/cross_sectional.py ADDED
@@ -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]
algotrader/engine.py CHANGED
@@ -62,8 +62,15 @@ def run_backtest(
62
 
63
  gross = position * asset_ret
64
 
65
- traded = position.diff()
66
- traded.iloc[0] = position.iloc[0]
 
 
 
 
 
 
 
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
algotrader/panel.py ADDED
@@ -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())
algotrader/portfolio.py ADDED
@@ -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
+ }
algotrader/portfolio_lab.py ADDED
@@ -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
algotrader/validation/cross_permutation.py ADDED
@@ -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
+ )
algotrader/validation/walkforward.py CHANGED
@@ -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
+ }
algotrader/verdict.py CHANGED
@@ -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 shuffled, "
110
- "structure-free markets did this well or better."
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 buy & hold — "
135
  "this is mostly a repackaged long position."
136
  )
137
  if sharpe < bench_sharpe:
138
  flags.append(
139
- f"Buy & hold beat it on risk-adjusted return ({bench_sharpe:.2f} vs {sharpe:.2f} Sharpe)."
 
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 shuffling the market, "
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}. A randomly shuffled market produces results like this "
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 {
app.py CHANGED
@@ -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
 
scripts/deploy_hf_space.sh CHANGED
@@ -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" tests/
 
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|^\./| |'
tests/test_data_ingestion.py CHANGED
@@ -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'].dtype == 'datetime64[ns]'
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'].dtype == 'datetime64[ns]'
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)
tests/test_synthetic_data_generator.py CHANGED
@@ -56,8 +56,8 @@ class TestSyntheticDataGenerator:
56
  assert col in df.columns
57
 
58
  # Check data types
59
- assert df['timestamp'].dtype == 'datetime64[ns]'
60
- assert df['symbol'].dtype == 'object'
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']
tests/test_v2_portfolio.py ADDED
@@ -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