Files
set50-system/backend/tests/test_backtest_engine.py

214 lines
8.2 KiB
Python

"""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()