algorithmic_trading / tests /test_v2_portfolio.py
Claude
Add multi-asset portfolio support with a cross-sectional null
2d2e42a unverified
Raw
History Blame Contribute Delete
16.6 kB
"""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)
# Equal-weight across many names still drifts apart, so turnover > 0...
assert result.metrics["turnover_ann"] > 0
# ...but a single full-notional position does not.
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