Team Ai
Modelpublic

ParallelLLC/algorithmic_trading

sourceHugging Faceapache-2.0updated 2mo agoView on Hugging Face
27likes32downloads
test_v2_portfolio.py378 linesDownload Raw Back to tests
1"""Multi-asset engine, panel, cross-sectional strategies and their null."""2 3from __future__ import annotations4 5import numpy as np6import pandas as pd7import pytest8 9from algotrader.attribution import build_style_factors, factor_attribution10from algotrader.cross_sectional import XS_REGISTRY, get_xs_strategy, scores_to_weights11from algotrader.data import simulate_ohlcv12from algotrader.engine import run_backtest13from algotrader.panel import Panel, load_panel14from algotrader.portfolio import (15    normalise_weights,16    rebalance_schedule,17    run_portfolio_backtest,18)19from algotrader.types import CostModel20from algotrader.validation.cross_permutation import (21    cross_sectional_permutation_test,22    permute_within_dates,23)24 25XS_KEYS = sorted(XS_REGISTRY)26 27 28def 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")57def panel() -> Panel:58    return make_panel()59 60 61class 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.symbols67 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() == 4084 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 == 198        assert "DEAD" in report.delisted_symbols99        assert not report.biased100 101    def test_all_survivors_is_flagged_as_biased(self, panel):102        report = panel.survivorship()103        assert report.survival_rate == 1.0104        assert report.biased105        assert "upper bound" in report.note106 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) == 41117 118 119class 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=costs135            )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"] > 0149        # ...but a single full-notional position does not.150        one = pd.DataFrame(0.0, index=panel.index, columns=panel.symbols)151        one["A0"] = 1.0152        assert run_portfolio_backtest(panel, one, costs=costs).costs.sum() == pytest.approx(153            20 / 1e4, rel=1e-6154        )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-9171 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.2176 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.0180        result = run_portfolio_backtest(panel, weights, max_weight=0.1)181        assert result.weights.abs().max().max() <= 0.1 + 1e-9182 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.0195 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-10208        )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.metrics219 220 221class 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.shape226        assert weights.notna().all().all()227        assert weights.abs().sum(axis=1).max() <= 1.0 + 1e-9228 229    @pytest.mark.parametrize("key", XS_KEYS)230    def test_weights_are_causal(self, panel, key):231        cut = 700232        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-9240 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.0250 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 260class 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-12268        )269        np.testing.assert_allclose(270            weights.abs().sum(axis=1).to_numpy(), shuffled.abs().sum(axis=1).to_numpy(), atol=1e-12271        )272        np.testing.assert_allclose(273            weights.sum(axis=1).to_numpy(), shuffled.sum(axis=1).to_numpy(), atol=1e-12274        )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.0288 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_mean297        assert result.p_value < 0.05298 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.05307 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 / 21312 313 314class 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.85323        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.05331        assert report["r_squared"] > 0.99332 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.2339 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 347class TestPortfolioLab:348    def test_full_pipeline(self):349        from algotrader.portfolio_lab import PortfolioLabConfig, run_portfolio_lab350 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.0359        assert report.verdict["grade"] in {"A", "B", "C", "D", "F"}360        assert 0 < report.permutation.p_value <= 1361        assert report.attribution["available"]362        assert report.survivorship.n_symbols == 6363        assert report.cost_stress["sharpe_3x"] <= report.cost_stress["sharpe_1x"] + 1e-9364 365    def test_survivorship_bias_reaches_the_verdict(self):366        from algotrader.portfolio_lab import PortfolioLabConfig, run_portfolio_lab367 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.biased376        assert any("still trading" in f for f in report.verdict["flags"])377        assert report.verdict["score"] <= 60378