fix: unify backtest execution and add forward validation candidates
Share point-in-time signals and a cash-funded order ledger across backtests and trading. Correct eligibility, missing-price fills, quality factors, regime inputs, warmup windows, and cash-injection accounting. Add reproducible registered-strategy comparisons and paired forward candidates with a 20% resize band. Validation: 125 Python tests, 23 web tests, TypeScript/Vite build, 87 strategy causality checks, and real-data target/order/equity parity for 10 strategies.
This commit is contained in:
139
tests/test_execution_contract.py
Normal file
139
tests/test_execution_contract.py
Normal file
@@ -0,0 +1,139 @@
|
||||
import unittest
|
||||
import json
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
import data_manager
|
||||
import trader
|
||||
from execution import simulate_book, rebalance, portfolio_value
|
||||
from execution import compute_trades, market_permissions
|
||||
from signals import session_targets
|
||||
from strategies.momentum import MomentumStrategy
|
||||
from strategies.momentum_quality import rolling_max_drawdown
|
||||
from strategies.composite_alpha import CompositeAlphaStrategy
|
||||
from strategies.robust_momentum import RobustMomentumStrategy
|
||||
|
||||
|
||||
class ExecutionContracts(unittest.TestCase):
|
||||
def test_a_halt_cannot_be_sold_at_the_carried_mark(self):
|
||||
dates = pd.bdate_range("2026-01-05", periods=5)
|
||||
raw = pd.DataFrame({"A": [100, np.nan, np.nan, 10, 10]}, index=dates)
|
||||
targets = pd.DataFrame({"A": [1, 0, 0, 0, 0]}, index=dates)
|
||||
result = simulate_book(targets, raw, 10000, transaction_cost=0, min_trade_value=0)
|
||||
self.assertAlmostEqual(result["equity"].iloc[-1], 1000)
|
||||
self.assertEqual([t["date"] for t in result["trade_log"]],
|
||||
[str(dates[0].date()), str(dates[3].date())])
|
||||
|
||||
def test_fractional_and_integer_orders_are_funded_after_all_fees(self):
|
||||
prices = {"A": 73., "B": 29.}
|
||||
for integer in [False, True]:
|
||||
state = {"holdings": {}, "cash": 1000., "trade_log": []}
|
||||
rebalance(state, {"A": .5, "B": .5}, prices, set(prices),
|
||||
fixed_fee=2, fee_base=1.88, fee_per_share=.009,
|
||||
integer_shares=integer, min_trade_value=0)
|
||||
self.assertGreaterEqual(state["cash"], 0)
|
||||
equity = portfolio_value(state["holdings"], prices, state["cash"])
|
||||
self.assertAlmostEqual(equity, 1000 - sum(t["commission"] for t in state["trade_log"]), places=2)
|
||||
|
||||
def test_batch_replay_matches_session_ledger_with_a_halt_and_drift(self):
|
||||
dates = pd.bdate_range("2026-01-05", periods=5)
|
||||
prices = pd.DataFrame({"A": [100, 110, np.nan, 70, 90], "B": [20, 21, 22, 22, 23]}, index=dates)
|
||||
targets = pd.DataFrame([[.5, .5], [.5, .5], [0, 1], [0, 1], [.5, .5]], index=dates, columns=prices.columns)
|
||||
options = dict(transaction_cost=.001, fixed_fee=2., integer_shares=True, resize_band=.1)
|
||||
batch = simulate_book(targets, prices, 10000, **options)
|
||||
state = trader.init_state("us", "test", 10000)
|
||||
fees = {"tx_cost": .001, "fixed_fee": 2.}
|
||||
for day in dates:
|
||||
trader._run_session(state, "us", day, "B", prices.ffill(), prices, fees, True, targets.loc[day])
|
||||
self.assertAlmostEqual(state["daily_equity"][str(day.date())], batch["equity"].loc[day], places=2)
|
||||
self.assertEqual(state["trade_log"], batch["trade_log"])
|
||||
|
||||
def test_contributions_are_executed_and_fees_affect_unit_returns(self):
|
||||
dates = pd.bdate_range("2026-01-05", periods=3)
|
||||
prices = pd.DataFrame({"A": [100., 100., 100.]}, index=dates)
|
||||
weights = pd.DataFrame(1., index=dates, columns=["A"])
|
||||
base = simulate_book(weights, prices, 1000, transaction_cost=0, fixed_fee=2)
|
||||
injected = simulate_book(weights, prices, 1000, transaction_cost=0, fixed_fee=2,
|
||||
contributions=pd.Series([0, 1000, 0], index=dates))
|
||||
self.assertEqual(len(base["trade_log"]), 1)
|
||||
self.assertEqual(len(injected["trade_log"]), 2)
|
||||
self.assertAlmostEqual(injected["equity"].iloc[-1], 1996)
|
||||
self.assertLess(injected["unit_equity"].iloc[-1], base["unit_equity"].iloc[-1])
|
||||
|
||||
def test_missing_open_never_sees_same_day_close(self):
|
||||
close = pd.DataFrame({"A": [100., 200.]})
|
||||
raw_open = pd.DataFrame({"A": [np.nan, np.nan]})
|
||||
self.assertTrue(data_manager._fill_open(close, raw_open).isna().all().all())
|
||||
|
||||
def test_china_limit_permissions_tax_and_star_minimum(self):
|
||||
raw = pd.Series({"600000.SS": 11., "000001.SZ": 9., "688001.SS": 12.})
|
||||
buy, sell, tax = market_permissions("cn", pd.Timestamp("2026-09-30"), raw, raw * 0 + 10)
|
||||
self.assertNotIn("600000.SS", buy)
|
||||
self.assertNotIn("000001.SZ", sell)
|
||||
self.assertNotIn("688001.SS", buy)
|
||||
self.assertEqual(tax, .0005)
|
||||
self.assertEqual(market_permissions("cn", pd.Timestamp("2023-08-25"), raw, raw * 0 + 10)[2], .001)
|
||||
orders = compute_trades({}, 10000, {"688001.SS": .7}, {"688001.SS": 23.},
|
||||
integer_shares=True, lot_size=100)
|
||||
self.assertEqual(orders[0]["shares_delta"], 304)
|
||||
|
||||
|
||||
class SignalContracts(unittest.TestCase):
|
||||
def test_live_refresh_updates_the_membership_snapshot_before_selection(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
cache = Path(tmp) / "sp500_history.json"
|
||||
cache.write_text(json.dumps({"date": "2000-01-01", "intervals": {"OLD": [["2020-01-01", None]]}}))
|
||||
history = {"OLD": [["2020-01-01", "2026-09-30"]], "NEW": [["2026-09-30", None]]}
|
||||
with mock.patch.object(trader.universe_history, "CACHE_DIR", tmp), \
|
||||
mock.patch.object(trader.universe_history, "build_sp500_history", return_value=history), \
|
||||
mock.patch.object(trader, "strategy_universe", return_value=(["NEW"], "SPY")), \
|
||||
mock.patch.object(data_manager, "update"):
|
||||
trader.refresh_panels("us", ["momentum"], "test")
|
||||
member = trader.index_members("us", pd.DatetimeIndex(["2026-10-01"]))
|
||||
self.assertTrue(member.iloc[0]["NEW"])
|
||||
self.assertFalse(member.iloc[0]["OLD"])
|
||||
|
||||
def test_close_signal_is_unaffected_by_its_execution_days_close(self):
|
||||
dates = pd.bdate_range("2024-01-01", periods=30)
|
||||
c = pd.DataFrame({"A": np.arange(100., 130.), "B": np.arange(130., 100., -1)}, index=dates)
|
||||
s = MomentumStrategy(lookback=5, skip=1, top_n=1)
|
||||
base = session_targets(s, c)
|
||||
shocked = c.copy(); shocked.iloc[20:] = shocked.iloc[20:] * [0.1, 10]
|
||||
actual = session_targets(s, shocked)
|
||||
pd.testing.assert_frame_equal(base.iloc[:21], actual.iloc[:21])
|
||||
|
||||
def test_trader_and_backtest_use_identical_membership_histories(self):
|
||||
rng = np.random.default_rng(42)
|
||||
dates = pd.bdate_range("2024-01-01", periods=370)
|
||||
close = pd.DataFrame(100 * np.exp(rng.normal(.0005, .02, (370, 25)).cumsum(0)),
|
||||
index=dates, columns=[f"S{i}" for i in range(25)])
|
||||
opens = close.shift(1).fillna(100)
|
||||
member = pd.DataFrame(True, index=dates, columns=close.columns)
|
||||
member.iloc[:320, 0] = False
|
||||
member.iloc[330:, 1] = False
|
||||
bars = {"close": close, "open": opens}
|
||||
strategy = RobustMomentumStrategy("overnight_recovery", top_n=5)
|
||||
expected = session_targets(strategy, close, opens, bars, member)
|
||||
with mock.patch.object(trader, "index_members", lambda m, d: member.reindex(d)):
|
||||
actual = trader.session_signals("us", "overnight_recovery_top10", list(close), dates[-20:],
|
||||
close, opens, bars=bars, strategy=strategy)
|
||||
pd.testing.assert_frame_equal(actual, expected.reindex(index=actual.index, columns=actual.columns))
|
||||
self.assertTrue((actual["S1"] == 0).all())
|
||||
|
||||
def test_quality_prefers_a_smaller_drawdown_and_needs_only_one_window(self):
|
||||
p = pd.DataFrame({"small": [100, 110, 99, 108], "large": [100, 110, 55, 108]})
|
||||
dd = rolling_max_drawdown(p, 4)
|
||||
self.assertGreater(dd.iloc[-1]["small"], dd.iloc[-1]["large"])
|
||||
self.assertAlmostEqual(dd.iloc[-1]["small"], -.1)
|
||||
|
||||
def test_missing_regime_benchmark_is_an_error(self):
|
||||
with self.assertRaisesRegex(ValueError, "benchmark"):
|
||||
CompositeAlphaStrategy().generate_signals(pd.DataFrame({"A": [1, 2]}))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -77,6 +77,7 @@ class LiveBookTests(unittest.TestCase):
|
||||
mock.patch.object(trader, "strategy_universe", lambda m, n: (["AAA", "BBB"], "SPY")), \
|
||||
mock.patch.object(trader, "pit_candidates", lambda m, n, s: []), \
|
||||
mock.patch.object(trader, "load_strategy_prices", lambda *a, **k: (close, close, close)), \
|
||||
mock.patch.object(data_manager, "load_raw", lambda *a, **k: None), \
|
||||
mock.patch.object(trader, "session_signals", lambda m, n, t, days, c, o: equal.loc[days]):
|
||||
return trader._process_sessions(args, state, refresh=False)
|
||||
|
||||
@@ -151,20 +152,17 @@ class ReportingTests(unittest.TestCase):
|
||||
equity = pd.Series(10_000 * 1.0003 ** np.arange(400), index=idx)
|
||||
schedule = {"amount": 5_000, "frequency": "yearly_months", "months": [2, 8], "startYear": 1}
|
||||
plain = bridge._build_strategy_result("x", equity, [], None, 10_000)
|
||||
injected = bridge._build_strategy_result("x", equity, [], schedule, 10_000)
|
||||
self.assertAlmostEqual(injected["metrics"]["totalReturn"], plain["metrics"]["totalReturn"])
|
||||
flows = injected["capitalFlows"]
|
||||
self.assertEqual(flows["invested"], 10_000 + 5_000 * len(injected["injections"]))
|
||||
self.assertAlmostEqual(flows["profit"], flows["finalValue"] - flows["invested"], places=2)
|
||||
with self.assertRaisesRegex(ValueError, "order replay"):
|
||||
bridge._build_strategy_result("x", equity, [], schedule, 10_000)
|
||||
|
||||
def test_ruined_portfolio_stays_at_zero(self):
|
||||
def test_unfunded_leverage_is_rejected(self):
|
||||
class TwiceLevered:
|
||||
def generate_signals(self, data):
|
||||
return pd.DataFrame(2.0, index=data.index, columns=data.columns)
|
||||
|
||||
prices = pd.DataFrame({"X": [10.0, 10.0, 4.0, 5.0]}, index=pd.bdate_range("2026-01-01", periods=4))
|
||||
equity = backtest(TwiceLevered(), prices, initial_capital=100)
|
||||
self.assertTrue((equity.iloc[2:] == 0).all(), equity)
|
||||
with self.assertRaisesRegex(ValueError, "unfunded"):
|
||||
backtest(TwiceLevered(), prices, initial_capital=100)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -155,12 +155,11 @@ class BacktestUniverseTests(unittest.TestCase):
|
||||
panel = pd.DataFrame(1.0, index=idx, columns=["AAA", "BBB", "SPY", "TQQQ"])
|
||||
fake_universe = {"us": {"fetch": lambda: ["AAA"], "benchmark": "SPY",
|
||||
"benchmark_label": "SPY"}}
|
||||
members = pd.DataFrame(True, index=idx, columns=["AAA", "BBB"])
|
||||
with mock.patch.object(backtest_runner, "UNIVERSES", fake_universe), \
|
||||
mock.patch.object(backtest_runner.uh, "pit_tickers_ever", lambda m, since=None: ["AAA", "BBB"]), \
|
||||
mock.patch.object(backtest_runner.uh, "mask_prices_pit", lambda p, m: p), \
|
||||
mock.patch.object(backtest_runner.data_manager, "update",
|
||||
lambda m, t, fields=None: {f: panel for f in fields}):
|
||||
data = backtest_runner.load_stock_panels("us", ["momentum"], None)
|
||||
mock.patch.object(backtest_runner.uh, "pit_membership", lambda m, days: members.reindex(days).fillna(False)), \
|
||||
mock.patch.object(backtest_runner, "_panels", lambda *a: {"close": panel, "open": panel}):
|
||||
data = backtest_runner.load_stock_panels("us", ["momentum"], None, refresh=False)
|
||||
self.assertEqual(data["stocks"], ["AAA", "BBB"])
|
||||
|
||||
|
||||
@@ -180,15 +179,13 @@ class MembershipTests(unittest.TestCase):
|
||||
bars["open"]["S00"] = bars["close"]["S00"]
|
||||
days = bars["close"].index[-5:]
|
||||
others = [c for c in bars["close"].columns if c != "S00"]
|
||||
members = pd.DataFrame(True, index=days[:-1], columns=bars["close"].columns)
|
||||
members["S00"] = False # joins the index on the last day
|
||||
with mock.patch.object(trader, "index_members", lambda m, d: members.loc[d]):
|
||||
runs = trader.session_universes("us", "momentum", others + ["S00"], days)
|
||||
members = pd.DataFrame(True, index=bars["close"].index, columns=bars["close"].columns)
|
||||
members.loc[members.index < days[-1], "S00"] = False
|
||||
with mock.patch.object(trader, "index_members", lambda m, d: members.reindex(d)):
|
||||
weights = trader.session_signals("us", "momentum", others + ["S00"], days,
|
||||
bars["close"], bars["open"])
|
||||
self.assertEqual([len(r) for _, r in runs], [4, 1])
|
||||
self.assertNotIn("S00", runs[0][0])
|
||||
self.assertTrue((weights.loc[days[:-1], "S00"] == 0).all())
|
||||
# The historical observations were public before index admission.
|
||||
self.assertGreater(weights.loc[days[-1], "S00"], 0)
|
||||
|
||||
def test_changes_table_with_two_level_headers_is_found(self):
|
||||
|
||||
@@ -3,6 +3,7 @@ kept to their own columns, shares that drift like separate accounts, and the
|
||||
live trader's session timing."""
|
||||
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
@@ -82,6 +83,17 @@ class SleevePortfolioTests(unittest.TestCase):
|
||||
got = trader.bar_session_weights(strat, bars, days)
|
||||
pd.testing.assert_frame_equal(got, full)
|
||||
|
||||
def test_trader_truncates_all_bar_fields_before_padding_sleeves(self):
|
||||
bars = _bars()
|
||||
days = bars["close"].index[-4:-2]
|
||||
stocks = [c for c in bars["close"] if c not in ETF_UNIVERSE]
|
||||
member = pd.DataFrame(True, index=bars["close"].index, columns=stocks)
|
||||
expected = us_three_sleeve().generate_signals({f: b.loc[:days[-1]] for f, b in bars.items()}).loc[days]
|
||||
with mock.patch.object(trader, "index_members", lambda m, d: member.reindex(d)):
|
||||
actual = trader.session_signals("us", "us_three_sleeve", list(bars["close"]), days,
|
||||
bars["close"], bars["open"])
|
||||
pd.testing.assert_frame_equal(actual, expected.reindex(columns=actual.columns))
|
||||
|
||||
def test_registered_as_a_us_mixed_strategy(self):
|
||||
self.assertIsInstance(trader.STRATEGY_REGISTRY["us_three_sleeve"](), SleevePortfolioStrategy)
|
||||
self.assertEqual(set(trader.strategy_extra_tickers("us_three_sleeve")), set(ETF_UNIVERSE))
|
||||
|
||||
Reference in New Issue
Block a user