"""Integration tests for the event-driven backtest engine (Task 5).""" from __future__ import annotations import datetime as dt import unittest from app.backtest_engine import ( run_event_backtest, ) from app.backtest_events import DIVIDEND_PAYMENT_LAG_DAYS def make_series(symbols, start, days, price_map=None): """Price series; price_map maps date->{sym:price} overrides, else flat.""" s = dt.date.fromisoformat(start) out = {} for sym in symbols: bars = [] for i in range(days): d = (s + dt.timedelta(days=i)).isoformat() base = (price_map or {}).get(d, {}).get(sym, 10.0) bars.append({"date": d, "adjusted_close": float(base)}) out[sym] = {"bars": bars} return out def price_jump_series(symbols, start, days, jump_date, new_price=20.0): """Price series that jumps on/after jump_date for all symbols.""" s = dt.date.fromisoformat(start) jd = dt.date.fromisoformat(jump_date) out = {} for sym in symbols: bars = [] for i in range(days): d = (s + dt.timedelta(days=i)) px = new_price if d >= jd else 10.0 bars.append({"date": d.isoformat(), "adjusted_close": px}) out[sym] = {"bars": bars} return out class FakeFactorStore: """factor_key -> release timestamps; used to trigger releases on a date.""" def __init__(self, releases=None): self._rels = releases or {} def series(self, key): return [ {"released_at": ts, "observed_at": ts, "value": 1.0} for ts in self._rels.get(key, []) ] class FakeLedger: def __init__(self, entries): self._by = {} for e in entries: self._by.setdefault(e["symbol"], []).append(e) def symbols(self): return list(self._by.keys()) def entries(self, sym): return self._by.get(sym, []) def noop_scorer(symbols, as_of=None): # non-PIT scorer: all dividend, equal score -> allocation picks top by list out = {} for i, sym in enumerate(symbols): out[sym] = { "combined": 1.0 / (i + 1), "is_dividend": True, "dividend_yield": 3.0, } return out class NoLookAheadTest(unittest.TestCase): def test_signal_release_three_months_in_triggers_one_rebalance(self): series = make_series(["A", "B"], "2026-01-01", 200) # a factor release 3 months later (daily series, days ~90) rel = "2026-04-01T09:00:00+07:00" fstore = FakeFactorStore({"energy_net_margin": [rel]}) res = run_event_backtest( start="2026-01-01", end="2026-06-30", capital=100_000, factor_store=fstore, price_series=series, score_fn=noop_scorer, symbols=["A", "B"], ) # event-driven: releases were 1 (the release day) + initial; the initial # signal always triggers the first buy; the release triggers one more # rebalance (>=1 because portfolio changes when scores re-rank). self.assertGreaterEqual(res.rebalances, 1) self.assertTrue(res.account_reconciled) class LifecycleTest(unittest.TestCase): def test_full_lifecycle_reconciles_with_trade_pnl(self): # price jumps mid-run so a sell realizes real P&L series = price_jump_series(["A", "B"], "2026-01-01", 90, "2026-03-01", 20.0) # dividend on A before the jump ledger = FakeLedger([{ "symbol": "A", "ex_date": "2026-02-15", "per_share": 1.0, "estimate": False, }]) res = run_event_backtest( start="2026-01-01", end="2026-03-30", capital=100_000, dividend_ledger=ledger, price_series=series, score_fn=noop_scorer, symbols=["A", "B"], ) self.assertTrue(res.account_reconciled) # final equity must be consistent with reported components self.assertAlmostEqual( res.final_equity, res.capital + res.realized_trading_pnl + res.unrealized_trading_pnl + res.dividend_cash_received - res.transaction_costs, places=1, ) def test_dividend_cash_becomes_available_after_ex_date_plus_30(self): series = make_series(["A"], "2026-01-01", 200) ex = "2026-01-15" ledger = FakeLedger([{ "symbol": "A", "ex_date": ex, "per_share": 2.0, "estimate": False, }]) res = run_event_backtest( start="2026-01-01", end="2026-04-01", capital=200_000, dividend_ledger=ledger, price_series=series, score_fn=noop_scorer, symbols=["A"], ) # by end (after ex+30) the dividend has been paid into cash self.assertGreater(res.dividend_cash_received, 0) self.assertEqual(res.dividend_receivable, 0.0) class ResultContractTest(unittest.TestCase): def test_to_dict_has_expected_shape(self): series = make_series(["A"], "2026-01-01", 120) res = run_event_backtest( start="2026-01-01", end="2026-04-30", capital=100_000, price_series=series, score_fn=noop_scorer, symbols=["A"], ) d = res.to_dict() for key in [ "final_equity", "net_return", "realized_trading_pnl", "unrealized_trading_pnl", "price_pnl", "dividend_cash_received", "transaction_costs", "holdings", "trades", "leakage_guard", "accounting_reconciled", "fee_rate", "dividend_timing", ]: self.assertIn(key, d) self.assertEqual(d["fee_rate"], 0.003) self.assertEqual(d["dividend_timing"], "ex_date_plus_30d") def test_refuses_to_fallback_to_live_board_without_scorer(self): # strict event-driven mode must not freeze the live (non-PIT) board at a # historical release — that is look-ahead. It must fail closed. series = make_series(["A"], "2026-01-01", 120) with self.assertRaises(ValueError): run_event_backtest( start="2026-01-01", end="2026-04-30", capital=100_000, price_series=series, symbols=["A"], # no score_fn -> must raise ) def test_no_entitlement_for_shares_bought_on_ex_date(self): # Regression for the cycle-2 review breach (invariant #4): when an # execution day coincides with a dividend ex-date, shares acquired # ON the ex-date must not be entitled to that dividend. The engine must # capture entitlement from the pre-trade position. # Daily price series; A held from day 1. ex-date on a day when an # initial signal's next trading day also lands (coincident) — the buy on # that ex-date must NOT count toward entitlement. ledger = FakeLedger([{ "symbol": "A", "ex_date": "2026-01-02", "per_share": 1.0, "estimate": False, }]) # start next trading day after 2026-01-01 is 2026-01-02 (daily series), # which is the exact ex-date -> coincident execution + ex-date. series = make_series(["A", "B"], "2026-01-01", 30) res = run_event_backtest( start="2026-01-01", end="2026-03-31", capital=200_000, dividend_ledger=ledger, price_series=series, score_fn=noop_scorer, symbols=["A", "B"], ) # dividend was entitled for A, but only for shares held BEFORE the # ex-date. Since A was FIRST bought on the ex-date (initial signal's # next trading day), entitlement must be 0 (no receivable created). assert res.ledger is not None # no receivable for A on the ex-date (shares only acquired that day) a_receivables = [ r for r in res.ledger.state.receivables if r.symbol == "A" and r.ex_date == "2026-01-02" ] self.assertEqual(a_receivables, [], "shares bought on ex-date must not be entitled") # no dividend log entry either for d in (res.ledger.state.dividends or []): if d["symbol"] == "A" and d["ex_date"] == "2026-01-02": self.fail("dividend logged for shares bought on ex-date") # reconcile still holds self.assertTrue(res.account_reconciled) if __name__ == "__main__": unittest.main()