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,1206 @@
1
+ """Backtest engine - where strategies go to find out the truth about themselves.
2
+
3
+ Orchestrates a vectorized-fetch / bar-by-bar-simulate / aggregate pipeline:
4
+
5
+ data (marketdata) -> signals (strategy) -> fills (here) -> metrics (analytics)
6
+
7
+ The engine owns *only* the trade simulation and the wiring between layers. It
8
+ holds no indicator logic (strategy) and no metric formulas (analytics).
9
+
10
+ **One clock, one capital pool.** Every symbol is simulated on a single merged
11
+ timeline against shared cash, so positions compete for capital exactly as they
12
+ would live: within a bar, exits settle before entries, and when more entry
13
+ signals arrive than the book can fund they are admitted in conviction order
14
+ (the strategy's own score, ties broken by symbol for determinism).
15
+
16
+ This is what makes absolute metrics mean anything. Simulating each symbol
17
+ independently over its whole history - the engine's original shape - summed N
18
+ full-capital single-name backtests onto one capital base, so return and Sharpe
19
+ both scaled with universe size.
20
+ """
21
+
22
+ import logging
23
+ from dataclasses import dataclass, field
24
+ from datetime import datetime
25
+ from typing import Any, Dict, List, Optional, Tuple
26
+
27
+ import numpy as np
28
+ import pandas as pd
29
+
30
+ from quantred.analytics import excursion as excursion_module
31
+ from quantred.analytics import metrics as m
32
+ from quantred.analytics import performance
33
+ from quantred.brokers.base import AccountSnapshot
34
+ from quantred.costs.base import CostModel, Trade
35
+ from quantred.execution.sizing import PositionSizer, RiskBasedSizer, below_min_notional
36
+ from quantred.marketdata.client import MarketDataClient
37
+ from quantred.marketdata.timeframe import Timeframe
38
+ from quantred.strategies import signals
39
+ from quantred.strategies.base import Strategy
40
+ from quantred.utils.numeric import round_quantity
41
+
42
+ logger = logging.getLogger(__name__)
43
+
44
+ #: How the engine accounts for capital. Bump this whenever a change makes results
45
+ #: incommensurable with earlier ones, so a stored record can never be compared to a
46
+ #: newer one as though the two measured the same thing.
47
+ #:
48
+ #: * **1** — the original model. Each symbol was simulated independently over its whole
49
+ #: history against the full capital base, and the equity curve accumulated realized
50
+ #: P&L at exit, resampled to calendar days. Absolute return and Sharpe scaled with
51
+ #: universe size, and position limits were per-symbol.
52
+ #: * **2** — one merged timeline, one shared capital pool, portfolio-level position limits,
53
+ #: and a per-bar mark-to-market equity curve. Per-step quantities (the equity curve, short
54
+ #: carry) were annualized at the strategy's single-symbol timeframe rate, which understates
55
+ #: the merged timeline's sampling rate whenever symbols don't share one bar grid.
56
+ #: * **3** — the current model. Per-step quantities annualize on the merged timeline's own
57
+ #: rate. Identical to 2 for a universe whose symbols share a grid; corrects inflated
58
+ #: Sharpe/volatility and understated carry when they don't.
59
+ #:
60
+ #: * **4** — a signal is executed one bar after the bar that produced
61
+ #: it. Through v3 a signal derived from bar i's close was filled at bar i's *open*,
62
+ #: for entries and signal exits alike - a one-bar look-ahead applied to every trade in
63
+ #: every recorded result. It survived the leakage probe because a feed shift moves
64
+ #: signal and price together, and it made the backtest structurally unable to be
65
+ #: matched live, where a closed bar produces a signal and a market order fills after
66
+ #: it. Every number recorded under 1-3 overstates what a deployment could achieve; a
67
+ #: v4 result is not comparable with them and the store keeps the two apart.
68
+ #:
69
+ #: * **5** — the book is marked at a bar's *open* for the decisions
70
+ #: that transact at it, and at its close for the equity curve. Through v4 positions
71
+ #: were marked to the bar's close before entries were considered, and the exposure
72
+ #: caps (`max_total_risk`, `max_gross_exposure`, `max_net_exposure`) are tested
73
+ #: against `equity * cap` - so whether an entry filling at the open was admitted
74
+ #: could depend on where that same bar finished, hours later. The same class of
75
+ #: defect as v4 and equally invisible to a feed shift, in the admission gate rather
76
+ #: than the signal.
77
+ #:
78
+ #: Narrower in effect than v4: it changes a result only where a cap was close to
79
+ #: binding, and the default sizer is untouched (it sizes off cash, which is not
80
+ #: marked). But "usually identical" is not comparable, and a stored number nobody can
81
+ #: tell apart from a changed one is exactly what this version exists to prevent.
82
+ #: The promotion gates are deliberately **not** recalibrated: v4->v5 moves no metric
83
+ #: systematically, unlike the mark-to-market change at v2, and rescaling a threshold
84
+ #: without a measured shift to justify it is gate-fitting.
85
+ #:
86
+ #: * **6** — the current model. Window boundaries derived by adding days to a market
87
+ #: date - walk-forward folds, the holdout, warmup starts, scanner and beta lookbacks,
88
+ #: the research agent's holdout - stay at New York midnight across daylight saving.
89
+ #: Through v5 the arithmetic kept the January offset, so from March to November every
90
+ #: such boundary sat at 01:00 New York; a window starting on a day skipped that day's
91
+ #: bar, an out-of-sample fold began one bar late and a lookback lost its first bar.
92
+ #: No look-ahead (the in-sample/out-of-sample gap only grew by a day), but the
93
+ #: windows were not the ones requested, so a v5 walk-forward is not comparable.
94
+ #: Out-of-sample folds are also half-open now: consecutive folds shared their
95
+ #: boundary bar (in winter under v5, all year once the boundaries were fixed), so
96
+ #: that bar's return counted in two folds. The version is one stamp for every kind,
97
+ #: so v5 backtests leave the default listing too, though most never crossed a boundary.
98
+ #: A walk-forward's in-sample search now runs at the configured book, not the class
99
+ #: default, so v5 walk-forwards chose parameters for a different book than they scored;
100
+ #: and a stored optimize candidate is keyed on capital and book too, so earlier ones miss.
101
+ #:
102
+ #: Records written before this field existed carry no version; absence means 1.
103
+ ACCOUNTING_VERSION = 6
104
+
105
+
106
+ class BacktestError(RuntimeError):
107
+ """A backtest could not be simulated at all (as opposed to finding no edge).
108
+
109
+ Raised when every symbol in the universe failed, which almost always means a
110
+ broken strategy or an unconstructable config. Distinguishing this from a
111
+ genuine zero-trade result is what stops a crash being scored as "no edge".
112
+ """
113
+
114
+
115
+ def _align_tz(when, index: pd.DatetimeIndex) -> pd.Timestamp:
116
+ """Coerce ``when`` to ``index``'s tz-awareness so the two can be compared.
117
+
118
+ Callers pass plain datetimes while bar indices are usually exchange-local, and
119
+ pandas refuses to compare tz-naive with tz-aware.
120
+ """
121
+ ts = pd.Timestamp(when)
122
+ tz = getattr(index, "tz", None)
123
+ if tz is not None and ts.tz is None:
124
+ return ts.tz_localize(tz)
125
+ if tz is None and ts.tz is not None:
126
+ return ts.tz_localize(None)
127
+ return ts
128
+
129
+
130
+ @dataclass
131
+ class _Panel:
132
+ """One symbol's arrays, aligned to the merged timeline.
133
+
134
+ ``rows`` maps each master-timeline step to this symbol's row index, or -1 when
135
+ the symbol has no bar then (a listing gap, a halt, a shorter history). -1 means
136
+ "not tradeable at t" - never "carry the last price forward".
137
+ """
138
+
139
+ opens: np.ndarray
140
+ highs: np.ndarray
141
+ lows: np.ndarray
142
+ closes: np.ndarray
143
+ timestamps: np.ndarray
144
+ sig: np.ndarray
145
+ score: np.ndarray
146
+ rows: np.ndarray
147
+ adv: Optional[np.ndarray] = None
148
+ vol: Optional[np.ndarray] = None
149
+ last_timestamp: Any = None
150
+ #: This symbol's own last valid master-timeline step (where ``rows >= 0``) -
151
+ #: distinct from the merged panel's last step when this symbol's history is
152
+ #: shorter than the universe's. Force-close must age a position against this,
153
+ #: not the master timeline's length, or a short-history symbol gets charged
154
+ #: carry cost as if held through bars it never actually traded.
155
+ last_step: int = -1
156
+
157
+
158
+ @dataclass
159
+ class _Execution:
160
+ """What the sizer asked for versus what could actually be traded.
161
+
162
+ Whole-share rounding is silent, and at small capital it is not a rounding error:
163
+ the sizer asks for 0.4 shares of a $500 name, the engine floors it to zero, and the
164
+ equity curve is correct while the reason for it is invisible. These counters make
165
+ the difference between the intended book and the executed one a number rather than
166
+ an inference.
167
+
168
+ Filled and requested notional are accumulated over *opened* positions only, so the
169
+ ratio is pure rounding drag; positions lost outright are counted separately, since
170
+ a book that cannot open a name at all is a different problem from one that opens a
171
+ slightly smaller one.
172
+ """
173
+
174
+ requested_notional: float = 0.0
175
+ filled_notional: float = 0.0
176
+ filled: int = 0
177
+ rounded_to_zero: int = 0
178
+ below_min_notional: int = 0
179
+
180
+ def drag_pct(self) -> float:
181
+ """Requested notional that whole-share rounding removed, as a percentage."""
182
+ if self.requested_notional <= 0:
183
+ return 0.0
184
+ return float((1.0 - self.filled_notional / self.requested_notional) * 100.0)
185
+
186
+ def unfillable_pct(self) -> float:
187
+ """Share of intended entries that could not be opened at all, as a percentage.
188
+
189
+ Separate from the drag: a book that opens every name slightly smaller is a
190
+ different proposition from one that silently never opens a quarter of them.
191
+ """
192
+ attempted = self.filled + self.rounded_to_zero + self.below_min_notional
193
+ if attempted <= 0:
194
+ return 0.0
195
+ return float((self.rounded_to_zero + self.below_min_notional) / attempted * 100.0)
196
+
197
+ def as_dict(self) -> Dict[str, Any]:
198
+ return {
199
+ "requested_notional": self.requested_notional,
200
+ "filled_notional": self.filled_notional,
201
+ "rounding_drag_pct": self.drag_pct(),
202
+ "positions_filled": self.filled,
203
+ "positions_rounded_to_zero": self.rounded_to_zero,
204
+ "positions_below_min_notional": self.below_min_notional,
205
+ "unfillable_pct": self.unfillable_pct(),
206
+ }
207
+
208
+
209
+ @dataclass
210
+ class _Book:
211
+ """Mutable portfolio state carried across the merged timeline."""
212
+
213
+ cash: float
214
+ positions: Dict[str, Dict[str, Any]] = field(default_factory=dict)
215
+ execution: _Execution = field(default_factory=_Execution)
216
+
217
+ def open_risk(self) -> float:
218
+ """Sum of per-position risk (notional x stop distance) across the book."""
219
+ return sum(p["risk"] for p in self.positions.values())
220
+
221
+ def gross_exposure(self) -> float:
222
+ """Marked gross notional across the book, shorts counted by magnitude.
223
+
224
+ Deliberately not :meth:`market_value`, which is an accounting number -
225
+ reserved notional plus unrealized P&L under full cash collateral. This is
226
+ the market exposure the book is actually carrying.
227
+ """
228
+ return sum(p["size"] * p["last_price"] for p in self.positions.values())
229
+
230
+ def net_exposure(self) -> float:
231
+ """Signed notional across the book: longs positive, shorts negative.
232
+
233
+ The directional tilt :meth:`gross_exposure` cannot see. A book that is long
234
+ $4,000 and short $4,000 has $8,000 gross and $0 net; one that is long $8,000
235
+ has the same gross and $8,000 net, and only the second is a bet on direction.
236
+ """
237
+ return sum(
238
+ (p["size"] if p["side"] == signals.BUY else -p["size"]) * p["last_price"]
239
+ for p in self.positions.values()
240
+ )
241
+
242
+ def market_value_at(self, prices: Optional[Dict[str, float]] = None) -> float:
243
+ """Reserved notional plus unrealized gross P&L, marked at the given prices.
244
+
245
+ ``prices`` overrides the mark per symbol and falls back to each position's last
246
+ seen price. It exists so a diagnostic can ask what the book would have been
247
+ worth at some *other* price — the worst tick inside a bar, say — without either
248
+ moving the marks the engine decides on or writing a second copy of this sum.
249
+ Two implementations of "what is this book worth" is precisely the divergence
250
+ that is invisible until the two disagree.
251
+ """
252
+ prices = prices or {}
253
+ total = 0.0
254
+ for symbol, p in self.positions.items():
255
+ direction = 1 if p["side"] == signals.BUY else -1
256
+ price = prices.get(symbol, p["last_price"])
257
+ total += p["notional"] + (price - p["entry_price"]) * p["size"] * direction
258
+ return total
259
+
260
+ def market_value(self) -> float:
261
+ """Reserved notional plus unrealized gross P&L, marked at last seen price."""
262
+ return self.market_value_at()
263
+
264
+ def equity(self) -> float:
265
+ return self.cash + self.market_value()
266
+
267
+ def equity_at(self, prices: Optional[Dict[str, float]] = None) -> float:
268
+ """What the book would be worth marked at ``prices``. Never consulted by a
269
+ decision - see :meth:`market_value_at`."""
270
+ return self.cash + self.market_value_at(prices)
271
+
272
+
273
+ def _leg_report(leg_curves, trades, initial_capital: float) -> Dict[str, Any]:
274
+ """What each side of a long/short book actually did, separately.
275
+
276
+ A near-zero net beta is the headline of any market-neutral result, and it has two
277
+ completely different causes: genuinely small exposure on both sides, or a large long
278
+ beta cancelling a large short one. Those are the same number and opposite risks, and
279
+ nothing in a net-level report tells them apart.
280
+
281
+ Curves are realized-plus-unrealized per side, sampled on the equity curve's own
282
+ steps, so the per-leg volatility, drawdown and beta describe the position as it was
283
+ held rather than only as it was closed.
284
+
285
+ Diagnostic only: no threshold, no verdict. Make the risk visible first and decide
286
+ afterwards whether any of it deserves to gate something.
287
+ """
288
+ report: Dict[str, Any] = {}
289
+ trades_df = pd.DataFrame(trades) if trades else pd.DataFrame()
290
+ for side, curve in leg_curves.items():
291
+ name = "long" if side == signals.BUY else "short"
292
+ if trades_df.empty or "side" not in trades_df:
293
+ leg_trades = pd.DataFrame()
294
+ else:
295
+ leg_trades = trades_df[trades_df["side"] == side]
296
+ if len(curve) < 2 and leg_trades.empty:
297
+ continue
298
+ # As a fraction of capital, so the two legs are on one scale and the pair adds
299
+ # up to something a reader can check against the headline return.
300
+ series = pd.Series(curve, dtype="float64") / initial_capital if initial_capital else pd.Series(curve)
301
+ report[name] = {
302
+ "pnl": float(leg_trades["pnl"].sum()) if not leg_trades.empty else 0.0,
303
+ "return_pct": float(series.iloc[-1] * 100.0),
304
+ "volatility_pct": float(series.diff().std() * 100.0),
305
+ "max_drawdown_pct": float(abs(m.max_drawdown(list(1.0 + series))) * 100.0),
306
+ "trades": int(len(leg_trades)),
307
+ "cost": float(leg_trades["cost"].sum()) if "cost" in leg_trades else 0.0,
308
+ "_returns": series.diff().dropna(), # for beta, dropped before serialization
309
+ }
310
+ return report
311
+
312
+
313
+ def _exposure_report(samples: List[Dict[str, float]]) -> Dict[str, Any]:
314
+ """The distribution of exposure the book actually carried, as fractions of equity.
315
+
316
+ A net cap is a fraction, and choosing one from anything but the strategy's own
317
+ history is a guess. This is the history: |long - short| and long + short at every
318
+ step the equity curve was sampled at, so the question "what did this actually run
319
+ at" has an answer.
320
+
321
+ Signed net is kept alongside the absolute value because they answer different
322
+ questions - the absolute distribution says how large a tilt to allow, the signed
323
+ mean says whether the strategy leans one way by construction.
324
+
325
+ Diagnostic. It sets nothing and gates nothing.
326
+ """
327
+ if not samples:
328
+ return {}
329
+ net_fracs, gross_fracs, signed = [], [], []
330
+ for sample in samples:
331
+ equity = sample["equity"]
332
+ if equity <= 0:
333
+ # A book with no equity has no meaningful fraction; skipped rather than
334
+ # divided, and counted so the sample size is never overstated.
335
+ continue
336
+ net_fracs.append(abs(sample["net"]) / equity)
337
+ gross_fracs.append(sample["gross"] / equity)
338
+ signed.append(sample["net"] / equity)
339
+ if not net_fracs:
340
+ return {}
341
+ from quantred.analytics.exposure import candidate_caps
342
+
343
+ series = pd.Series(net_fracs, dtype="float64")
344
+ stats = {
345
+ "median": float(series.median()),
346
+ "p90": float(series.quantile(0.90)),
347
+ "p95": float(series.quantile(0.95)),
348
+ "p99": float(series.quantile(0.99)),
349
+ "max": float(series.max()),
350
+ }
351
+ # Evaluated here, where the samples are, and carried as scalars. Keeping the whole
352
+ # series on the result would put one row per step into anything that serializes it,
353
+ # for a number only ever read at a handful of caps.
354
+ candidates = [
355
+ {
356
+ "cap": cap,
357
+ "binding_rate": float((series > cap).mean()),
358
+ "above_observed_max": cap >= stats["max"],
359
+ }
360
+ for cap in candidate_caps(stats)
361
+ ]
362
+ return {
363
+ "samples": len(net_fracs),
364
+ "skipped": len(samples) - len(net_fracs),
365
+ "net_abs": stats,
366
+ "candidates": candidates,
367
+ "net_signed_mean": float(pd.Series(signed, dtype="float64").mean()),
368
+ "gross_max": float(pd.Series(gross_fracs, dtype="float64").max()),
369
+ }
370
+
371
+
372
+ def _leg_betas(legs: Dict[str, Any], benchmark_returns) -> None:
373
+ """Add each leg's beta against the benchmark, in place.
374
+
375
+ Separate from :func:`_leg_report` because the benchmark is aligned later - and
376
+ because a leg beta is exactly the number that distinguishes "no exposure" from
377
+ "two exposures cancelling", so it is worth its own step rather than a side effect.
378
+ """
379
+ for leg in legs.values():
380
+ leg_returns = leg.pop("_returns", None)
381
+ if benchmark_returns is None or leg_returns is None or len(leg_returns) < 2:
382
+ leg["beta"] = None
383
+ leg["benchmark_correlation"] = None
384
+ continue
385
+ paired = pd.concat(
386
+ [pd.Series(leg_returns.to_numpy()), pd.Series(pd.Series(benchmark_returns).to_numpy())],
387
+ axis=1,
388
+ keys=["leg", "bench"],
389
+ ).dropna()
390
+ if len(paired) < 2 or paired["bench"].std() == 0 or paired["leg"].std() == 0:
391
+ leg["beta"] = None
392
+ leg["benchmark_correlation"] = None
393
+ continue
394
+ leg["beta"] = float(paired["leg"].cov(paired["bench"]) / paired["bench"].var())
395
+ leg["benchmark_correlation"] = float(paired["leg"].corr(paired["bench"]))
396
+
397
+
398
+ def _benchmark_returns(closes, equity_times) -> Optional[pd.Series]:
399
+ """Benchmark returns sampled at the equity curve's own instants.
400
+
401
+ Positional, not date-indexed, because that is the only alignment the metrics can
402
+ use: the equity curve reaches them as a bare list of floats, so its returns carry
403
+ a RangeIndex and a date-indexed benchmark would join to nothing at all - silently,
404
+ producing an empty regression rather than an error.
405
+
406
+ Reindexed onto those instants and forward-filled, so a benchmark that does not
407
+ trade on one of the universe's bars carries its last close rather than dropping
408
+ the step and shifting every later pairing by one.
409
+ """
410
+ if closes is None or closes.empty or len(equity_times) < 2:
411
+ return None
412
+ stamps = pd.DatetimeIndex([t for t in equity_times[1:]])
413
+ aligned = closes.reindex(closes.index.union(stamps)).ffill().reindex(stamps)
414
+ returns = aligned.pct_change()
415
+ if returns.notna().sum() < 2:
416
+ return None
417
+ # Drop the index: the pairing is by position against the equity curve's own steps.
418
+ return pd.Series(returns.to_numpy(), dtype="float64")
419
+
420
+
421
+ @dataclass
422
+ class BacktestResult:
423
+ """Everything produced by a backtest run."""
424
+
425
+ metrics: Dict[str, float]
426
+ trades: pd.DataFrame
427
+ equity_curve: List[float]
428
+ initial_capital: float
429
+ final_capital: float
430
+ start: datetime
431
+ end: datetime
432
+ strategy_config: Dict[str, Any]
433
+ total_cost: float = 0.0 # total transaction cost charged across all trades
434
+ gross_final_capital: float = 0.0 # final capital before cost (for the haircut attribution)
435
+ #: What the sizer asked for versus what was actually tradeable - see :class:`_Execution`.
436
+ execution: Dict[str, Any] = field(default_factory=dict)
437
+ #: What the book actually carried, step by step - see :func:`_exposure_report`.
438
+ exposure: Dict[str, Any] = field(default_factory=dict)
439
+ #: The book's aggregate adverse/favourable excursion - how bad it ever looked
440
+ #: *inside* a bar, against how bad the closing marks ever showed. Diagnostic;
441
+ #: nothing in the metrics reads it. See
442
+ #: :func:`quantred.analytics.excursion.portfolio_excursion`.
443
+ excursion: Dict[str, Any] = field(default_factory=dict)
444
+ #: Per-side realized performance for a long/short book - see :func:`_leg_report`.
445
+ #: Empty for a long-only run, which has nothing to decompose.
446
+ legs: Dict[str, Any] = field(default_factory=dict)
447
+ #: The benchmark returns this run scored against, already aligned positionally to
448
+ #: ``equity_curve``. Exposed so a caller that recomputes metrics on *this* curve
449
+ #: (the walk-forward does, to fold in its own trial counts) can reuse the alignment
450
+ #: instead of deriving a second one - two alignments of the same series is how the
451
+ #: two quietly stop matching.
452
+ benchmark_returns: Optional[Any] = None
453
+ #: The instant each ``equity_curve`` step was marked at. Exposed so a diagnostic
454
+ #: can say *when* a step happened rather than reconstructing the merged timeline
455
+ #: and risking a second, disagreeing definition of it. Nothing in the metrics
456
+ #: reads it.
457
+ equity_times: Optional[List[Any]] = None
458
+
459
+
460
+ class BacktestEngine:
461
+ """Runs a strategy over historical data and reports performance."""
462
+
463
+ #: Trailing windows for the as-of liquidity inputs the cost model needs.
464
+ ADV_WINDOW = 20
465
+ VOL_WINDOW = 20
466
+
467
+ def __init__(
468
+ self,
469
+ strategy: Strategy,
470
+ data_client: MarketDataClient,
471
+ sizer: Optional[PositionSizer] = None,
472
+ cost_model: Optional[CostModel] = None,
473
+ take_profit_margin_bps: float = 0.0,
474
+ ):
475
+ self.strategy = strategy
476
+ self.data_client = data_client
477
+ # Same sizing abstraction as live execution; defaults to the strategy's
478
+ # own risk-based sizing so behavior is unchanged unless a sizer is given.
479
+ self.sizer = sizer or RiskBasedSizer(strategy)
480
+ # When set, every simulated fill is charged so metrics are net of cost.
481
+ # None = gross (the engine is a mechanism; the service layer defaults to net).
482
+ self.cost_model = cost_model
483
+ #: How far a bar must trade *through* a take-profit before it counts as filled.
484
+ #:
485
+ #: Zero - the default, and every historical result - means a bar that merely
486
+ #: touched the target filled at it. That models a resting limit order that is
487
+ #: always first in the queue, which is the most generous reading available: a
488
+ #: single print at the level is enough. For a strategy whose edge is
489
+ #: concentrated in target exits, that assumption *is* the result, so it is worth
490
+ #: being able to move rather than only being able to believe.
491
+ #:
492
+ #: The trigger tightens; the fill price does not change. A limit order that
493
+ #: fills, fills at its limit - the question this asks is whether it filled at
494
+ #: all, not whether it filled worse.
495
+ self.take_profit_margin_bps = take_profit_margin_bps
496
+
497
+ def run(
498
+ self,
499
+ symbols: List[str],
500
+ start: datetime,
501
+ end: datetime,
502
+ initial_capital: float,
503
+ trade_from: Optional[datetime] = None,
504
+ benchmark: Optional[str] = None,
505
+ ) -> BacktestResult:
506
+ """Backtest ``symbols`` over ``[start, end]`` starting from ``initial_capital``.
507
+
508
+ Loads the full ``{symbol: bars}`` panel up front. For broad universes / long
509
+ intraday histories that don't fit in RAM, :meth:`run_streaming` is equivalent
510
+ but bounded by one symbol at a time.
511
+
512
+ ``trade_from`` separates *warmup* from *trading*: bars before it feed the
513
+ indicators but open no positions, and the equity curve starts there. This is
514
+ what lets an out-of-sample window be measured on its own portfolio curve
515
+ rather than reconstructed from a filtered list of trades.
516
+ """
517
+ self.strategy.initialize()
518
+ timeframe = self.strategy.config["timeframe"]
519
+ self._periods_per_year = Timeframe.parse(timeframe).periods_per_year()
520
+ data = self.data_client.get_bars(symbols, timeframe, start, end)
521
+ bench = self._benchmark_closes(benchmark, timeframe, start, end)
522
+ return self._simulate(
523
+ ((s, b) for s, b in data.items()), start, end, initial_capital, trade_from, bench
524
+ )
525
+
526
+ def run_streaming(
527
+ self,
528
+ source,
529
+ symbols: List[str],
530
+ start: datetime,
531
+ end: datetime,
532
+ initial_capital: float,
533
+ lookback_days: int = 3650,
534
+ trade_from: Optional[datetime] = None,
535
+ ) -> BacktestResult:
536
+ """Backtest from a :class:`~quantred.data.scan.BarSource`, **fetching** one symbol at a time.
537
+
538
+ Identical to :meth:`run` on the same data; the leakage guard lives in the
539
+ source's scan.
540
+
541
+ Memory note: this used to be bounded by a single symbol's history, because each
542
+ symbol was simulated independently and its frame retired immediately. Portfolio
543
+ accounting removed that property — positions compete for one capital pool, which
544
+ cannot be resolved without every symbol's bars resident. The saving here is now
545
+ in the *fetch* path (scan one symbol at a time rather than the whole panel at
546
+ once), not in peak simulation memory. For universes that genuinely do not fit,
547
+ the out-of-core substrate is the right tool.
548
+ """
549
+ self.strategy.initialize()
550
+ timeframe = self.strategy.config["timeframe"]
551
+ self._periods_per_year = Timeframe.parse(timeframe).periods_per_year()
552
+
553
+ def per_symbol():
554
+ for symbol in symbols:
555
+ scanned = source.scan([symbol], timeframe, end, lookback_days)
556
+ frame = scanned.get(symbol)
557
+ if frame is not None and not frame.empty:
558
+ yield symbol, frame # retired after this iteration → bounded memory
559
+
560
+ return self._simulate(per_symbol(), start, end, initial_capital, trade_from)
561
+
562
+ def _benchmark_closes(self, benchmark: Optional[str], timeframe, start, end):
563
+ """The benchmark's close series, or ``None`` when there is nothing usable.
564
+
565
+ A benchmark that cannot be fetched degrades to no benchmark rather than
566
+ failing the run - but it says so, because silently scoring alpha against
567
+ nothing is exactly the reading this whole path got wrong before.
568
+ """
569
+ if not benchmark:
570
+ return None
571
+ try:
572
+ bars = self.data_client.get_bars([benchmark], timeframe, start, end)
573
+ except Exception as exc: # noqa: BLE001 - a missing benchmark is not a failed run
574
+ logger.warning("Benchmark %s unavailable (%s); alpha/beta/IR will be unavailable", benchmark, exc)
575
+ return None
576
+ frame = bars.get(benchmark)
577
+ if frame is None or frame.empty or "close" not in frame:
578
+ logger.warning("Benchmark %s returned no bars; alpha/beta/IR will be unavailable", benchmark)
579
+ return None
580
+ return frame["close"]
581
+
582
+ def _simulate(
583
+ self,
584
+ symbol_bars,
585
+ start,
586
+ end,
587
+ initial_capital: float,
588
+ trade_from=None,
589
+ benchmark_closes=None,
590
+ ) -> BacktestResult:
591
+ """Simulate the whole universe on one clock against one capital pool."""
592
+ panels, market_data, master = self._prepare(symbol_bars)
593
+ # A merged-timeline step is the unit for *both* carry accrual and the equity
594
+ # curve, so both have to annualize on the merged timeline's own rate.
595
+ self._step_periods_per_year = self._step_rate(panels, master)
596
+ all_trades, equity_curve, equity_times, execution, legs, exposure, excursion = self._replay(
597
+ panels, master, initial_capital, trade_from
598
+ )
599
+
600
+ trades_df = pd.DataFrame(all_trades)
601
+ if not trades_df.empty:
602
+ trades_df = trades_df.sort_values("exit_time").reset_index(drop=True)
603
+
604
+ aligned_benchmark = _benchmark_returns(benchmark_closes, equity_times)
605
+ _leg_betas(legs, aligned_benchmark)
606
+ net_pnl = trades_df["pnl"].sum() if not trades_df.empty else 0.0
607
+ total_cost = float(trades_df["cost"].sum()) if "cost" in trades_df else 0.0
608
+ final_capital = initial_capital + net_pnl
609
+ # Cost belongs beside the fill diagnostics: both answer "what did executing
610
+ # this actually take out of the book". Gross profit is the honest denominator -
611
+ # 3% of capital in cost is fine against 40% gross and fatal against 4%.
612
+ execution["total_cost"] = total_cost
613
+ execution["gross_profit"] = float(net_pnl + total_cost)
614
+ # The shape of the book that was actually validated. All three shipped
615
+ # strategies declare max_positions=1, so a run over a scanned universe of
616
+ # sixty names can validate a one-position book without ever saying so.
617
+ limits = self.strategy.position_limits()
618
+ execution["max_positions"] = limits.get("max_positions")
619
+ execution["universe_size"] = len(market_data)
620
+ execution["symbols_traded"] = int(trades_df["symbol"].nunique()) if not trades_df.empty else 0
621
+ metrics = performance.compute_backtest_metrics(
622
+ trades_df,
623
+ equity_curve,
624
+ initial_capital,
625
+ final_capital,
626
+ market_data,
627
+ start=start,
628
+ end=end,
629
+ # The curve is sampled per merged-timeline step, so it annualizes on that
630
+ # timeline's rate — not the daily default, and not the raw timeframe.
631
+ periods_per_year=self._step_periods_per_year,
632
+ benchmark_returns=aligned_benchmark,
633
+ )
634
+
635
+ return BacktestResult(
636
+ metrics=metrics,
637
+ trades=trades_df,
638
+ equity_curve=equity_curve,
639
+ initial_capital=initial_capital,
640
+ final_capital=final_capital,
641
+ start=start,
642
+ end=end,
643
+ strategy_config=dict(self.strategy.config),
644
+ total_cost=total_cost,
645
+ gross_final_capital=final_capital + total_cost,
646
+ execution=execution,
647
+ benchmark_returns=aligned_benchmark,
648
+ equity_times=list(equity_times),
649
+ legs=legs,
650
+ exposure=_exposure_report(exposure),
651
+ excursion=excursion_module.portfolio_excursion(excursion),
652
+ )
653
+
654
+ # ------------------------------------------------------------------ #
655
+ # Simulation
656
+ # ------------------------------------------------------------------ #
657
+ def _prepare(self, symbol_bars):
658
+ """Compute signals/scores per symbol and align every symbol to one timeline.
659
+
660
+ Portfolio accounting needs the whole panel resident: a shared capital pool
661
+ cannot be simulated one symbol at a time.
662
+ """
663
+ frames: Dict[str, pd.DataFrame] = {}
664
+ market_data: Dict[str, Dict[str, float]] = {}
665
+ attempted = 0
666
+ failures: List[str] = []
667
+
668
+ for symbol, bars in symbol_bars:
669
+ if bars.empty:
670
+ continue
671
+ attempted += 1
672
+ market_data[symbol] = {"first_open": bars["open"].iloc[0], "last_close": bars["close"].iloc[-1]}
673
+ try:
674
+ processed = self.strategy.process_data(bars)
675
+ if processed is None or processed.empty:
676
+ continue
677
+ frames[symbol] = processed
678
+ except Exception as exc: # noqa: BLE001 - one bad symbol shouldn't abort the run
679
+ failures.append(f"{symbol}: {exc}")
680
+ # The traceback is for whoever is debugging; the failure itself is reported once,
681
+ # below, as a single line - a strategy that raises on every symbol used to
682
+ # print a full traceback per symbol.
683
+ logger.debug("Error preparing %s: %s", symbol, exc, exc_info=True)
684
+
685
+ # A run where *every* symbol raised is a broken strategy, not a flat one.
686
+ # Reporting it as a zero-trade result would let a config error masquerade as
687
+ # "no edge" - the one failure mode a validation engine must never have.
688
+ if attempted and len(failures) == attempted:
689
+ raise BacktestError(f"backtest failed for all {attempted} symbol(s); first error - {failures[0]}")
690
+ if not frames:
691
+ return {}, market_data, pd.DatetimeIndex([])
692
+
693
+ master = pd.DatetimeIndex(sorted(set().union(*(f.index for f in frames.values()))))
694
+ panels: Dict[str, _Panel] = {}
695
+ for symbol in sorted(frames): # sorted: iteration order must not affect results
696
+ data = frames[symbol]
697
+ try:
698
+ panels[symbol] = self._panel_for(symbol, data, master)
699
+ except Exception as exc: # noqa: BLE001
700
+ failures.append(f"{symbol}: {exc}")
701
+ logger.debug("Error backtesting %s: %s", symbol, exc, exc_info=True)
702
+
703
+ if attempted and len(failures) == attempted:
704
+ raise BacktestError(f"backtest failed for all {attempted} symbol(s); first error - {failures[0]}")
705
+ if failures:
706
+ # Some symbols dropped out: never silent, but one line, not one traceback each.
707
+ logger.warning(
708
+ "%d of %d symbol(s) failed and were left out of the run; first error - %s",
709
+ len(failures),
710
+ attempted,
711
+ failures[0],
712
+ )
713
+ return panels, market_data, master
714
+
715
+ def _step_rate(self, panels: Dict[str, _Panel], master: pd.DatetimeIndex) -> float:
716
+ """Steps-per-year of the merged timeline, for annualizing per-step quantities.
717
+
718
+ The strategy's timeframe gives the rate of a *single* symbol's bars. The
719
+ merged timeline is the union of every symbol's timestamps, so it is at least
720
+ as dense and is strictly denser whenever symbols don't share one grid —
721
+ halts, differing listing calendars, a mixed-venue universe. Annualizing a
722
+ per-step series at the single-symbol rate would then understate the sampling
723
+ frequency, inflating Sharpe and volatility by ``sqrt(density)`` and
724
+ understating short carry by ``density``.
725
+
726
+ Correcting by the observed density keeps an aligned universe (the common
727
+ case) exactly at its timeframe rate, since the ratio is then 1.
728
+ """
729
+ base = getattr(self, "_periods_per_year", None) or m.TRADING_DAYS_PER_YEAR
730
+ if not panels or len(master) < 2:
731
+ return float(base)
732
+ # The densest symbol is the best available estimate of one symbol's own grid;
733
+ # any symbol with a shorter history would overstate the density.
734
+ densest = max(len(p.opens) for p in panels.values())
735
+ if densest < 2:
736
+ return float(base)
737
+ return float(base) * (len(master) - 1) / (densest - 1)
738
+
739
+ def _panel_for(self, symbol: str, data: pd.DataFrame, master: pd.DatetimeIndex) -> _Panel:
740
+ """Build one symbol's master-aligned arrays."""
741
+ signal_map = self.strategy.generate_signals(data)
742
+ sig = (
743
+ pd.Series(signal_map).reindex(data.index, fill_value=signals.HOLD).to_numpy()
744
+ if signal_map
745
+ else np.full(len(data), signals.HOLD, dtype=object)
746
+ )
747
+ try:
748
+ scores = self.strategy.calculate_scores(data)
749
+ score = pd.Series(scores).reindex(data.index).fillna(0.0).to_numpy(dtype=float)
750
+ except Exception: # noqa: BLE001 - ranking falls back to symbol order
751
+ logger.debug("No usable score for %s; ranking on symbol order", symbol, exc_info=True)
752
+ score = np.zeros(len(data), dtype=float)
753
+
754
+ if self.cost_model is not None and "volume" in data:
755
+ adv = data["volume"].rolling(self.ADV_WINDOW).mean().fillna(0.0).to_numpy()
756
+ vol = data["close"].pct_change().rolling(self.VOL_WINDOW).std().fillna(0.0).to_numpy()
757
+ else:
758
+ adv = vol = None
759
+
760
+ rows = data.index.get_indexer(master)
761
+ return _Panel(
762
+ opens=data["open"].to_numpy(),
763
+ timestamps=data.index.to_numpy(),
764
+ highs=data["high"].to_numpy(),
765
+ lows=data["low"].to_numpy(),
766
+ closes=data["close"].to_numpy(),
767
+ sig=sig,
768
+ score=score,
769
+ rows=rows,
770
+ adv=adv,
771
+ vol=vol,
772
+ last_timestamp=data.index[-1],
773
+ last_step=int(np.nonzero(rows >= 0)[0][-1]),
774
+ )
775
+
776
+ def _replay(self, panels: Dict[str, _Panel], master, initial_capital: float, trade_from=None):
777
+ """Walk the merged timeline once: mark, exit, rank, admit, record."""
778
+ book = _Book(cash=initial_capital)
779
+ trades: List[Dict[str, Any]] = []
780
+ equity_curve: List[float] = [initial_capital]
781
+ # One timestamp per recorded equity point. The curve is a bare list of floats
782
+ # by the time metrics see it, so alpha/beta against a date-indexed benchmark
783
+ # can only be computed if something remembers which instants those floats
784
+ # belong to - and nothing did.
785
+ equity_times: List[Any] = [None]
786
+ # Realized-plus-unrealized P&L per side, sampled on the same steps as the equity
787
+ # curve. Built here rather than from the trade list because a curve
788
+ # reconstructed from closed trades only sees P&L at exit - which is precisely
789
+ # what volatility, drawdown and beta are most distorted by.
790
+ realized_by_side: Dict[str, float] = {signals.BUY: 0.0, signals.SELL: 0.0}
791
+ leg_curves: Dict[str, List[float]] = {signals.BUY: [0.0], signals.SELL: [0.0]}
792
+ exposure_curve: List[Dict[str, float]] = []
793
+ excursion_curve: List[Dict[str, Any]] = []
794
+ if not panels:
795
+ return (
796
+ trades,
797
+ equity_curve,
798
+ equity_times,
799
+ book.execution.as_dict(),
800
+ _leg_report(leg_curves, trades, initial_capital),
801
+ exposure_curve,
802
+ excursion_curve,
803
+ )
804
+
805
+ cutoff = _align_tz(trade_from, master) if trade_from is not None else None
806
+ limits = self.strategy.position_limits()
807
+ max_positions = limits["max_positions"]
808
+ max_total_risk = limits["max_total_risk"]
809
+ max_gross_exposure = limits.get("max_gross_exposure")
810
+ max_net_exposure = limits.get("max_net_exposure")
811
+ min_notional = limits.get("min_notional")
812
+ n_steps = len(master)
813
+ order = sorted(panels)
814
+
815
+ for k in range(n_steps):
816
+ # 1. Mark open positions at this bar's OPEN, and track excursion extremes.
817
+ #
818
+ # The open, not the close, and the distinction is a causality one. Every
819
+ # decision below transacts at this bar's open, and the exposure caps in
820
+ # `_open_position` are tested against `equity * cap` - so marking the book
821
+ # at the close first meant whether an entry was admitted could depend on
822
+ # where the bar finished, hours after the price it filled at. The book's
823
+ # value at the moment of the fill is what the open gives you.
824
+ #
825
+ # The excursion extremes are the exception and stay full-bar: they are
826
+ # reported, never consulted by a decision, and the whole point of MAE/MFE
827
+ # is the worst and best the position actually saw while it was held.
828
+ for symbol in order:
829
+ pos = book.positions.get(symbol)
830
+ if pos is None:
831
+ continue
832
+ i = panels[symbol].rows[k]
833
+ if i < 0: # no bar for this name right now
834
+ continue
835
+ panel = panels[symbol]
836
+ pos["last_price"] = panel.opens[i]
837
+ pos["lowest"] = min(pos["lowest"], panel.lows[i])
838
+ pos["highest"] = max(pos["highest"], panel.highs[i])
839
+
840
+ # 2. Exits first, so the capital they free is reusable this same bar.
841
+ for symbol in order:
842
+ pos = book.positions.get(symbol)
843
+ if pos is None:
844
+ continue
845
+ panel = panels[symbol]
846
+ i = panel.rows[k]
847
+ if i < 0:
848
+ continue
849
+ exit_cost = self._trade_cost(
850
+ symbol, pos["size"], panel.opens[i], panel.adv, panel.vol, i
851
+ ) + self._carry(pos, k)
852
+ decided_signal, _ = self._decided(panel, i)
853
+ closed = self._maybe_close(
854
+ pos,
855
+ decided_signal,
856
+ panel.opens[i],
857
+ panel.highs[i],
858
+ panel.lows[i],
859
+ panel.timestamps[i],
860
+ exit_cost,
861
+ )
862
+ if closed is not None:
863
+ trades.append(closed)
864
+ realized_by_side[pos["side"]] += closed["pnl"]
865
+ book.cash += pos["notional"] + closed["gross_pnl"] - exit_cost
866
+ del book.positions[symbol]
867
+
868
+ # 3. Collect this bar's entry candidates across the whole universe.
869
+ # Before the cutoff, bars only warm up indicators - no position opens.
870
+ candidates: List[Tuple[float, str, int]] = []
871
+ trading = cutoff is None or master[k] >= cutoff
872
+ for symbol in order if trading else ():
873
+ if symbol in book.positions:
874
+ continue
875
+ panel = panels[symbol]
876
+ i = panel.rows[k]
877
+ if i < 0:
878
+ continue
879
+ decided_signal, decided_score = self._decided(panel, i)
880
+ if decided_signal in signals.ENTRY_SIGNALS:
881
+ # Ranked on the same bar's score the signal came from, or the
882
+ # ordering would reintroduce the look-ahead the signal just lost.
883
+ candidates.append((abs(float(decided_score)), symbol, i, decided_signal))
884
+
885
+ # 4. Admit in conviction order while limits and cash allow. Ranking by the
886
+ # strategy's own score keeps one source of truth; the symbol tie-break
887
+ # makes a tie deterministic rather than dict-order dependent.
888
+ if candidates:
889
+ candidates.sort(key=lambda c: (-c[0], c[1]))
890
+ equity_now = book.equity()
891
+ for _, symbol, i, decided_signal in candidates:
892
+ if len(book.positions) >= max_positions:
893
+ break
894
+ panel = panels[symbol]
895
+ pos = self._open_position(
896
+ symbol,
897
+ decided_signal,
898
+ panel.opens[i],
899
+ panel.timestamps[i],
900
+ book,
901
+ equity_now,
902
+ max_total_risk,
903
+ max_gross_exposure,
904
+ max_net_exposure,
905
+ min_notional,
906
+ panel.adv,
907
+ panel.vol,
908
+ i,
909
+ )
910
+ if pos is None:
911
+ continue
912
+ pos["entry_k"] = k
913
+ book.cash -= pos["notional"] + pos["entry_cost"]
914
+ book.positions[symbol] = pos
915
+
916
+ if trading:
917
+ # Now the close: everything that transacts on this bar has happened, so
918
+ # the curve is an end-of-bar mark-to-market again. Only the decisions
919
+ # above are held to the open, and only because they priced there.
920
+ for symbol in order:
921
+ pos = book.positions.get(symbol)
922
+ if pos is None:
923
+ continue
924
+ i = panels[symbol].rows[k]
925
+ if i >= 0:
926
+ pos["last_price"] = panels[symbol].closes[i]
927
+ equity_now_marked = book.equity()
928
+ equity_curve.append(equity_now_marked)
929
+ equity_times.append(master[k])
930
+ # Sampled every step the equity curve is, so the exposure a book
931
+ # *carried* is measurable rather than only the exposure it closed at.
932
+ # This is what a net cap has to be derived from: the fraction is
933
+ # meaningless without knowing what the strategy actually ran at.
934
+ exposure_curve.append(
935
+ {
936
+ "net": book.net_exposure(),
937
+ "gross": book.gross_exposure(),
938
+ "equity": equity_now_marked,
939
+ }
940
+ )
941
+ # What the book would have been worth at the worst, and the best, tick
942
+ # inside this bar. Reported, never consulted: no decision above reads
943
+ # any of it, which is the only reason a full-bar extreme is allowed
944
+ # here at all while the decisions are held to the open.
945
+ adverse_prices: Dict[str, float] = {}
946
+ favourable_prices: Dict[str, float] = {}
947
+ for symbol, pos in book.positions.items():
948
+ i = panels[symbol].rows[k]
949
+ if i < 0:
950
+ continue
951
+ low, high = panels[symbol].lows[i], panels[symbol].highs[i]
952
+ long_side = pos["side"] == signals.BUY
953
+ adverse_prices[symbol] = low if long_side else high
954
+ favourable_prices[symbol] = high if long_side else low
955
+ excursion_curve.append(
956
+ {
957
+ "time": master[k],
958
+ "equity_close": equity_now_marked,
959
+ "equity_adverse": book.equity_at(adverse_prices),
960
+ "equity_favourable": book.equity_at(favourable_prices),
961
+ "gross": book.gross_exposure(),
962
+ "net": book.net_exposure(),
963
+ "open_positions": len(book.positions),
964
+ }
965
+ )
966
+ unrealized = {signals.BUY: 0.0, signals.SELL: 0.0}
967
+ for pos in book.positions.values():
968
+ direction = 1 if pos["side"] == signals.BUY else -1
969
+ unrealized[pos["side"]] += (
970
+ (pos["last_price"] - pos["entry_price"]) * pos["size"] * direction
971
+ )
972
+ for side, curve in leg_curves.items():
973
+ curve.append(realized_by_side[side] + unrealized[side])
974
+
975
+ # Force-close whatever is still open, each at its own last bar.
976
+ for symbol in order:
977
+ pos = book.positions.get(symbol)
978
+ if pos is None:
979
+ continue
980
+ panel = panels[symbol]
981
+ price = panel.closes[-1]
982
+ final_cost = self._trade_cost(symbol, pos["size"], price, panel.adv, panel.vol, -1) + self._carry(
983
+ pos, panel.last_step
984
+ )
985
+ closed = self._close(pos, price, panel.last_timestamp, "END_OF_PERIOD", final_cost)
986
+ trades.append(closed)
987
+ book.cash += pos["notional"] + closed["gross_pnl"] - final_cost
988
+ del book.positions[symbol]
989
+ if len(equity_curve) > 1:
990
+ equity_curve[-1] = book.equity()
991
+
992
+ return (
993
+ trades,
994
+ equity_curve,
995
+ equity_times,
996
+ book.execution.as_dict(),
997
+ _leg_report(leg_curves, trades, initial_capital),
998
+ exposure_curve,
999
+ excursion_curve,
1000
+ )
1001
+
1002
+ def _trade_cost(self, symbol, shares, price, adv, vol, i) -> float:
1003
+ """Dollar cost of trading ``shares`` at bar ``i`` (0 when no cost model)."""
1004
+ if self.cost_model is None or adv is None or shares == 0:
1005
+ return 0.0
1006
+ trade = Trade(symbol=symbol, shares=shares, price=price, adv=float(adv[i]), daily_vol=float(vol[i]))
1007
+ return self.cost_model.cost(trade).total
1008
+
1009
+ def _carry(self, position: Dict[str, Any], exit_bar: int) -> float:
1010
+ """Financing (borrow) cost of holding ``position`` until ``exit_bar`` - shorts only."""
1011
+ if self.cost_model is None:
1012
+ return 0.0
1013
+ # entry_k/exit_bar are merged-timeline steps, so the divisor must be the
1014
+ # merged timeline's rate rather than a single symbol's timeframe rate.
1015
+ rate = getattr(self, "_step_periods_per_year", None) or self._periods_per_year
1016
+ held_years = max(exit_bar - position.get("entry_k", exit_bar), 0) / rate
1017
+ notional = position["size"] * position["entry_price"]
1018
+ return self.cost_model.carry_cost(notional, position["side"] == signals.SELL, held_years)
1019
+
1020
+ def _open_position(
1021
+ self,
1022
+ symbol: str,
1023
+ signal: str,
1024
+ price: float,
1025
+ timestamp,
1026
+ book: _Book,
1027
+ equity: float,
1028
+ max_total_risk: float,
1029
+ max_gross_exposure: Optional[float],
1030
+ max_net_exposure: Optional[float],
1031
+ min_notional: Optional[float],
1032
+ adv: Optional[np.ndarray],
1033
+ vol: Optional[np.ndarray],
1034
+ i: int,
1035
+ ) -> Optional[Dict[str, Any]]:
1036
+ """Size and admit one candidate, or return None if the book cannot fund it.
1037
+
1038
+ **Shorts are fully cash-collateralized.** Opening debits the whole notional
1039
+ regardless of side, rather than crediting short proceeds against margin as a
1040
+ real margin account would. This is deliberate and conservative: it charges a
1041
+ short the same buying power as the equivalent long, so the book can never
1042
+ take on leverage the engine isn't modelling. The consequence to keep in mind
1043
+ is that short capacity is understated, and a long-short configuration is
1044
+ therefore compared against a long-only one on slightly unequal footing.
1045
+ Entry and exit are symmetric, so realized P&L is unaffected either way.
1046
+ """
1047
+ # Free cash is the buying power; equity is the whole book. Sizing against
1048
+ # cash is what makes positions actually compete for the same dollars.
1049
+ account = AccountSnapshot(
1050
+ cash=book.cash,
1051
+ equity=equity,
1052
+ buying_power=book.cash,
1053
+ portfolio_value=equity,
1054
+ )
1055
+ # Requested before rounding, so the gap between intent and fill is measurable
1056
+ # rather than inferred. Whole shares: the live path floors too, and the two
1057
+ # clocks must agree about what is fillable.
1058
+ requested = self.sizer.size(symbol, price, account)
1059
+ size = round_quantity(requested)
1060
+ if size <= 0:
1061
+ if requested > 0:
1062
+ book.execution.rounded_to_zero += 1
1063
+ return None
1064
+ if below_min_notional(size, price, min_notional):
1065
+ # The same rule the live path applies, from the same definition — the two
1066
+ # clocks agreeing about what is fillable is the whole point of it living in
1067
+ # one place.
1068
+ book.execution.below_min_notional += 1
1069
+ return None
1070
+ # The affordability check must include what this fill will cost to enter,
1071
+ # not just its notional - otherwise a maximally-sized position can push
1072
+ # cash negative once commission/spread/impact are subtracted.
1073
+ entry_cost = self._trade_cost(symbol, size, price, adv, vol, i)
1074
+ if size * price + entry_cost > book.cash:
1075
+ return None
1076
+
1077
+ stop_pct = self.strategy.config["stop_loss"]
1078
+ take_pct = self.strategy.config["take_profit"]
1079
+ # Portfolio-level risk budget. calculate_position_size caps a *single*
1080
+ # position against max_total_risk; nothing capped the book as a whole, so
1081
+ # "max_total_risk" was per-symbol in practice.
1082
+ #
1083
+ # What it bounds is loss-at-stop, not deployed notional: at a 0.5% stop a 5%
1084
+ # budget admits ten times equity in notional before this gate binds. It is
1085
+ # not a gross exposure cap, and max_gross_exposure below is the one that is.
1086
+ risk = size * price * stop_pct
1087
+ if max_total_risk and book.open_risk() + risk > equity * max_total_risk:
1088
+ return None
1089
+
1090
+ # Gross notional cap, off unless configured. Free cash already holds the book
1091
+ # near 1x on its own (shorts are fully collateralized, per the docstring), so
1092
+ # this binds when a config wants to sit deliberately below that.
1093
+ if max_gross_exposure and book.gross_exposure() + size * price > equity * max_gross_exposure:
1094
+ return None
1095
+
1096
+ # Directional cap. Tested on the resulting |net| rather than on the addition,
1097
+ # so an entry that moves the book *toward* flat is admitted even when the book
1098
+ # is already over the cap - rejecting a hedge for being a trade would leave the
1099
+ # tilt it would have corrected in place.
1100
+ if max_net_exposure:
1101
+ signed = size * price if signal == signals.BUY else -size * price
1102
+ if abs(book.net_exposure() + signed) > equity * max_net_exposure:
1103
+ return None
1104
+
1105
+ if signal == signals.BUY:
1106
+ stop, take = price * (1 - stop_pct), price * (1 + take_pct)
1107
+ else:
1108
+ stop, take = price * (1 + stop_pct), price * (1 - take_pct)
1109
+
1110
+ # Counted here rather than at the sizing call, because everything between the
1111
+ # two can still decline the entry - and a position the book refused to fund is
1112
+ # not evidence about whether rounding was affordable.
1113
+ book.execution.requested_notional += requested * price
1114
+ book.execution.filled_notional += size * price
1115
+ book.execution.filled += 1
1116
+ return {
1117
+ "symbol": symbol,
1118
+ "side": signal,
1119
+ "size": size,
1120
+ "entry_price": price,
1121
+ "entry_time": timestamp,
1122
+ "stop_loss": stop,
1123
+ "take_profit": take,
1124
+ "notional": size * price,
1125
+ "entry_cost": entry_cost,
1126
+ "risk": risk,
1127
+ "last_price": price,
1128
+ # Running extremes while the position is open, seeded at entry.
1129
+ "lowest": price,
1130
+ "highest": price,
1131
+ }
1132
+
1133
+ @staticmethod
1134
+ def _decided(panel, i: int):
1135
+ """The signal and score a bar may act on: the *previous* bar's, always.
1136
+
1137
+ A signal at bar ``i`` comes from ``calculate_scores`` at bar ``i``, and scores
1138
+ are computed from that bar's close. Acting on it at bar ``i``'s open transacted
1139
+ at a price that existed hours before the information did — a one-bar look-ahead
1140
+ on every entry and every signal exit, applied to the whole history.
1141
+
1142
+ A feed shift cannot detect it: shifting moves signal and price together, so the
1143
+ relationship survives intact. That is why the leakage probe passed.
1144
+
1145
+ The live path has always been causal — a closed bar arrives, a signal is emitted,
1146
+ a market order fills strictly afterwards. This is the backtest matching it, not a
1147
+ new conservatism.
1148
+ """
1149
+ if i <= 0:
1150
+ return signals.HOLD, 0.0
1151
+ return panel.sig[i - 1], panel.score[i - 1]
1152
+
1153
+ def _maybe_close(
1154
+ self, position: Dict[str, Any], signal: str, price_open, high, low, timestamp, exit_cost: float = 0.0
1155
+ ) -> Optional[Dict[str, Any]]:
1156
+ """Return a closed-trade record if an exit triggers this bar, else None."""
1157
+ margin = self.take_profit_margin_bps / 1e4
1158
+ if position["side"] == signals.BUY:
1159
+ if low <= position["stop_loss"]:
1160
+ return self._close(position, position["stop_loss"], timestamp, "STOP_LOSS", exit_cost)
1161
+ if high >= position["take_profit"] * (1.0 + margin):
1162
+ return self._close(position, position["take_profit"], timestamp, "TAKE_PROFIT", exit_cost)
1163
+ if signal in (signals.SELL, signals.CLOSE_BUY):
1164
+ return self._close(position, price_open, timestamp, "SIGNAL", exit_cost)
1165
+ else: # short
1166
+ if high >= position["stop_loss"]:
1167
+ return self._close(position, position["stop_loss"], timestamp, "STOP_LOSS", exit_cost)
1168
+ if low <= position["take_profit"] * (1.0 - margin):
1169
+ return self._close(position, position["take_profit"], timestamp, "TAKE_PROFIT", exit_cost)
1170
+ if signal in (signals.BUY, signals.CLOSE_SELL):
1171
+ return self._close(position, price_open, timestamp, "SIGNAL", exit_cost)
1172
+ return None
1173
+
1174
+ @staticmethod
1175
+ def _close(
1176
+ position: Dict[str, Any], exit_price: float, timestamp, reason: str, exit_cost: float = 0.0
1177
+ ) -> Dict[str, Any]:
1178
+ direction = 1 if position["side"] == signals.BUY else -1
1179
+ entry = position["entry_price"]
1180
+ gross_pnl = (exit_price - entry) * position["size"] * direction
1181
+ # Cost is charged on BOTH legs (entry + exit); net pnl is what the book keeps.
1182
+ cost = position.get("entry_cost", 0.0) + exit_cost
1183
+ pnl = gross_pnl - cost
1184
+ # Max adverse / favorable excursion as fractions of entry price. For a long
1185
+ # the adverse extreme is the low and the favorable extreme is the high; for
1186
+ # a short the roles swap.
1187
+ low, high = position.get("lowest", entry), position.get("highest", entry)
1188
+ if direction == 1:
1189
+ mae_pct, mfe_pct = (entry - low) / entry, (high - entry) / entry
1190
+ else:
1191
+ mae_pct, mfe_pct = (high - entry) / entry, (entry - low) / entry
1192
+ return {
1193
+ "symbol": position["symbol"],
1194
+ "side": position["side"],
1195
+ "entry_time": position["entry_time"],
1196
+ "exit_time": timestamp,
1197
+ "entry_price": entry,
1198
+ "exit_price": exit_price,
1199
+ "size": position["size"],
1200
+ "pnl": pnl,
1201
+ "gross_pnl": gross_pnl,
1202
+ "cost": cost,
1203
+ "exit_reason": reason,
1204
+ "mae_pct": float(max(mae_pct, 0.0)) * 100.0,
1205
+ "mfe_pct": float(max(mfe_pct, 0.0)) * 100.0,
1206
+ }