214 lines
8.2 KiB
Python
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()
|