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.
- quantred/__init__.py +24 -0
- quantred/alphas/__init__.py +53 -0
- quantred/alphas/base.py +221 -0
- quantred/alphas/combine.py +260 -0
- quantred/alphas/horizon.py +153 -0
- quantred/alphas/refine.py +300 -0
- quantred/alphas/scorers.py +87 -0
- quantred/analytics/attribution.py +354 -0
- quantred/analytics/bootstrap.py +353 -0
- quantred/analytics/charts.py +393 -0
- quantred/analytics/excursion.py +185 -0
- quantred/analytics/execution_quality.py +180 -0
- quantred/analytics/exposure.py +167 -0
- quantred/analytics/htmlreport.py +1208 -0
- quantred/analytics/information.py +197 -0
- quantred/analytics/metrics.py +502 -0
- quantred/analytics/performance.py +639 -0
- quantred/analytics/reporting.py +1672 -0
- quantred/analytics/screening.py +190 -0
- quantred/analytics/series_comparison.py +303 -0
- quantred/analytics/trade_analytics.py +353 -0
- quantred/brokers/alpaca/broker.py +415 -0
- quantred/brokers/alpaca/factory.py +33 -0
- quantred/brokers/alpaca/market_data.py +166 -0
- quantred/brokers/base.py +220 -0
- quantred/brokers/dryrun.py +164 -0
- quantred/brokers/errors.py +70 -0
- quantred/cli.py +8619 -0
- quantred/costs/__init__.py +11 -0
- quantred/costs/base.py +126 -0
- quantred/costs/parametric.py +131 -0
- quantred/data/__init__.py +29 -0
- quantred/data/compute.py +332 -0
- quantred/data/edges.py +81 -0
- quantred/data/features.py +120 -0
- quantred/data/panel.py +61 -0
- quantred/data/scan.py +85 -0
- quantred/data/store.py +269 -0
- quantred/demo/__init__.py +22 -0
- quantred/demo/scanners.py +104 -0
- quantred/demo/strategies.py +125 -0
- quantred/engine/backtest.py +1206 -0
- quantred/engine/barcheck.py +228 -0
- quantred/engine/live.py +539 -0
- quantred/execution/decision.py +192 -0
- quantred/execution/flatten.py +302 -0
- quantred/execution/halt.py +165 -0
- quantred/execution/ledger.py +758 -0
- quantred/execution/live_trader.py +587 -0
- quantred/execution/order_id.py +83 -0
- quantred/execution/sizing.py +114 -0
- quantred/indicators/indicators.py +158 -0
- quantred/marketdata/base.py +62 -0
- quantred/marketdata/client.py +59 -0
- quantred/marketdata/session.py +112 -0
- quantred/marketdata/synthetic.py +74 -0
- quantred/marketdata/timeframe.py +125 -0
- quantred/mcp/__init__.py +6 -0
- quantred/mcp/selftest.py +139 -0
- quantred/mcp/server.py +2419 -0
- quantred/optimization/causality.py +607 -0
- quantred/optimization/config_store.py +300 -0
- quantred/optimization/optimizer.py +431 -0
- quantred/optimization/parallel.py +345 -0
- quantred/optimization/param_space.py +309 -0
- quantred/optimization/walk_forward.py +1254 -0
- quantred/portfolio/allocator.py +135 -0
- quantred/portfolio/benchmark.py +109 -0
- quantred/portfolio/optimizer.py +786 -0
- quantred/portfolio/policy.py +335 -0
- quantred/portfolio/posterior.py +208 -0
- quantred/research/__init__.py +30 -0
- quantred/research/agent.py +868 -0
- quantred/research/demo_proposals.py +222 -0
- quantred/research/llm.py +311 -0
- quantred/research/proposer.py +234 -0
- quantred/research/provider_errors.py +242 -0
- quantred/research/sandbox.py +340 -0
- quantred/risk/__init__.py +60 -0
- quantred/risk/base.py +218 -0
- quantred/risk/conditional.py +474 -0
- quantred/risk/exposures.py +88 -0
- quantred/risk/factor.py +119 -0
- quantred/risk/sample.py +89 -0
- quantred/risk/streaming.py +214 -0
- quantred/scanners/base.py +141 -0
- quantred/scanners/symbol_scanner.py +148 -0
- quantred/services/__init__.py +51 -0
- quantred/services/analysis.py +6082 -0
- quantred/services/audit.py +574 -0
- quantred/services/campaign.py +381 -0
- quantred/services/configs.py +282 -0
- quantred/services/data.py +172 -0
- quantred/services/dryrun.py +206 -0
- quantred/services/glossary.py +130 -0
- quantred/services/maintenance.py +314 -0
- quantred/services/registry.py +351 -0
- quantred/services/setup.py +1023 -0
- quantred/services/sizing.py +150 -0
- quantred/services/smallreal.py +450 -0
- quantred/settings.py +571 -0
- quantred/store/__init__.py +0 -0
- quantred/store/bars.py +660 -0
- quantred/store/trials.py +1804 -0
- quantred/strategies/base.py +643 -0
- quantred/strategies/signals.py +28 -0
- quantred/utils/errors.py +48 -0
- quantred/utils/extras.py +330 -0
- quantred/utils/logging_config.py +33 -0
- quantred/utils/numeric.py +46 -0
- quantred/utils/streaming.py +474 -0
- quantred/utils/timeutils.py +148 -0
- quantred-2.4.0.dist-info/METADATA +632 -0
- quantred-2.4.0.dist-info/RECORD +117 -0
- quantred-2.4.0.dist-info/WHEEL +4 -0
- quantred-2.4.0.dist-info/entry_points.txt +8 -0
- 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
|
+
}
|