feat: add residual momentum to signal-edge backtest
Deploy / lint (push) Successful in 6s
Deploy / test (push) Successful in 55s
Deploy / deploy (push) Successful in 33s

Adds a research-only 12-1 residual momentum signal to the cross-sectional signal-evaluation harness. The signal estimates benchmark beta over the 12-1 formation window and ranks cumulative stock return minus beta-adjusted benchmark return; it only appears when benchmark closes are available.

No production qualification behavior changes. The Backtest signal table labels the new row as 12-1 residual momentum. Tests cover benchmark-gated emission and beta removal while keeping stock-specific drift.

Verification: 453 backend tests pass, ruff check app/ clean, frontend npm run build clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-02 15:46:54 +02:00
parent 1e82dfad7f
commit 13374087db
4 changed files with 122 additions and 10 deletions
+38
View File
@@ -62,6 +62,44 @@ def _bar(high: float, low: float, close: float, open_: float | None = None) -> S
)
def _signal_test_series(extra_return: float = 0.0) -> tuple[list[date], list[float], list[float], dict[date, float]]:
base = date(2024, 1, 1)
dates = [base + timedelta(days=i) for i in range(280)]
benchmark = [100.0]
closes = [100.0]
for i in range(1, len(dates)):
market_ret = 0.0004 + 0.002 * math.sin(i / 9.0)
benchmark.append(benchmark[-1] * (1.0 + market_ret))
# Same market beta for both test stocks; only ``extra_return`` is
# idiosyncratic drift, which residual momentum should keep.
stock_ret = 1.4 * market_ret + extra_return
closes.append(closes[-1] * (1.0 + stock_ret))
highs = [c * 1.01 for c in closes]
benchmark_closes = dict(zip(dates, benchmark))
return dates, closes, highs, benchmark_closes
def test_signal_values_emit_residual_momentum_only_with_benchmark():
dates, closes, highs, benchmark = _signal_test_series(extra_return=0.0008)
no_benchmark = bt._signal_values(dates, closes, highs, 260)
with_benchmark = bt._signal_values(dates, closes, highs, 260, benchmark)
assert "mom_12_1" in no_benchmark
assert "mom_12_1_resid" not in no_benchmark
assert "mom_12_1_resid" in with_benchmark
def test_residual_momentum_removes_market_beta_but_keeps_specific_drift():
dates, pure_beta, highs, benchmark = _signal_test_series(extra_return=0.0)
_, drift_stock, drift_highs, _ = _signal_test_series(extra_return=0.0008)
pure = bt._signal_values(dates, pure_beta, highs, 260, benchmark)
drift = bt._signal_values(dates, drift_stock, drift_highs, 260, benchmark)
assert pure["mom_12_1_resid"] == pytest.approx(0.0, abs=0.03)
assert drift["mom_12_1_resid"] > pure["mom_12_1_resid"] + 0.12
class TestStopFillR:
def test_intraday_fill_at_stop(self):
assert bt._stop_fill_r("long", 100.0, 95.0, _bar(101, 94, 96)) == pytest.approx(-1.0)
+4 -2
View File
@@ -60,8 +60,9 @@ def test_quintile_spread_none_when_too_few():
def test_signal_values_momentum_and_trend():
# Steadily rising series so every lookback is positive and trend is above SMA.
closes = [100.0 * (1.01 ** k) for k in range(300)]
dates = [date(2024, 1, 1) + timedelta(days=k) for k in range(300)]
i = 299
vals = bt._signal_values(closes, closes, i)
vals = bt._signal_values(dates, closes, closes, i)
assert vals["mom_12_1"] > 0 # up over the 12→1 month window
assert vals["trend_200"] > 0 # price above its 200-bar SMA in an uptrend
# 12-1 momentum skips the last month: close[i-21] / close[i-252] - 1
@@ -73,7 +74,8 @@ def test_signal_values_momentum_and_trend():
def test_signal_values_drops_signals_without_enough_history():
closes = [100.0 + k for k in range(80)] # only 80 bars
vals = bt._signal_values(closes, closes, 79)
dates = [date(2024, 1, 1) + timedelta(days=k) for k in range(80)]
vals = bt._signal_values(dates, closes, closes, 79)
assert "mom_3_1" in vals # needs 63 bars of lookback — present
assert "mom_6_1" not in vals # needs 126 — absent
assert "mom_12_1" not in vals # needs 252 — absent