diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 10366c5..638ae3f 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -11,4 +11,4 @@ jobs: cache: pip - run: pip install -e ".[dev]" - run: python -m unittest discover -s tests -v - - run: ruff check engine tests run.py + - run: ruff check engine tests run.py run_cross_sectional.py diff --git a/.gitignore b/.gitignore index 00d97d2..b661980 100644 --- a/.gitignore +++ b/.gitignore @@ -8,4 +8,6 @@ build/ dist/ data/*.csv data/*.png +data/**/*.csv +data/**/*.png !data/.gitkeep diff --git a/docs/RESULTS.md b/docs/RESULTS.md index 132ba03..cca4513 100644 --- a/docs/RESULTS.md +++ b/docs/RESULTS.md @@ -12,3 +12,41 @@ Expected files: Numerical results are deliberately generated rather than hard-coded because the upstream adjusted dataset can change. Interpret the study as an engine demonstration. A simple moving-average result can depend heavily on the chosen period, parameters, market regime, and assumed costs; it is not investment advice or a claim of deployable alpha. The most important version-one result is structural: automated tests verify next-bar execution, event order, cost direction, cash constraints, multi-symbol reservations, exits, common-calendar handling, final marking, and end-of-data cancellation. + +## Cross-sectional momentum snapshot + +The following is the observed July 20, 2026 run of +`python run_cross_sectional.py`. The requested data interval ends January 1, +2025. Because Yahoo Finance can revise adjusted history, generated CSV files +remain the source of truth for a fresh run. + +| Strategy | Cost | Annual return | Sharpe | Max drawdown | Final equity | Fills | +|---|---:|---:|---:|---:|---:|---:| +| Momentum | 0 bps | 11.28% | 0.750 | -31.86% | $495,681 | 189 | +| Equal weight | 0 bps | 11.89% | 0.792 | -33.52% | $537,320 | 9 | +| Momentum | 5 bps | 11.08% | 0.738 | -31.90% | $482,234 | 189 | +| Equal weight | 5 bps | 11.88% | 0.792 | -33.53% | $537,275 | 9 | +| Momentum | 10 bps | 10.88% | 0.726 | -31.94% | $469,139 | 189 | +| Equal weight | 10 bps | 11.88% | 0.792 | -33.53% | $537,230 | 9 | +| Momentum | 25 bps | 10.26% | 0.690 | -32.07% | $431,687 | 189 | +| Equal weight | 25 bps | 11.88% | 0.792 | -33.55% | $537,095 | 9 | + +### Interpretation + +The fixed 12-1 sector momentum rule did not improve return or Sharpe relative +to equal-weight buy-and-hold in this sample. It reduced maximum drawdown by +roughly 1.6 percentage points, but required far more trading. As assumed costs +rose, that turnover widened the performance deficit. This is a useful negative +result: the tested rule did not demonstrate an implementable advantage over the +simpler alternative. + +The experiment now also writes annualized turnover plus +`active_performance.csv`, containing annualized active mean return, tracking +error, information ratio, and ending-wealth difference at each cost level. +Those statistics measure the strategy against equal weight; they do not turn +this full-period comparison into an untouched test. + +Do not tune the lookback, skip, selection fraction, or sample dates in response +to this table and then describe the same interval as out of sample. Any future +variant should be declared first and evaluated on genuinely new data or through +a separately designed robustness study. diff --git a/run_cross_sectional.py b/run_cross_sectional.py index ef0c9ee..803eb1c 100644 --- a/run_cross_sectional.py +++ b/run_cross_sectional.py @@ -2,6 +2,7 @@ from pathlib import Path +import numpy as np import pandas as pd from engine.backtest import Backtest @@ -14,6 +15,55 @@ CAPITAL = 100_000.0 +def annualized_turnover(result, periods_per_year=252): + """Annualized traded notional divided by same-day portfolio equity.""" + fills = result["fills"] + equity = result["equity"].copy() + if fills.empty or len(equity) < 2: + return 0.0 + + equity["dt"] = pd.to_datetime(equity["dt"]) + equity_by_date = equity.set_index("dt")["equity"] + traded = fills.copy() + traded["fill_dt"] = pd.to_datetime(traded["fill_dt"]) + traded["notional"] = traded["quantity"].abs() * traded["fill_price"] + daily_notional = traded.groupby("fill_dt")["notional"].sum() + daily_turnover = daily_notional / equity_by_date.reindex(daily_notional.index) + periods = len(equity_by_date) - 1 + return float(daily_turnover.sum() * periods_per_year / periods) + + +def active_performance(momentum_equity, benchmark_equity, periods_per_year=252): + """Return active mean, tracking error, and information ratio.""" + curves = [] + for name, frame in ( + ("momentum", momentum_equity), + ("benchmark", benchmark_equity), + ): + curve = frame.copy() + curve["dt"] = pd.to_datetime(curve["dt"]) + curves.append(curve.set_index("dt")["equity"].rename(name)) + + aligned = pd.concat(curves, axis=1, join="inner").dropna() + returns = aligned.pct_change().dropna() + active = returns["momentum"] - returns["benchmark"] + if active.empty: + return { + "annualized_active_return": 0.0, + "tracking_error": 0.0, + "information_ratio": 0.0, + } + + annualized_active_return = float(active.mean() * periods_per_year) + tracking_error = float(active.std(ddof=1) * np.sqrt(periods_per_year)) + information_ratio = annualized_active_return / tracking_error if tracking_error > 0 else 0.0 + return { + "annualized_active_return": annualized_active_return, + "tracking_error": tracking_error, + "information_ratio": information_ratio, + } + + def run_strategy(price_data, strategy_cls, bps, strategy_kwargs): return Backtest( SYMBOLS, @@ -41,6 +91,7 @@ def main(): } benchmark_kwargs = {"gross_allocation": 0.90} rows = [] + active_rows = [] plotted = {} for bps in (0, 5, 10, 25): @@ -56,12 +107,31 @@ def main(): bps, benchmark_kwargs, ) - for label, result in (("momentum", momentum), ("equal_weight", equal_weight)): + pair = (("momentum", momentum), ("equal_weight", equal_weight)) + for label, result in pair: stats, curve = performance(result["equity"], CAPITAL) - rows.append({"strategy": label, "cost_bps": bps, **stats, "fills": len(result["fills"])}) + rows.append( + { + "strategy": label, + "cost_bps": bps, + **stats, + "annualized_turnover": annualized_turnover(result), + "fills": len(result["fills"]), + } + ) if bps == 5: plotted[label] = curve["equity"] + active_rows.append( + { + "cost_bps": bps, + **active_performance(momentum["equity"], equal_weight["equity"]), + "ending_wealth_difference": float( + momentum["equity"].iloc[-1]["equity"] - equal_weight["equity"].iloc[-1]["equity"] + ), + } + ) + if bps == 5: for name, ledger in momentum.items(): ledger.to_csv(output / f"momentum_{name}.csv", index=False) @@ -69,7 +139,9 @@ def main(): ledger.to_csv(output / f"equal_weight_{name}.csv", index=False) sensitivity = pd.DataFrame(rows) + active_summary = pd.DataFrame(active_rows) sensitivity.to_csv(output / "cost_sensitivity.csv", index=False) + active_summary.to_csv(output / "active_performance.csv", index=False) ax = pd.DataFrame(plotted).plot( figsize=(11, 6), title="Sector ETFs: 12-1 momentum vs equal-weight buy-and-hold (5 bps)", @@ -78,7 +150,10 @@ def main(): ax.figure.tight_layout() ax.figure.savefig(output / "equity_comparison.png", dpi=150) + print("STRATEGY RESULTS") print(sensitivity.to_string(index=False)) + print("\nACTIVE PERFORMANCE VS EQUAL WEIGHT") + print(active_summary.to_string(index=False)) print(f"\nSaved results to {output}/") diff --git a/tests/test_cross_sectional_metrics.py b/tests/test_cross_sectional_metrics.py new file mode 100644 index 0000000..21a61a1 --- /dev/null +++ b/tests/test_cross_sectional_metrics.py @@ -0,0 +1,60 @@ +import unittest + +import pandas as pd + +from run_cross_sectional import active_performance, annualized_turnover + + +class CrossSectionalMetricTests(unittest.TestCase): + def test_annualized_turnover_uses_notional_and_equity(self): + dates = pd.date_range("2024-01-01", periods=3) + result = { + "equity": pd.DataFrame({"dt": dates, "equity": [1_000, 1_000, 1_000]}), + "fills": pd.DataFrame( + { + "fill_dt": [dates[1], dates[2]], + "quantity": [5, 10], + "fill_price": [20, 10], + } + ), + } + self.assertAlmostEqual(annualized_turnover(result), 25.2) + + def test_active_performance_matches_manual_calculation(self): + dates = pd.date_range("2024-01-01", periods=4) + momentum = pd.DataFrame({"dt": dates, "equity": [100, 102, 101, 104]}) + benchmark = pd.DataFrame({"dt": dates, "equity": [100, 101, 102, 103]}) + result = active_performance(momentum, benchmark) + + joined = pd.DataFrame( + { + "momentum": momentum["equity"].pct_change(), + "benchmark": benchmark["equity"].pct_change(), + } + ).dropna() + active = joined["momentum"] - joined["benchmark"] + expected_return = active.mean() * 252 + expected_te = active.std(ddof=1) * (252**0.5) + + self.assertAlmostEqual(result["annualized_active_return"], expected_return) + self.assertAlmostEqual(result["tracking_error"], expected_te) + self.assertAlmostEqual( + result["information_ratio"], + expected_return / expected_te, + ) + + def test_empty_fills_have_zero_turnover(self): + result = { + "equity": pd.DataFrame( + { + "dt": pd.date_range("2024-01-01", periods=2), + "equity": [1_000, 1_010], + } + ), + "fills": pd.DataFrame(), + } + self.assertEqual(annualized_turnover(result), 0.0) + + +if __name__ == "__main__": + unittest.main()