| """Multi-asset engine, panel, cross-sectional strategies and their null.""" |
|
|
| from __future__ import annotations |
|
|
| import numpy as np |
| import pandas as pd |
| import pytest |
|
|
| from algotrader.attribution import build_style_factors, factor_attribution |
| from algotrader.cross_sectional import XS_REGISTRY, get_xs_strategy, scores_to_weights |
| from algotrader.data import simulate_ohlcv |
| from algotrader.engine import run_backtest |
| from algotrader.panel import Panel, load_panel |
| from algotrader.portfolio import ( |
| normalise_weights, |
| rebalance_schedule, |
| run_portfolio_backtest, |
| ) |
| from algotrader.types import CostModel |
| from algotrader.validation.cross_permutation import ( |
| cross_sectional_permutation_test, |
| permute_within_dates, |
| ) |
|
|
| XS_KEYS = sorted(XS_REGISTRY) |
|
|
|
|
| def make_panel(n_assets=8, n=1200, drift_sd=0.0, seed=1, common_vol=0.008, idio=0.012) -> Panel: |
| """A synthetic universe. ``drift_sd`` controls genuine cross-sectional structure.""" |
| rng = np.random.default_rng(seed) |
| index = pd.date_range("2016-01-01", periods=n, freq="B") |
| drift = rng.normal(0, drift_sd, n_assets) |
| returns = ( |
| rng.normal(0.0002, common_vol, n)[:, None] |
| + rng.normal(0, idio, (n, n_assets)) |
| + drift[None, :] |
| ) |
| close = 100 * np.exp(np.cumsum(returns, axis=0)) |
| columns = [f"A{i}" for i in range(n_assets)] |
|
|
| def frame(values): |
| return pd.DataFrame(values, index=index, columns=columns) |
|
|
| return Panel( |
| fields={ |
| "open": frame(close), |
| "high": frame(close * 1.004), |
| "low": frame(close * 0.996), |
| "close": frame(close), |
| "volume": frame(np.full_like(close, 1e6)), |
| }, |
| sources={c: "synthetic" for c in columns}, |
| ) |
|
|
|
|
| @pytest.fixture(scope="module") |
| def panel() -> Panel: |
| return make_panel() |
|
|
|
|
| class TestPanel: |
| def test_fields_are_aligned(self, panel): |
| assert panel.shape == (1200, 8) |
| for name in ("open", "high", "low", "close", "volume"): |
| assert panel.fields[name].index.equals(panel.index) |
| assert list(panel.fields[name].columns) == panel.symbols |
|
|
| def test_misaligned_fields_are_rejected(self, panel): |
| broken = dict(panel.fields) |
| broken["high"] = broken["high"].iloc[:-5] |
| with pytest.raises(ValueError, match="not aligned"): |
| Panel(fields=broken) |
|
|
| def test_missing_field_is_rejected(self, panel): |
| with pytest.raises(ValueError, match="missing field"): |
| Panel(fields={"close": panel.close}) |
|
|
| def test_missing_data_is_not_forward_filled(self): |
| """Filling a gap invents liquidity that never existed.""" |
| df = simulate_ohlcv("AAPL", "2018-01-01", "2022-01-01") |
| gapped = df.drop(df.index[100:140]) |
| built = Panel.from_frames({"AAPL": gapped, "MSFT": df}) |
| assert built.close["AAPL"].isna().sum() == 40 |
|
|
| def test_tradable_requires_two_consecutive_prices(self, panel): |
| assert not panel.tradable().iloc[0].any() |
| assert panel.tradable().iloc[1:].all().all() |
|
|
| def test_returns_are_masked_where_untradable(self, panel): |
| assert panel.returns().iloc[0].isna().all() |
|
|
| def test_delisting_is_detected(self): |
| df = simulate_ohlcv("AAPL", "2016-01-01", "2022-01-01") |
| dead = df.iloc[: len(df) // 2] |
| built = Panel.from_frames({"ALIVE": df, "DEAD": dead}) |
| report = built.survivorship() |
| assert report.n_delisted == 1 |
| assert "DEAD" in report.delisted_symbols |
| assert not report.biased |
|
|
| def test_all_survivors_is_flagged_as_biased(self, panel): |
| report = panel.survivorship() |
| assert report.survival_rate == 1.0 |
| assert report.biased |
| assert "upper bound" in report.note |
|
|
| def test_load_panel_skips_symbols_without_history(self): |
| built = load_panel(["SPY", "AAPL"], "2018-01-01", "2022-01-01", source="synthetic") |
| assert set(built.symbols) == {"SPY", "AAPL"} |
| assert all(s == "synthetic" for s in built.sources.values()) |
|
|
| def test_select_and_slice(self, panel): |
| subset = panel.select(["A0", "A1"]) |
| assert subset.symbols == ["A0", "A1"] |
| sliced = panel.slice(panel.index[10], panel.index[50]) |
| assert len(sliced) == 41 |
|
|
|
|
| class TestPortfolioEngine: |
| def test_matches_single_asset_engine_exactly(self): |
| """One column through the matrix engine must equal the fast path.""" |
| df = simulate_ohlcv("SPY", "2018-01-01", "2023-01-01") |
| single = Panel.from_frames({"SPY": df}) |
| costs = CostModel(1, 2, 50) |
| n = len(df) |
| cases = { |
| "buy_and_hold": np.ones(n), |
| "flip": np.tile([1.0, -1.0], n // 2 + 1)[:n], |
| "long_flat": np.tile([1.0, 0.0], n // 2 + 1)[:n], |
| "fractional": np.full(n, 0.5), |
| } |
| for name, target in cases.items(): |
| portfolio = run_portfolio_backtest( |
| single, pd.DataFrame({"SPY": target}, index=df.index), costs=costs |
| ) |
| direct = run_backtest(df, pd.Series(target, index=df.index), costs=costs) |
| np.testing.assert_allclose( |
| portfolio.returns.to_numpy(), direct.returns.to_numpy(), |
| atol=1e-12, err_msg=f"mismatch for {name}", |
| ) |
|
|
| def test_holding_still_costs_nothing_but_drift_does(self, panel): |
| """A full-notional long needs no rebalancing; a short does.""" |
| costs = CostModel(10, 10, 0) |
| long_only = pd.DataFrame(1.0 / len(panel.symbols), index=panel.index, columns=panel.symbols) |
| result = run_portfolio_backtest(panel, long_only, costs=costs, gross_leverage=1.0) |
| |
| assert result.metrics["turnover_ann"] > 0 |
| |
| one = pd.DataFrame(0.0, index=panel.index, columns=panel.symbols) |
| one["A0"] = 1.0 |
| assert run_portfolio_backtest(panel, one, costs=costs).costs.sum() == pytest.approx( |
| 20 / 1e4, rel=1e-6 |
| ) |
|
|
| def test_rebalancing_less_often_lowers_turnover(self, panel): |
| weights = get_xs_strategy("equal_weight").generate(panel) |
| turnovers = [] |
| for frequency in ("D", "M", "Q"): |
| result = run_portfolio_backtest( |
| panel, weights, costs=CostModel(1, 2, 0), |
| rebalance_on=rebalance_schedule(panel.index, frequency), |
| ) |
| turnovers.append(result.metrics["turnover_ann"]) |
| assert turnovers[0] > turnovers[1] > turnovers[2] |
|
|
| def test_gross_leverage_is_capped(self, panel): |
| weights = pd.DataFrame(1.0, index=panel.index, columns=panel.symbols) |
| result = run_portfolio_backtest(panel, weights, gross_leverage=1.0) |
| assert result.weights.abs().sum(axis=1).max() <= 1.0 + 1e-9 |
|
|
| def test_leverage_is_scaled_down_never_up(self, panel): |
| small = pd.DataFrame(0.01, index=panel.index, columns=panel.symbols) |
| result = run_portfolio_backtest(panel, small, gross_leverage=1.0) |
| assert result.weights.abs().sum(axis=1).max() < 0.2 |
|
|
| def test_per_name_cap_is_applied(self, panel): |
| weights = pd.DataFrame(0.0, index=panel.index, columns=panel.symbols) |
| weights["A0"] = 1.0 |
| result = run_portfolio_backtest(panel, weights, max_weight=0.1) |
| assert result.weights.abs().max().max() <= 0.1 + 1e-9 |
|
|
| def test_shorts_are_blocked_when_disallowed(self, panel): |
| weights = pd.DataFrame(-0.1, index=panel.index, columns=panel.symbols) |
| result = run_portfolio_backtest(panel, weights, allow_short=False) |
| assert (result.weights >= 0).all().all() |
|
|
| def test_delisted_names_cannot_be_held(self): |
| df = simulate_ohlcv("AAPL", "2016-01-01", "2022-01-01") |
| built = Panel.from_frames({"ALIVE": df, "DEAD": df.iloc[: len(df) // 2]}) |
| weights = pd.DataFrame(0.5, index=built.index, columns=built.symbols) |
| result = run_portfolio_backtest(built, weights) |
| after_death = built.close["DEAD"].last_valid_index() |
| assert result.held.loc[result.held.index > after_death, "DEAD"].abs().max() == 0.0 |
|
|
| def test_lag_zero_is_rejected(self, panel): |
| with pytest.raises(ValueError, match="lag"): |
| run_portfolio_backtest(panel, pd.DataFrame(0.1, index=panel.index, columns=panel.symbols), lag=0) |
|
|
| def test_future_bars_cannot_change_past_equity(self, panel): |
| weights = get_xs_strategy("xs_momentum").generate(panel) |
| full = run_portfolio_backtest(panel, weights) |
| cut = run_portfolio_backtest( |
| panel.slice(panel.index[0], panel.index[799]), weights.iloc[:800] |
| ) |
| np.testing.assert_allclose( |
| full.equity.iloc[:800].to_numpy(), cut.equity.to_numpy(), rtol=1e-10 |
| ) |
|
|
| def test_attribution_sums_to_gross_return(self, panel): |
| weights = get_xs_strategy("equal_weight").generate(panel) |
| result = run_portfolio_backtest(panel, weights, costs=CostModel(0, 0, 0)) |
| assert result.attribution().sum() == pytest.approx(result.gross_returns.sum(), rel=1e-9) |
|
|
| def test_portfolio_metrics_are_reported(self, panel): |
| result = run_portfolio_backtest(panel, get_xs_strategy("xs_momentum").generate(panel)) |
| for key in ("gross_exposure", "net_exposure", "avg_positions", "concentration_hhi"): |
| assert key in result.metrics |
|
|
|
|
| class TestCrossSectionalStrategies: |
| @pytest.mark.parametrize("key", XS_KEYS) |
| def test_weights_are_well_formed(self, panel, key): |
| weights = get_xs_strategy(key).generate(panel) |
| assert weights.shape == panel.shape |
| assert weights.notna().all().all() |
| assert weights.abs().sum(axis=1).max() <= 1.0 + 1e-9 |
|
|
| @pytest.mark.parametrize("key", XS_KEYS) |
| def test_weights_are_causal(self, panel, key): |
| cut = 700 |
| full = get_xs_strategy(key).generate(panel).iloc[:cut] |
| partial = get_xs_strategy(key).generate(panel.slice(panel.index[0], panel.index[cut - 1])) |
| pd.testing.assert_frame_equal(full, partial, rtol=1e-9) |
|
|
| def test_long_short_books_are_dollar_neutral(self, panel): |
| weights = get_xs_strategy("xs_momentum").generate(panel) |
| active = weights[weights.abs().sum(axis=1) > 1e-9] |
| assert active.sum(axis=1).abs().max() < 1e-9 |
|
|
| def test_long_only_uses_the_whole_book(self): |
| scores = pd.DataFrame(np.arange(40).reshape(4, 10).astype(float)) |
| weights = scores_to_weights(scores, long_frac=0.3, long_only=True) |
| assert weights.sum(axis=1).round(9).eq(1.0).all() |
| assert (weights >= 0).all().all() |
|
|
| def test_thin_cross_sections_are_skipped(self): |
| scores = pd.DataFrame(np.random.default_rng(0).normal(size=(10, 3))) |
| assert scores_to_weights(scores).abs().sum(axis=1).max() == 0.0 |
|
|
| def test_equal_weight_holds_everything(self, panel): |
| weights = get_xs_strategy("equal_weight").generate(panel) |
| assert (weights.iloc[-1] > 0).all() |
|
|
| def test_unknown_strategy_names_alternatives(self): |
| with pytest.raises(KeyError, match="Available"): |
| get_xs_strategy("nope") |
|
|
|
|
| class TestCrossSectionalNull: |
| def test_permutation_preserves_the_shape_of_the_book(self, panel): |
| weights = get_xs_strategy("xs_momentum").generate(panel) |
| investable = panel.close.notna() |
| shuffled = permute_within_dates(weights, investable, np.random.default_rng(0)) |
|
|
| np.testing.assert_allclose( |
| np.sort(weights.to_numpy(), axis=1), np.sort(shuffled.to_numpy(), axis=1), atol=1e-12 |
| ) |
| np.testing.assert_allclose( |
| weights.abs().sum(axis=1).to_numpy(), shuffled.abs().sum(axis=1).to_numpy(), atol=1e-12 |
| ) |
| np.testing.assert_allclose( |
| weights.sum(axis=1).to_numpy(), shuffled.sum(axis=1).to_numpy(), atol=1e-12 |
| ) |
|
|
| def test_permutation_actually_reassigns(self, panel): |
| weights = get_xs_strategy("xs_momentum").generate(panel) |
| shuffled = permute_within_dates(weights, panel.close.notna(), np.random.default_rng(0)) |
| assert not np.allclose(weights.to_numpy(), shuffled.to_numpy()) |
|
|
| def test_non_investable_names_stay_empty(self): |
| df = simulate_ohlcv("AAPL", "2016-01-01", "2022-01-01") |
| built = Panel.from_frames({"ALIVE": df, "DEAD": df.iloc[: len(df) // 2]}) |
| weights = pd.DataFrame(0.5, index=built.index, columns=built.symbols) |
| shuffled = permute_within_dates(weights, built.close.notna(), np.random.default_rng(1)) |
| after_death = built.close["DEAD"].last_valid_index() |
| assert shuffled.loc[shuffled.index > after_death, "DEAD"].abs().max() == 0.0 |
|
|
| def test_real_cross_sectional_skill_is_detected(self): |
| strong = make_panel(drift_sd=0.003, seed=11) |
| weights = get_xs_strategy("xs_momentum").generate(strong) |
| result = cross_sectional_permutation_test( |
| strong, weights, n_permutations=100, |
| rebalance_on=rebalance_schedule(strong.index, "M"), seed=2, |
| ) |
| assert result.observed > result.null_mean |
| assert result.p_value < 0.05 |
|
|
| def test_no_structure_is_not_significant(self): |
| flat = make_panel(drift_sd=0.0, seed=12) |
| weights = get_xs_strategy("xs_momentum").generate(flat) |
| result = cross_sectional_permutation_test( |
| flat, weights, n_permutations=100, |
| rebalance_on=rebalance_schedule(flat.index, "M"), seed=4, |
| ) |
| assert result.p_value > 0.05 |
|
|
| def test_p_value_can_never_be_zero(self, panel): |
| weights = get_xs_strategy("xs_momentum").generate(panel) |
| result = cross_sectional_permutation_test(panel, weights, n_permutations=20, seed=0) |
| assert result.p_value >= 1 / 21 |
|
|
|
|
| class TestAttribution: |
| def test_equal_weight_is_explained_by_the_market(self, panel): |
| factors = build_style_factors(panel) |
| weights = get_xs_strategy("equal_weight").generate(panel) |
| result = run_portfolio_backtest(panel, weights) |
| report = factor_attribution(result.returns, factors) |
|
|
| assert report["available"] |
| assert report["r_squared"] > 0.85 |
| assert report["dominant_factor"] == "market" |
| assert not report["alpha_significant"] |
| assert "more cheaply" in report["note"] |
|
|
| def test_a_factor_regressed_on_itself_has_no_alpha(self, panel): |
| factors = build_style_factors(panel) |
| report = factor_attribution(factors["market"], factors) |
| assert abs(report["alpha_annual"]) < 0.05 |
| assert report["r_squared"] > 0.99 |
|
|
| def test_pure_noise_has_no_alpha_and_no_fit(self, panel): |
| rng = np.random.default_rng(5) |
| noise = pd.Series(rng.normal(0, 0.01, len(panel)), index=panel.index) |
| report = factor_attribution(noise, build_style_factors(panel)) |
| assert not report["alpha_significant"] |
| assert report["r_squared"] < 0.2 |
|
|
| def test_short_series_degrade_gracefully(self, panel): |
| factors = build_style_factors(panel) |
| report = factor_attribution(factors["market"].iloc[:20], factors) |
| assert not report["available"] |
| assert report["note"] |
|
|
|
|
| class TestPortfolioLab: |
| def test_full_pipeline(self): |
| from algotrader.portfolio_lab import PortfolioLabConfig, run_portfolio_lab |
|
|
| report = run_portfolio_lab( |
| PortfolioLabConfig( |
| symbols=["SPY", "QQQ", "AAPL", "MSFT", "NVDA", "GLD"], |
| start="2017-01-01", end="2023-01-01", source="synthetic", |
| strategy="xs_momentum", n_permutations=20, wf_folds=2, grid_limit=6, |
| ) |
| ) |
| assert 0.0 <= report.verdict["score"] <= 100.0 |
| assert report.verdict["grade"] in {"A", "B", "C", "D", "F"} |
| assert 0 < report.permutation.p_value <= 1 |
| assert report.attribution["available"] |
| assert report.survivorship.n_symbols == 6 |
| assert report.cost_stress["sharpe_3x"] <= report.cost_stress["sharpe_1x"] + 1e-9 |
|
|
| def test_survivorship_bias_reaches_the_verdict(self): |
| from algotrader.portfolio_lab import PortfolioLabConfig, run_portfolio_lab |
|
|
| report = run_portfolio_lab( |
| PortfolioLabConfig( |
| symbols=["SPY", "QQQ", "AAPL", "MSFT", "NVDA", "GLD"], |
| start="2015-01-01", end="2023-01-01", source="synthetic", |
| strategy="equal_weight", n_permutations=0, wf_folds=2, grid_limit=3, |
| ) |
| ) |
| assert report.survivorship.biased |
| assert any("still trading" in f for f in report.verdict["flags"]) |
| assert report.verdict["score"] <= 60 |
|
|