P0-B (registry is the single source of truth for scoring): - FACTORS now carries center/span normalization spec; unused hand-written per-theme surprise blocks in dashboard.py replaced by one registry-driven compute_theme_surprises() (themes.py). - THEMES['banks'] adds bank_npl weight so NPL is genuinely blended. - factor_value/normalize hardened against NaN/inf (finite guards). - Board re-ranks (TRUE/GULF up, TOP->3) per registry weights; 3 new tests incl. 'changing a registry weight changes output'. P3 (point-in-time backtest): - run_backtest is now a real multi-rebalance engine (reallocates every window, reconciles holdings, marks to market) instead of allocate-once+break. - Added leakage_guard (False unless a PIT score_fn is supplied), planned vs actual rebalances, and momentum_at() true 12-1 (skips last month, PIT). P4 (factor-weight learning): - weight_learning.py: cross-sectional Spearman IC, forward-return builder, IC aggregation + t-stat, and apply_weight_update (new = clip(old*(1+shrink*IC))). - GET /api/v1/learning/momentum endpoint. Live result: momentum IC=0.012 t=0.132 over 22 periods -> momentum has no reliable predictive power here. Macro/demographic factors blocked (no historical factor vintages yet). Two independent review gates passed (deleg_fe6f45cd, deleg_718218f8): empty security/logic arrays; their non-blocking suggestions applied (finite guards, dedupe leakage_guard resolution). 226 tests pass; Vite build passes.
88 lines
3.3 KiB
Python
88 lines
3.3 KiB
Python
"""Tests for the point-in-time multi-rebalance backtest engine."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import datetime as dt
|
|
import unittest
|
|
from unittest.mock import patch
|
|
|
|
from app import backtest
|
|
|
|
|
|
def _fake_series() -> dict:
|
|
"""Synthetic daily price series for A (up) and B (flat), 300+ days."""
|
|
def bars(base, drift):
|
|
out = []
|
|
for i in range(400):
|
|
d = (dt.date(2025, 1, 1) + dt.timedelta(days=i)).isoformat()
|
|
out.append({"date": d, "adjusted_close": base + drift * i})
|
|
return out
|
|
return {"A": {"bars": bars(10.0, 0.1)}, "B": {"bars": bars(20.0, 0.0)}}
|
|
|
|
|
|
class RebalanceDatesTest(unittest.TestCase):
|
|
def test_monthly(self):
|
|
d = backtest._rebalance_dates("2026-01-01", "2026-04-01")
|
|
self.assertEqual(d, ["2026-01-01", "2026-02-01", "2026-03-01", "2026-04-01"])
|
|
|
|
def test_quarterly_boundaries(self):
|
|
d = backtest._rebalance_dates("2026-01-01", "2027-04-01", "quarterly")
|
|
self.assertEqual(d, ["2026-01-01", "2026-04-01", "2026-07-01",
|
|
"2026-10-01", "2027-01-01", "2027-04-01"])
|
|
|
|
def test_end_after_start_required(self):
|
|
with self.assertRaises(backtest.BacktestError):
|
|
backtest._rebalance_dates("2026-06-01", "2026-06-01")
|
|
|
|
def test_bad_freq(self):
|
|
with self.assertRaises(backtest.BacktestError):
|
|
backtest._rebalance_dates("2026-01-01", "2026-06-01", "weekly")
|
|
|
|
|
|
class MomentumAtTest(unittest.TestCase):
|
|
def test_true_12_1_skips_last_month(self):
|
|
series = _fake_series()
|
|
d = dt.date(2026, 5, 1)
|
|
m = backtest.momentum_at(series, "A", d)
|
|
# A rises 0.1/day; momentum over 12m should be clearly positive.
|
|
self.assertIsNotNone(m)
|
|
self.assertTrue(m is not None and m > 0.0)
|
|
|
|
|
|
class RunBacktestTest(unittest.TestCase):
|
|
def _score_fn(self, syms, as_of):
|
|
return {s: {"combined": 1.0 if s == "A" else 0.5,
|
|
"is_dividend": True, "dividend_yield": 2.0} for s in syms}
|
|
|
|
@patch("app.backtest.load_price_snapshot", return_value=_fake_series())
|
|
def test_multi_rebalance_reuses_portfolio(self, _load):
|
|
res = backtest.run_backtest(
|
|
"2026-01-01", "2026-06-01", capital=1_000_000,
|
|
rebalance_freq="monthly", score_fn=self._score_fn,
|
|
symbols=["A", "B"],
|
|
)
|
|
self.assertEqual(res.planned_rebalances, 6)
|
|
# Real re-allocations happened (price data exists for every window).
|
|
self.assertEqual(res.rebalances, 6)
|
|
self.assertGreater(res.trades, 0)
|
|
# Supplying a score_fn -> leakage_guard True (PIT contract).
|
|
self.assertTrue(res.leakage_guard)
|
|
|
|
@patch("app.backtest.load_price_snapshot", return_value={})
|
|
def test_no_price_snapshot_raises(self, _load):
|
|
with self.assertRaises(backtest.BacktestError):
|
|
backtest.run_backtest("2026-01-01", "2026-06-01")
|
|
|
|
@patch("app.backtest.load_price_snapshot", return_value=_fake_series())
|
|
def test_default_no_score_fn_non_pit(self, _load):
|
|
# Without a score_fn, the engine uses the current board -> non-PIT.
|
|
res = backtest.run_backtest(
|
|
"2026-01-01", "2026-03-01", capital=100_000,
|
|
rebalance_freq="monthly", symbols=["A", "B"],
|
|
)
|
|
self.assertFalse(res.leakage_guard)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|