quantred 2.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) hide show
  1. quantred/__init__.py +24 -0
  2. quantred/alphas/__init__.py +53 -0
  3. quantred/alphas/base.py +221 -0
  4. quantred/alphas/combine.py +260 -0
  5. quantred/alphas/horizon.py +153 -0
  6. quantred/alphas/refine.py +300 -0
  7. quantred/alphas/scorers.py +87 -0
  8. quantred/analytics/attribution.py +354 -0
  9. quantred/analytics/bootstrap.py +353 -0
  10. quantred/analytics/charts.py +393 -0
  11. quantred/analytics/excursion.py +185 -0
  12. quantred/analytics/execution_quality.py +180 -0
  13. quantred/analytics/exposure.py +167 -0
  14. quantred/analytics/htmlreport.py +1208 -0
  15. quantred/analytics/information.py +197 -0
  16. quantred/analytics/metrics.py +502 -0
  17. quantred/analytics/performance.py +639 -0
  18. quantred/analytics/reporting.py +1672 -0
  19. quantred/analytics/screening.py +190 -0
  20. quantred/analytics/series_comparison.py +303 -0
  21. quantred/analytics/trade_analytics.py +353 -0
  22. quantred/brokers/alpaca/broker.py +415 -0
  23. quantred/brokers/alpaca/factory.py +33 -0
  24. quantred/brokers/alpaca/market_data.py +166 -0
  25. quantred/brokers/base.py +220 -0
  26. quantred/brokers/dryrun.py +164 -0
  27. quantred/brokers/errors.py +70 -0
  28. quantred/cli.py +8619 -0
  29. quantred/costs/__init__.py +11 -0
  30. quantred/costs/base.py +126 -0
  31. quantred/costs/parametric.py +131 -0
  32. quantred/data/__init__.py +29 -0
  33. quantred/data/compute.py +332 -0
  34. quantred/data/edges.py +81 -0
  35. quantred/data/features.py +120 -0
  36. quantred/data/panel.py +61 -0
  37. quantred/data/scan.py +85 -0
  38. quantred/data/store.py +269 -0
  39. quantred/demo/__init__.py +22 -0
  40. quantred/demo/scanners.py +104 -0
  41. quantred/demo/strategies.py +125 -0
  42. quantred/engine/backtest.py +1206 -0
  43. quantred/engine/barcheck.py +228 -0
  44. quantred/engine/live.py +539 -0
  45. quantred/execution/decision.py +192 -0
  46. quantred/execution/flatten.py +302 -0
  47. quantred/execution/halt.py +165 -0
  48. quantred/execution/ledger.py +758 -0
  49. quantred/execution/live_trader.py +587 -0
  50. quantred/execution/order_id.py +83 -0
  51. quantred/execution/sizing.py +114 -0
  52. quantred/indicators/indicators.py +158 -0
  53. quantred/marketdata/base.py +62 -0
  54. quantred/marketdata/client.py +59 -0
  55. quantred/marketdata/session.py +112 -0
  56. quantred/marketdata/synthetic.py +74 -0
  57. quantred/marketdata/timeframe.py +125 -0
  58. quantred/mcp/__init__.py +6 -0
  59. quantred/mcp/selftest.py +139 -0
  60. quantred/mcp/server.py +2419 -0
  61. quantred/optimization/causality.py +607 -0
  62. quantred/optimization/config_store.py +300 -0
  63. quantred/optimization/optimizer.py +431 -0
  64. quantred/optimization/parallel.py +345 -0
  65. quantred/optimization/param_space.py +309 -0
  66. quantred/optimization/walk_forward.py +1254 -0
  67. quantred/portfolio/allocator.py +135 -0
  68. quantred/portfolio/benchmark.py +109 -0
  69. quantred/portfolio/optimizer.py +786 -0
  70. quantred/portfolio/policy.py +335 -0
  71. quantred/portfolio/posterior.py +208 -0
  72. quantred/research/__init__.py +30 -0
  73. quantred/research/agent.py +868 -0
  74. quantred/research/demo_proposals.py +222 -0
  75. quantred/research/llm.py +311 -0
  76. quantred/research/proposer.py +234 -0
  77. quantred/research/provider_errors.py +242 -0
  78. quantred/research/sandbox.py +340 -0
  79. quantred/risk/__init__.py +60 -0
  80. quantred/risk/base.py +218 -0
  81. quantred/risk/conditional.py +474 -0
  82. quantred/risk/exposures.py +88 -0
  83. quantred/risk/factor.py +119 -0
  84. quantred/risk/sample.py +89 -0
  85. quantred/risk/streaming.py +214 -0
  86. quantred/scanners/base.py +141 -0
  87. quantred/scanners/symbol_scanner.py +148 -0
  88. quantred/services/__init__.py +51 -0
  89. quantred/services/analysis.py +6082 -0
  90. quantred/services/audit.py +574 -0
  91. quantred/services/campaign.py +381 -0
  92. quantred/services/configs.py +282 -0
  93. quantred/services/data.py +172 -0
  94. quantred/services/dryrun.py +206 -0
  95. quantred/services/glossary.py +130 -0
  96. quantred/services/maintenance.py +314 -0
  97. quantred/services/registry.py +351 -0
  98. quantred/services/setup.py +1023 -0
  99. quantred/services/sizing.py +150 -0
  100. quantred/services/smallreal.py +450 -0
  101. quantred/settings.py +571 -0
  102. quantred/store/__init__.py +0 -0
  103. quantred/store/bars.py +660 -0
  104. quantred/store/trials.py +1804 -0
  105. quantred/strategies/base.py +643 -0
  106. quantred/strategies/signals.py +28 -0
  107. quantred/utils/errors.py +48 -0
  108. quantred/utils/extras.py +330 -0
  109. quantred/utils/logging_config.py +33 -0
  110. quantred/utils/numeric.py +46 -0
  111. quantred/utils/streaming.py +474 -0
  112. quantred/utils/timeutils.py +148 -0
  113. quantred-2.4.0.dist-info/METADATA +632 -0
  114. quantred-2.4.0.dist-info/RECORD +117 -0
  115. quantred-2.4.0.dist-info/WHEEL +4 -0
  116. quantred-2.4.0.dist-info/entry_points.txt +8 -0
  117. quantred-2.4.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,607 @@
