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,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
|
+
}
|