"""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") if __name__ == "__main__": unittest.main()