1
+ """Causality probes: could this decision have been made when it was made?
2
+
3
+ **These are a different class of probe from the leakage probe, and conflating them is
4
+ how three days were lost.** The leakage probe shifts the whole feed forward and checks
5
+ the result changes; it tests for *future data* — a strategy reading bars it should not
6
+ have. It ran against a candidate and passed while the engine was executing every signal
7
+ one bar before it could have known, because a feed shift moves signal and price
8
+ together: the relationship "signal from bar i's close, filled at bar i's open" survives
9
+ the shift completely intact.
10
+
11
+ So a feed-shift probe cannot test intra-bar causality, and a passing one says nothing
12
+ about it. What follows tests the other question: for each decision, was every input to
13
+ it available *strictly before* the price it transacted at.
14
+
15
+ The method is perturbation, not inspection. Change something that only becomes knowable
16
+ after the fill, re-run, and require the decision at that instant to be unchanged. A
17
+ decision that moves consumed information from its own future — whatever the code looks
18
+ like, and whichever layer did it.
19
+
20
+ Each probe reports ``passed=None`` when the run gave it nothing to test. That is not a
21
+ pass: a probe that could not run and a probe that ran and found nothing are different
22
+ facts, and only one of them is evidence.
23
+
24
+ **What a pass here does not cover.** The perturbation is bounded by the bar's own
25
+ high/low, because moving a close outside it would move the high or low with it, and a
26
+ stop or take-profit legitimately reads those on the bar it fills on - a wider bar would
27
+ report a leak that is not there. The cost is real: a dependency that only changes an
28
+ outcome when the close moves *further* than the bar actually ranged is not reachable
29
+ this way. The probes also examine a sample of decision instants, so a dependency that
30
+ binds only at an instant outside the sample is not reached either. Both limits are
31
+ reported in the result (``sampled``, ``instants_probed``) rather than left implicit,
32
+ because the failure mode of any probe is being read as broader than it is - which is
33
+ the whole reason this module exists.
34
+ """
35
+
36
+ import logging
37
+ from dataclasses import dataclass, field
38
+ from datetime import datetime
39
+ from typing import Any, Callable, Dict, List, Optional, Sequence
40
+
41
+ import pandas as pd
42
+
43
+ from quantred.engine.backtest import BacktestEngine
44
+ from quantred.marketdata.client import MarketDataClient
45
+ from quantred.optimization.walk_forward import PrefetchedProvider
46
+
47
+ logger = logging.getLogger(__name__)
48
+
49
+ #: How many decision instants each perturbation probe examines. Every one costs a
50
+ #: re-run, so this is the knob between "cheap enough to run routinely" and "exhaustive".
51
+ #: Reported in the result, because a probe that examined 3 of 40 entries and said
52
+ #: "passed" without saying so would be claiming far more than it checked.
53
+ DEFAULT_SAMPLE = 3
54
+
55
+ #: Probe classes. Named, and carried on every result, so a report cannot present a
56
+ #: future-data verdict as an intra-bar one.
57
+ FUTURE_DATA = "future-data"
58
+ INTRA_BAR = "intra-bar causality"
59
+ AS_OF = "as-of clock"
60
+
61
+
62
+ @dataclass
63
+ class ProbeResult:
64
+ """One probe's verdict, and what it actually examined.
65
+
66
+ ``passed`` is deliberately three-valued. ``None`` means the probe could not be
67
+ exercised — no trades, no competition for slots, no benchmark — and it must never
68
+ be rendered as a pass. A run that gave a probe nothing to look at has not been
69
+ cleared by it.
70
+ """
71
+
72
+ name: str
73
+ probe_class: str
74
+ passed: Optional[bool]
75
+ reason: str
76
+ detail: Dict[str, Any] = field(default_factory=dict)
77
+
78
+ def as_dict(self) -> Dict[str, Any]:
79
+ return {
80
+ "name": self.name,
81
+ "probe_class": self.probe_class,
82
+ "passed": self.passed,
83
+ "reason": self.reason,
84
+ **self.detail,
85
+ }
86
+
87
+
88
+ # --------------------------------------------------------------------------- #
89
+ # Perturbations
90
+ # --------------------------------------------------------------------------- #
91
+ def _replace(frame: pd.DataFrame, when, column: str, value: float) -> pd.DataFrame:
92
+ """A copy of ``frame`` with one cell replaced, widening the column to hold it.
93
+
94
+ The cast is not incidental. A user's bars can arrive with integer price or volume
95
+ columns, and pandas refuses a float into an int column rather than silently
96
+ truncating - so a probe that skipped this would raise on real data while passing
97
+ every test written against float fixtures.
98
+ """
99
+ out = frame.copy()
100
+ out[column] = out[column].astype("float64")
101
+ out.loc[when, column] = float(value)
102
+ return out
103
+
104
+
105
+ def _perturbed_close(frame: pd.DataFrame, when) -> Optional[pd.DataFrame]:
106
+ """A copy of ``frame`` whose bar at ``when`` closes at the other end of its range.
107
+
108
+ Deliberately *inside* the bar's existing high/low. Moving the close outside would
109
+ force the high or low to move with it, and a stop or take-profit legitimately reads
110
+ the high and low of the bar it fills on — so a wider bar would change exits for an
111
+ honest reason and the probe would report a leak that is not there.
112
+
113
+ ``None`` when the bar has no range to move within: a flat bar carries no
114
+ post-open information to withhold, so there is nothing to test at it.
115
+ """
116
+ if when not in frame.index:
117
+ return None
118
+ row = frame.loc[when]
119
+ low, high, close = float(row["low"]), float(row["high"]), float(row["close"])
120
+ if high <= low:
121
+ return None
122
+ # The far end from where it actually closed, so the change is as large as the bar
123
+ # allows. A small nudge can leave a threshold-crossing score unchanged, and a probe
124
+ # that perturbs too gently passes everything.
125
+ replacement = low if close - low > high - close else high
126
+ if replacement == close:
127
+ return None
128
+ return _replace(frame, when, "close", replacement)
129
+
130
+
131
+ def _perturbed_volume(frame: pd.DataFrame, when) -> Optional[pd.DataFrame]:
132
+ """A copy whose bar at ``when`` carries a very different volume.
133
+
134
+ Volume is the other thing about a bar that is unknown at its open, and it is not
135
+ inert: it feeds the trailing average-daily-volume the cost model charges impact
136
+ against, so it can reach the affordability test that admits or declines an entry.
137
+ """
138
+ if when not in frame.index or "volume" not in frame:
139
+ return None
140
+ current = float(frame.loc[when, "volume"])
141
+ return _replace(frame, when, "volume", current * 25.0 + 1_000.0)
142
+
143
+
144
+ def _entries_at(trades: pd.DataFrame, when) -> List[Dict[str, Any]]:
145
+ """Every entry filled at ``when``, as comparable records."""
146
+ if trades is None or trades.empty or "entry_time" not in trades:
147
+ return []
148
+ rows = trades[trades["entry_time"] == when]
149
+ return sorted(
150
+ (
151
+ {
152
+ "symbol": str(r["symbol"]),
153
+ "side": str(r["side"]),
154
+ "entry_price": round(float(r["entry_price"]), 8),
155
+ "size": round(float(r["size"]), 8),
156
+ }
157
+ for _, r in rows.iterrows()
158
+ ),
159
+ key=lambda r: r["symbol"],
160
+ )
161
+
162
+
163
+ def _signal_exits_at(trades: pd.DataFrame, when) -> List[Dict[str, Any]]:
164
+ """Signal-driven exits at ``when``.
165
+
166
+ Stop and take-profit exits are excluded on purpose: those legitimately read the
167
+ high and low of the bar they fill on, so they are not evidence about the decision
168
+ clock. A signal exit is, because it must come from a prior bar's score.
169
+ """
170
+ if trades is None or trades.empty or "exit_time" not in trades:
171
+ return []
172
+ rows = trades[(trades["exit_time"] == when) & (trades.get("exit_reason") == "signal")]
173
+ return sorted(
174
+ (
175
+ {"symbol": str(r["symbol"]), "exit_price": round(float(r["exit_price"]), 8)}
176
+ for _, r in rows.iterrows()
177
+ ),
178
+ key=lambda r: r["symbol"],
179
+ )
180
+
181
+
182
+ # --------------------------------------------------------------------------- #
183
+ # The runner
184
+ # --------------------------------------------------------------------------- #
185
+ class CausalityProbes:
186
+ """Re-runs one backtest under controlled perturbations of its own data.
187
+
188
+ Constructed with the frames the run used, so every probe is served from memory and
189
+ the whole suite costs one fetch however many re-runs it makes.
190
+ """
191
+
192
+ def __init__(
193
+ self,
194
+ strategy_factory: Callable[[], Any],
195
+ frames: Dict[str, pd.DataFrame],
196
+ symbols: Sequence[str],
197
+ start: datetime,
198
+ end: datetime,
199
+ *,
200
+ capital: float = 100_000.0,
201
+ cost_model: Optional[Any] = None,
202
+ benchmark: Optional[str] = None,
203
+ sample: int = DEFAULT_SAMPLE,
204
+ ):
205
+ self.strategy_factory = strategy_factory
206
+ self.frames = frames
207
+ self.symbols = list(symbols)
208
+ self.start = start
209
+ self.end = end
210
+ self.capital = capital
211
+ self.cost_model = cost_model
212
+ self.benchmark = benchmark
213
+ self.sample = max(int(sample), 1)
214
+
215
+ def _run(self, frames: Dict[str, pd.DataFrame]):
216
+ client = MarketDataClient(PrefetchedProvider(frames))
217
+ engine = BacktestEngine(self.strategy_factory(), client, cost_model=self.cost_model)
218
+ kwargs = {"benchmark": self.benchmark} if self.benchmark else {}
219
+ return engine.run(self.symbols, self.start, self.end, self.capital, **kwargs)
220
+
221
+ # --- 1. the execution clock ------------------------------------------------ #
222
+ def execution_clock(self) -> ProbeResult:
223
+ """For each fill, was every input to the decision available before its price?
224
+
225
+ A fill happens at a bar's open. Everything else about that bar — where it
226
+ closed, how much traded — becomes knowable only afterwards, so withholding it
227
+ must not change what was decided at that bar. If it does, the decision read its
228
+ own future, and the direction of the error is always flattering: the engine
229
+ transacted at a price that existed before the information that justified it.
230
+
231
+ This is the shape of the defect a feed shift cannot see. Both the close and the
232
+ volume are withheld, separately, because they reach the decision by different
233
+ routes — the close through the score, the volume through the trailing liquidity
234
+ the cost model charges impact against, and so through affordability.
235
+ """
236
+ base = self._run(self.frames)
237
+ instants = self._decision_instants(base)
238
+ if not instants:
239
+ return ProbeResult(
240
+ "execution_clock",
241
+ INTRA_BAR,
242
+ None,
243
+ "no fills to probe: the run transacted nothing, so its decision clock was never exercised",
244
+ )
245
+
246
+ findings = []
247
+ for when in instants:
248
+ for label, perturb in (("close", _perturbed_close), ("volume", _perturbed_volume)):
249
+ # `or` is not available here: a DataFrame has no truth value. The
250
+ # explicit None check is the point anyway - a frame with nothing to
251
+ # withhold at this instant is left exactly as it was.
252
+ altered = {}
253
+ touched = False
254
+ for symbol, frame in self.frames.items():
255
+ changed = perturb(frame, when)
256
+ altered[symbol] = frame if changed is None else changed
257
+ touched = touched or changed is not None
258
+ if not touched:
259
+ continue
260
+ run = self._run(altered)
261
+ base_entries, run_entries = _entries_at(base.trades, when), _entries_at(run.trades, when)
262
+ base_exits, run_exits = (
263
+ _signal_exits_at(base.trades, when),
264
+ _signal_exits_at(run.trades, when),
265
+ )
266
+ if base_entries != run_entries or base_exits != run_exits:
267
+ findings.append(
268
+ {
269
+ "when": str(when),
270
+ "withheld": label,
271
+ "entries_with_it": base_entries,
272
+ "entries_without_it": run_entries,
273
+ "signal_exits_with_it": base_exits,
274
+ "signal_exits_without_it": run_exits,
275
+ }
276
+ )
277
+
278
+ detail = {
279
+ "instants_probed": [str(i) for i in instants],
280
+ "sampled": len(instants),
281
+ "findings": findings,
282
+ }
283
+ if findings:
284
+ first = findings[0]
285
+ return ProbeResult(
286
+ "execution_clock",
287
+ INTRA_BAR,
288
+ False,
289
+ f"the decision at {first['when']} changed when that bar's {first['withheld']} was withheld, "
290
+ f"so it was made using information that did not exist when the fill priced",
291
+ detail,
292
+ )
293
+ return ProbeResult(
294
+ "execution_clock",
295
+ INTRA_BAR,
296
+ True,
297
+ f"withholding each probed bar's close and volume left that bar's decisions unchanged "
298
+ f"({len(instants)} instant(s) probed)",
299
+ detail,
300
+ )
301
+
302
+ def _decision_instants(self, base) -> List[Any]:
303
+ """The instants worth probing: where this run actually decided something."""
304
+ trades = base.trades
305
+ if trades is None or trades.empty:
306
+ return []
307
+ stamps = list(dict.fromkeys(sorted(trades["entry_time"].tolist())))
308
+ return stamps[: self.sample]
309
+
310
+ # --- 2. same-bar ranking --------------------------------------------------- #
311
+ def same_bar_ranking(self) -> ProbeResult:
312
+ """Entry ordering must not consult the bar it transacts on.
313
+
314
+ Distinct from the clock probe even though it perturbs the same thing. When more
315
+ candidates signal than the book has slots, *which* of them is admitted is a
316
+ second decision, made by ranking — and a ranking computed from the transacting
317
+ bar's own score reintroduces the look-ahead the signal itself just lost, while
318
+ every individual signal remains perfectly causal.
319
+
320
+ It needs contention to be visible at all: with slots to spare, every candidate
321
+ is admitted and the ordering never decides anything. A run that never filled
322
+ the book reports ``None``, because nothing here has been cleared.
323
+ """
324
+ base = self._run(self.frames)
325
+ contended = self._contended_instants(base)
326
+ if not contended:
327
+ return ProbeResult(
328
+ "same_bar_ranking",
329
+ INTRA_BAR,
330
+ None,
331
+ "no instant had more entry candidates than free slots, so ordering never chose between them",
332
+ )
333
+
334
+ for when in contended:
335
+ altered = {}
336
+ for symbol, frame in self.frames.items():
337
+ changed = _perturbed_close(frame, when)
338
+ altered[symbol] = frame if changed is None else changed
339
+ run = self._run(altered)
340
+ chose_with = [e["symbol"] for e in _entries_at(base.trades, when)]
341
+ chose_without = [e["symbol"] for e in _entries_at(run.trades, when)]
342
+ if chose_with != chose_without:
343
+ return ProbeResult(
344
+ "same_bar_ranking",
345
+ INTRA_BAR,
346
+ False,
347
+ f"at {when} the book admitted {chose_with} with that bar's close and "
348
+ f"{chose_without} without it, so the ordering ranked on the bar it transacted on",
349
+ {"when": str(when), "admitted_with": chose_with, "admitted_without": chose_without},
350
+ )
351
+ return ProbeResult(
352
+ "same_bar_ranking",
353
+ INTRA_BAR,
354
+ True,
355
+ f"the admitted set was unchanged at {len(contended)} contended instant(s)",
356
+ {"contended": [str(w) for w in contended]},
357
+ )
358
+
359
+ def _contended_instants(self, base) -> List[Any]:
360
+ """Instants where the book was full immediately after entering.
361
+
362
+ A proxy for contention, and an imperfect one: a bar can fill the last slot with
363
+ no rival for it. It errs toward probing more instants rather than fewer, which
364
+ is the right direction for something whose failure mode is claiming coverage.
365
+ """
366
+ trades = base.trades
367
+ if trades is None or trades.empty:
368
+ return []
369
+ limits = self.strategy_factory().position_limits()
370
+ max_positions = limits.get("max_positions") or 1
371
+ out = []
372
+ for when in dict.fromkeys(sorted(trades["entry_time"].tolist())):
373
+ open_now = int(((trades["entry_time"] <= when) & (trades["exit_time"] > when)).sum())
374
+ if open_now >= max_positions and _entries_at(trades, when):
375
+ out.append(when)
376
+ return out[: self.sample]
377
+
378
+ # --- 3. benchmark alignment ------------------------------------------------ #
379
+ def benchmark_alignment(self) -> ProbeResult:
380
+ """The benchmark series must not be shifted relative to the strategy's.
381
+
382
+ Stated as a causality claim rather than a correlation one, because correlation
383
+ cannot distinguish a misaligned benchmark from an uncorrelated strategy:
384
+ changing the benchmark's close at an instant must not move any benchmark return
385
+ belonging to an equity step that had already finished. If it does, the pairing
386
+ reaches backwards, and every alpha, beta and information ratio in the report is
387
+ measured against a series the strategy could not have been running beside.
388
+ """
389
+ if not self.benchmark:
390
+ return ProbeResult(
391
+ "benchmark_alignment", INTRA_BAR, None, "no benchmark was supplied, so nothing was aligned"
392
+ )
393
+ base = self._run(self.frames)
394
+ if base.benchmark_returns is None or base.equity_times is None or len(base.equity_times) < 4:
395
+ return ProbeResult(
396
+ "benchmark_alignment",
397
+ INTRA_BAR,
398
+ None,
399
+ "the run produced no aligned benchmark series to probe",
400
+ )
401
+ frame = self.frames.get(self.benchmark)
402
+ if frame is None or frame.empty:
403
+ return ProbeResult(
404
+ "benchmark_alignment", INTRA_BAR, None, f"no bars for the benchmark {self.benchmark!r}"
405
+ )
406
+
407
+ # Perturb late, so there is a long stretch of "already finished" steps in front
408
+ # of it for a backwards-reaching pairing to disturb.
409
+ stamps = list(base.equity_times[1:])
410
+ pivot = stamps[max(len(stamps) * 3 // 4, 1)]
411
+ candidates = [t for t in frame.index if t >= pivot]
412
+ if not candidates:
413
+ return ProbeResult(
414
+ "benchmark_alignment", INTRA_BAR, None, "the benchmark has no bar late enough to perturb"
415
+ )
416
+ altered = dict(self.frames)
417
+ altered[self.benchmark] = _replace(
418
+ frame, candidates[0], "close", float(frame.loc[candidates[0], "close"]) * 1.5 + 1.0
419
+ )
420
+
421
+ run = self._run(altered)
422
+ before, after = base.benchmark_returns, run.benchmark_returns
423
+ if after is None or len(after) != len(before):
424
+ return ProbeResult(
425
+ "benchmark_alignment",
426
+ INTRA_BAR,
427
+ None,
428
+ "the perturbed run produced a different-length benchmark series; nothing comparable to check",
429
+ )
430
+
431
+ changed = [k for k in range(len(before)) if not _close_enough(before.iloc[k], after.iloc[k])]
432
+ early = [k for k in changed if stamps[k] < candidates[0]]
433
+ detail = {
434
+ "perturbed_at": str(candidates[0]),
435
+ "steps_changed": len(changed),
436
+ "steps_changed_before_the_perturbation": len(early),
437
+ }
438
+ if early:
439
+ return ProbeResult(
440
+ "benchmark_alignment",
441
+ INTRA_BAR,
442
+ False,
443
+ f"changing the benchmark at {candidates[0]} moved {len(early)} benchmark return(s) belonging "
444
+ f"to equity steps that had already finished — the two series are paired out of step",
445
+ detail,
446
+ )
447
+ if not changed:
448
+ return ProbeResult(
449
+ "benchmark_alignment",
450
+ INTRA_BAR,
451
+ None,
452
+ "changing the benchmark moved nothing at all, so this run's metrics do not depend on it "
453
+ "and its alignment was not exercised",
454
+ detail,
455
+ )
456
+ return ProbeResult(
457
+ "benchmark_alignment",
458
+ INTRA_BAR,
459
+ True,
460
+ f"a change at {candidates[0]} moved only benchmark returns at or after it",
461
+ detail,
462
+ )
463
+
464
+
465
+ def _close_enough(left: Any, right: Any) -> bool:
466
+ """Equal, treating two absent values as equal — absent is not zero, but it is absent."""
467
+ left_missing, right_missing = pd.isna(left), pd.isna(right)
468
+ if left_missing or right_missing:
469
+ return bool(left_missing and right_missing)
470
+ return abs(float(left) - float(right)) <= 1e-12
471
+
472
+
473
+ # --------------------------------------------------------------------------- #
474
+ # 4. the as-of scanner clock
475
+ # --------------------------------------------------------------------------- #
476
+ def probe_as_of_scanner(
477
+ scan: Callable[[Dict[str, pd.DataFrame], datetime], Sequence[str]],
478
+ frames: Dict[str, pd.DataFrame],
479
+ as_of: datetime,
480
+ ) -> ProbeResult:
481
+ """Universe selection must not see beyond the clock it claims to be reading.
482
+
483
+ Selected once against everything on disk and once against a feed truncated at
484
+ ``as_of``. A scanner honouring its own clock returns the same names either way; one
485
+ that reads past it selects the winners of a period it was not supposed to know
486
+ about, and a backtest over that universe is measuring hindsight — for every symbol,
487
+ for the whole window, with nothing in the result that looks wrong.
488
+
489
+ Takes the scan as a callable rather than reaching for a scanner registry, because
490
+ this module sits below the layer that owns scanning and must not import upwards.
491
+ """
492
+ truncated = {}
493
+ for symbol, frame in frames.items():
494
+ if frame is None or frame.empty:
495
+ continue
496
+ stamp = _coerce_stamp(as_of, frame.index)
497
+ truncated[symbol] = frame.loc[frame.index <= stamp]
498
+
499
+ if not any(len(f) for f in truncated.values()):
500
+ return ProbeResult(
501
+ "as_of_scanner", AS_OF, None, f"no bars at or before {as_of}, so there was nothing to select from"
502
+ )
503
+
504
+ full = sorted(scan(frames, as_of))
505
+ limited = sorted(scan(truncated, as_of))
506
+ if not full and not limited:
507
+ # Two empty selections agree, and agreeing about nothing is not evidence that a
508
+ # clock was honoured. Reporting this as a pass would clear a scanner that was
509
+ # never asked to choose.
510
+ return ProbeResult(
511
+ "as_of_scanner",
512
+ AS_OF,
513
+ None,
514
+ "the scanner selected no names either way, so its clock was never exercised",
515
+ {"as_of": str(as_of)},
516
+ )
517
+ if full != limited:
518
+ return ProbeResult(
519
+ "as_of_scanner",
520
+ AS_OF,
521
+ False,
522
+ f"the universe changed when bars after {as_of} were withheld, so selection read past its own clock",
523
+ {"with_future_bars": full, "without_them": limited, "as_of": str(as_of)},
524
+ )
525
+ return ProbeResult(
526
+ "as_of_scanner",
527
+ AS_OF,
528
+ True,
529
+ f"the same {len(full)} name(s) were selected with and without bars after {as_of}",
530
+ {"universe": full, "as_of": str(as_of)},
531
+ )
532
+
533
+
534
+ def _coerce_stamp(when: datetime, index) -> Any:
535
+ """``when`` made comparable with a possibly tz-aware bar index."""
536
+ stamp = pd.Timestamp(when)
537
+ if getattr(index, "tz", None) is not None:
538
+ stamp = stamp.tz_localize(index.tz) if stamp.tzinfo is None else stamp.tz_convert(index.tz)
539
+ elif stamp.tzinfo is not None:
540
+ stamp = stamp.tz_localize(None)
541
+ return stamp
542
+
543
+
544
+ # --------------------------------------------------------------------------- #
545
+ # The suite
546
+ # --------------------------------------------------------------------------- #
547
+ def run_causality_probes(
548
+ strategy_factory: Callable[[], Any],
549
+ frames: Dict[str, pd.DataFrame],
550
+ symbols: Sequence[str],
551
+ start: datetime,
552
+ end: datetime,
553
+ *,
554
+ capital: float = 100_000.0,
555
+ cost_model: Optional[Any] = None,
556
+ benchmark: Optional[str] = None,
557
+ sample: int = DEFAULT_SAMPLE,
558
+ scan: Optional[Callable[[Dict[str, pd.DataFrame], datetime], Sequence[str]]] = None,
559
+ scan_as_of: Optional[datetime] = None,
560
+ ) -> Dict[str, Any]:
561
+ """Every causality probe over one run, as one report.
562
+
563
+ The summary refuses to collapse into a single verdict when any probe could not be
564
+ exercised. "Three passed and one never ran" is not "passed", and a report that
565
+ rounds it to one is the reassurance this whole subsystem exists to withhold.
566
+ """
567
+ probes = CausalityProbes(
568
+ strategy_factory,
569
+ frames,
570
+ symbols,
571
+ start,
572
+ end,
573
+ capital=capital,
574
+ cost_model=cost_model,
575
+ benchmark=benchmark,
576
+ sample=sample,
577
+ )
578
+ results = [probes.execution_clock(), probes.same_bar_ranking(), probes.benchmark_alignment()]
579
+ if scan is not None and scan_as_of is not None:
580
+ results.append(probe_as_of_scanner(scan, frames, scan_as_of))
581
+ else:
582
+ results.append(
583
+ ProbeResult(
584
+ "as_of_scanner", AS_OF, None, "no scanner was used, so no selection clock was exercised"
585
+ )
586
+ )
587
+
588
+ failed = [r for r in results if r.passed is False]
589
+ unexercised = [r for r in results if r.passed is None]
590
+ if failed:
591
+ verdict = "non-causal"
592
+ elif unexercised:
593
+ verdict = "incomplete"
594
+ else:
595
+ verdict = "causal"
596
+ return {
597
+ "verdict": verdict,
598
+ "probes": [r.as_dict() for r in results],
599
+ "failed": [r.name for r in failed],
600
+ "not_exercised": [r.name for r in unexercised],
601
+ "note": (
602
+ "These test intra-bar causality and the as-of clock. They are a different "
603
+ "class from the feed-shift leakage probe, which tests for future data and "
604
+ "cannot see a one-bar look-ahead at all — a shift moves signal and price "
605
+ "together, so the relationship survives it. Neither substitutes for the other."
606
+ ),
607
+ }