Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,6 @@ build/
dist/
data/*.csv
data/*.png
data/**/*.csv
data/**/*.png
!data/.gitkeep
38 changes: 38 additions & 0 deletions docs/RESULTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
79 changes: 77 additions & 2 deletions run_cross_sectional.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from pathlib import Path

import numpy as np
import pandas as pd

from engine.backtest import Backtest
Expand All @@ -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,
Expand Down Expand Up @@ -41,6 +91,7 @@ def main():
}
benchmark_kwargs = {"gross_allocation": 0.90}
rows = []
active_rows = []
plotted = {}

for bps in (0, 5, 10, 25):
Expand All @@ -56,20 +107,41 @@ 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)
for name, ledger in equal_weight.items():
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)",
Expand All @@ -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}/")


Expand Down
60 changes: 60 additions & 0 deletions tests/test_cross_sectional_metrics.py
Original file line number Diff line number Diff line change
@@ -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()
Loading