"""Every registered strategy is causal on the production signal path. The targets a replay or the live trader trades on a session must not change when later bars are removed. ``trader._signals_for_sessions`` and the batch replay compute all sessions in one pass, which equals running each session on its own only for causal strategies, so a new strategy that reads a later bar (a missing shift, a full-sample statistic, a backward fill) fails here. """ import unittest import numpy as np import pandas as pd import trader from backtest_runner import prepare_strategy from universe import UNIVERSES N_DAYS, N_STOCKS = 700, 60 CUTS = (560, 640) # truncation points (row positions) START = 480 # first compared session def _panel(seed: int = 7) -> tuple[dict[str, pd.DataFrame], pd.DataFrame, list[str]]: rng = np.random.default_rng(seed) stocks = [f"S{i:02d}" for i in range(N_STOCKS)] etfs = sorted({t for u in trader.ETF_STRATEGY_UNIVERSES.values() for t in u} | {t for u in trader.MIXED_STRATEGY_EXTRA_TICKERS.values() for t in u} | {UNIVERSES[m]["benchmark"] for m in UNIVERSES}) cols = stocks + etfs idx = pd.bdate_range("2023-01-02", periods=N_DAYS) r = rng.normal(0.0004, 0.02, (N_DAYS, len(cols))) close = pd.DataFrame(100 * np.exp(np.cumsum(r, axis=0)), index=idx, columns=cols) opens = close.shift(1).bfill() * np.exp(rng.normal(0, 0.005, close.shape)) high = np.maximum(opens, close) * (1 + rng.uniform(0, 0.02, close.shape)) low = np.minimum(opens, close) * (1 - rng.uniform(0, 0.02, close.shape)) volume = pd.DataFrame(rng.uniform(1e5, 1e6, close.shape), index=idx, columns=cols) # Index changes: five names join late (price history before admission is # observable), five leave, one stops printing like an acquired stock. member = pd.DataFrame(True, index=idx, columns=stocks) member.iloc[:300, :5] = False member.iloc[500:, 5:10] = False for frame in (close, opens, high, low): frame.iloc[600:, 10] = np.nan volume.iloc[600:, 10] = np.nan panels = {"close": close, "open": opens, "high": high, "low": low, "volume": volume} return panels, member, stocks class CausalityContract(unittest.TestCase): @classmethod def setUpClass(cls): cls.panels, cls.member, cls.stocks = _panel() def _targets(self, name: str, market: str, end) -> pd.DataFrame: pure_etf = trader.canonical_strategy_name(name) in trader.ETF_STRATEGY_UNIVERSES cols = trader.ETF_STRATEGY_UNIVERSES[name] if pure_etf else self.stocks start = self.panels["close"].index[START] return prepare_strategy(name, market, self.panels, list(cols), member=None if pure_etf else self.member, start=start, end=end)["targets"] def test_targets_ignore_later_bars(self): idx = self.panels["close"].index leaks, idle = [], [] for market in ("us", "cn"): for name in trader.STRATEGY_REGISTRY: if name in trader.AGENT_REVIEW_STRATEGIES or not trader.strategy_supports_market(name, market): continue full = self._targets(name, market, None) if not (full.to_numpy() > 0).any(): idle.append(f"{market}:{name}") for cut in CUTS: part = self._targets(name, market, idx[cut]) cols = full.columns.union(part.columns) a = full.reindex(index=part.index, columns=cols).fillna(0.0) b = part.reindex(columns=cols).fillna(0.0) worst = float((a - b).abs().to_numpy().max()) if worst > 1e-9: leaks.append(f"{market}:{name} (cut {idx[cut].date()}, max diff {worst:.3g})") self.assertEqual(leaks, [], "targets depend on bars after their session") self.assertEqual(idle, [], "never invested on the synthetic panel, so the check is vacuous") if __name__ == "__main__": unittest.main()