alphaengine 0.1.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.
- alphaengine/__init__.py +47 -0
- alphaengine/_version.py +15 -0
- alphaengine/core/__init__.py +63 -0
- alphaengine/core/backtest.py +676 -0
- alphaengine/core/factors.py +136 -0
- alphaengine/core/pairs.py +514 -0
- alphaengine/core/performance.py +108 -0
- alphaengine/core/risk.py +127 -0
- alphaengine/core/technical.py +213 -0
- alphaengine/core/validation.py +361 -0
- alphaengine/py.typed +0 -0
- alphaengine/study/__init__.py +32 -0
- alphaengine/study/schema.py +146 -0
- alphaengine/sweep/__init__.py +18 -0
- alphaengine/sweep/runner.py +321 -0
- alphaengine-0.1.0.dist-info/METADATA +157 -0
- alphaengine-0.1.0.dist-info/RECORD +19 -0
- alphaengine-0.1.0.dist-info/WHEEL +4 -0
- alphaengine-0.1.0.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,676 @@
|
|
|
1
|
+
"""
|
|
2
|
+
backtest.py, deterministic signal+price backtest (BACKTESTING_TRACKRECORD_PLAN.md
|
|
3
|
+
Pillar A). PURE: signals + prices in, fills simulated with slippage/commission,
|
|
4
|
+
equity curve + per-trade log out. No fetch, no LLM, no clock. Math-identical to
|
|
5
|
+
backend/quant/backtest.py (parity-guarded).
|
|
6
|
+
|
|
7
|
+
Input:
|
|
8
|
+
signals : {ticker: [{date, target_weight | action, conviction?}...]}
|
|
9
|
+
target_weight is a signed portfolio weight (negative = short),
|
|
10
|
+
forward-filled until changed; `action` maps to a weight
|
|
11
|
+
(enter_long/long/buy=+1, enter_short/short=-1, exit/flat/close=0,
|
|
12
|
+
hold=carry prior). Weights are clamped to ±max_position_pct.
|
|
13
|
+
prices : {ticker: [{date, close, open?}...]} (or {date: close} / bare closes)
|
|
14
|
+
config : slippage_bps, commission_bps, fill_timing ('close'|'next_open'),
|
|
15
|
+
initial_capital, max_position_pct
|
|
16
|
+
|
|
17
|
+
Output: dates, equity_curve, returns, trades[], summary scalars. Scoring
|
|
18
|
+
(performance_report + deflated-Sharpe verdict) is layered on by the gateway tool;
|
|
19
|
+
this module only simulates and reconstructs trades.
|
|
20
|
+
|
|
21
|
+
No-look-ahead: a signal dated bar i is actioned at bar i's close ('close') or
|
|
22
|
+
bar i+1's open ('next_open'), never at a price it could not have transacted at.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import math
|
|
28
|
+
|
|
29
|
+
MIN_BARS = 2
|
|
30
|
+
|
|
31
|
+
# action keyword -> target weight; None means "hold" (carry the prior weight).
|
|
32
|
+
_ACTION_WEIGHTS = {
|
|
33
|
+
"enter_long": 1.0,
|
|
34
|
+
"long": 1.0,
|
|
35
|
+
"buy": 1.0,
|
|
36
|
+
"enter_short": -1.0,
|
|
37
|
+
"short": -1.0,
|
|
38
|
+
"sell_short": -1.0,
|
|
39
|
+
"exit": 0.0,
|
|
40
|
+
"flat": 0.0,
|
|
41
|
+
"close": 0.0,
|
|
42
|
+
"sell": 0.0,
|
|
43
|
+
"hold": None,
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _f(v) -> float | None:
|
|
48
|
+
try:
|
|
49
|
+
return float(v)
|
|
50
|
+
except (TypeError, ValueError):
|
|
51
|
+
return None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _norm_price_series(series) -> dict:
|
|
55
|
+
"""Coerce one ticker's prices to {date: {'close', 'open'}}. Accepts a list of
|
|
56
|
+
{date, close, open?}, a {date: close} map, or bare closes (synthetic dates)."""
|
|
57
|
+
out: dict = {}
|
|
58
|
+
if isinstance(series, dict):
|
|
59
|
+
for k, v in series.items():
|
|
60
|
+
if isinstance(v, dict):
|
|
61
|
+
c = _f(v.get("close", v.get("Close", v.get("price"))))
|
|
62
|
+
o = _f(v.get("open", v.get("Open")))
|
|
63
|
+
if c is not None:
|
|
64
|
+
out[str(k)] = {"close": c, "open": o}
|
|
65
|
+
else:
|
|
66
|
+
c = _f(v)
|
|
67
|
+
if c is not None:
|
|
68
|
+
out[str(k)] = {"close": c, "open": None}
|
|
69
|
+
elif isinstance(series, list):
|
|
70
|
+
for i, row in enumerate(series):
|
|
71
|
+
if isinstance(row, dict):
|
|
72
|
+
d = str(row.get("date") or row.get("Date") or i)
|
|
73
|
+
c = _f(row.get("close", row.get("Close", row.get("price"))))
|
|
74
|
+
o = _f(row.get("open", row.get("Open")))
|
|
75
|
+
if c is not None:
|
|
76
|
+
out[d] = {"close": c, "open": o}
|
|
77
|
+
else:
|
|
78
|
+
c = _f(row)
|
|
79
|
+
if c is not None:
|
|
80
|
+
out[str(i)] = {"close": c, "open": None}
|
|
81
|
+
return out
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _norm_signal_series(series) -> list[tuple[str, float | None]]:
|
|
85
|
+
"""-> sorted [(date, target_weight or None-for-hold)]; weight from an explicit
|
|
86
|
+
target_weight, else mapped from `action`."""
|
|
87
|
+
out: list[tuple[str, float | None]] = []
|
|
88
|
+
for row in series or []:
|
|
89
|
+
if not isinstance(row, dict):
|
|
90
|
+
continue
|
|
91
|
+
d = str(row.get("date") or row.get("Date") or "")
|
|
92
|
+
if not d:
|
|
93
|
+
continue
|
|
94
|
+
if row.get("target_weight") is not None:
|
|
95
|
+
w = _f(row["target_weight"])
|
|
96
|
+
if w is not None:
|
|
97
|
+
out.append((d, w))
|
|
98
|
+
elif row.get("action") is not None:
|
|
99
|
+
out.append((d, _ACTION_WEIGHTS.get(str(row["action"]).lower().strip())))
|
|
100
|
+
out.sort(key=lambda x: x[0])
|
|
101
|
+
return out
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _apply_fill(p: dict, ticker: str, delta: float, exec_price: float, date: str, trades: list) -> None:
|
|
105
|
+
"""Update a position with a signed share delta at exec_price, emitting a
|
|
106
|
+
closed-trade record on each reduction / close / flip (avg-cost accounting)."""
|
|
107
|
+
cur = p["shares"]
|
|
108
|
+
if cur == 0.0 or (cur > 0) == (delta > 0): # opening or adding (same side)
|
|
109
|
+
new = cur + delta
|
|
110
|
+
if cur == 0.0:
|
|
111
|
+
p["avg"] = exec_price
|
|
112
|
+
p["entry_date"] = date
|
|
113
|
+
else:
|
|
114
|
+
p["avg"] = (p["avg"] * cur + exec_price * delta) / new
|
|
115
|
+
p["shares"] = new
|
|
116
|
+
return
|
|
117
|
+
# Opposite side: reduce / close / flip, realize P&L on the closed portion.
|
|
118
|
+
closing = min(abs(delta), abs(cur))
|
|
119
|
+
sign = 1.0 if cur > 0 else -1.0
|
|
120
|
+
entry = p["avg"]
|
|
121
|
+
pnl = sign * (exec_price - entry) * closing
|
|
122
|
+
pnl_pct = (sign * (exec_price - entry) / entry * 100.0) if entry else 0.0
|
|
123
|
+
trades.append(
|
|
124
|
+
{
|
|
125
|
+
"ticker": ticker,
|
|
126
|
+
"side": ("long" if cur > 0 else "short"),
|
|
127
|
+
"entry_date": p["entry_date"],
|
|
128
|
+
"entry_price": round(entry, 6),
|
|
129
|
+
"exit_date": date,
|
|
130
|
+
"exit_price": round(exec_price, 6),
|
|
131
|
+
"shares": round(closing, 6),
|
|
132
|
+
"pnl": round(pnl, 2),
|
|
133
|
+
"pnl_pct": round(pnl_pct, 4),
|
|
134
|
+
}
|
|
135
|
+
)
|
|
136
|
+
remaining = abs(cur) - closing
|
|
137
|
+
if remaining <= 1e-12:
|
|
138
|
+
flip = abs(delta) - abs(cur)
|
|
139
|
+
if flip > 1e-12: # overshoot -> open the flipped side
|
|
140
|
+
p["shares"] = (1.0 if delta > 0 else -1.0) * flip
|
|
141
|
+
p["avg"] = exec_price
|
|
142
|
+
p["entry_date"] = date
|
|
143
|
+
else:
|
|
144
|
+
p["shares"], p["avg"], p["entry_date"] = 0.0, 0.0, None
|
|
145
|
+
else:
|
|
146
|
+
p["shares"] = sign * remaining # same side, reduced; avg/entry unchanged
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def run_backtest(
|
|
150
|
+
signals: dict,
|
|
151
|
+
prices: dict,
|
|
152
|
+
*,
|
|
153
|
+
slippage_bps: float = 5.0,
|
|
154
|
+
commission_bps: float = 1.0,
|
|
155
|
+
fill_timing: str = "close",
|
|
156
|
+
initial_capital: float = 100000.0,
|
|
157
|
+
max_position_pct: float = 1.0,
|
|
158
|
+
adv: dict | None = None,
|
|
159
|
+
impact_coef: float = 0.1,
|
|
160
|
+
max_participation: float | None = None,
|
|
161
|
+
) -> dict:
|
|
162
|
+
"""See the module docstring for the fill model. Capacity / market-impact are
|
|
163
|
+
OPT-IN and OFF unless `adv` (a {ticker: average daily DOLLAR volume} map) is
|
|
164
|
+
supplied, with adv=None the fill math is byte-identical to the cost-free path
|
|
165
|
+
(the golden + parity guard depend on this):
|
|
166
|
+
- square-root market impact: a per-fill cost = notional·impact_coef·√(notional/ADV)
|
|
167
|
+
(Almgren-style) deducted from cash on top of slippage + commission.
|
|
168
|
+
- capacity cap: when `max_participation` is set, a fill is clipped so its
|
|
169
|
+
notional never exceeds max_participation·ADV; the un-filled remainder keeps
|
|
170
|
+
working toward target on later bars (tagged n_capacity_limited_fills).
|
|
171
|
+
Always reports a `cost_breakdown` (slippage/commission/impact $), the per-bar
|
|
172
|
+
cost drag (`costs_per_bar`, for a gross-vs-net decomposition) and gross traded
|
|
173
|
+
notional (for turnover), these are derived, never stored."""
|
|
174
|
+
prices_n = {str(tk).upper(): _norm_price_series(s) for tk, s in (prices or {}).items()}
|
|
175
|
+
signals_n = {str(tk).upper(): _norm_signal_series(s) for tk, s in (signals or {}).items()}
|
|
176
|
+
|
|
177
|
+
all_dates = sorted({d for s in prices_n.values() for d in s})
|
|
178
|
+
if len(all_dates) < MIN_BARS:
|
|
179
|
+
return {"error": f"need >= {MIN_BARS} price bars across the universe", "n_bars": len(all_dates)}
|
|
180
|
+
|
|
181
|
+
slip = float(slippage_bps) / 10000.0
|
|
182
|
+
comm = float(commission_bps) / 10000.0
|
|
183
|
+
cap = abs(float(max_position_pct))
|
|
184
|
+
fill_open = str(fill_timing) == "next_open"
|
|
185
|
+
# Capacity / market-impact (opt-in; inert when adv is falsy).
|
|
186
|
+
_adv = {str(tk).upper(): float(v) for tk, v in (adv or {}).items() if _f(v) and float(v) > 0}
|
|
187
|
+
_icoef = abs(float(impact_coef)) if impact_coef else 0.0
|
|
188
|
+
_maxpart = abs(float(max_participation)) if max_participation else None
|
|
189
|
+
|
|
190
|
+
# Per-ticker target weight known as of each bar's close (forward-filled,
|
|
191
|
+
# clamped to ±cap; 'hold' carries the prior weight).
|
|
192
|
+
def _known_weights(tk: str) -> dict:
|
|
193
|
+
sig = signals_n.get(tk, [])
|
|
194
|
+
known, si, w = {}, 0, 0.0
|
|
195
|
+
for d in all_dates:
|
|
196
|
+
while si < len(sig) and sig[si][0] <= d:
|
|
197
|
+
if sig[si][1] is not None:
|
|
198
|
+
w = max(-cap, min(cap, sig[si][1]))
|
|
199
|
+
si += 1
|
|
200
|
+
known[d] = w
|
|
201
|
+
return known
|
|
202
|
+
|
|
203
|
+
targets = {tk: _known_weights(tk) for tk in prices_n}
|
|
204
|
+
tickers = sorted(prices_n)
|
|
205
|
+
|
|
206
|
+
cash = float(initial_capital)
|
|
207
|
+
pos = {tk: {"shares": 0.0, "avg": 0.0, "entry_date": None} for tk in prices_n}
|
|
208
|
+
applied = {tk: 0.0 for tk in prices_n} # weight currently expressed in the position
|
|
209
|
+
trades: list[dict] = []
|
|
210
|
+
equity_curve: list[float] = []
|
|
211
|
+
dates_out: list[str] = []
|
|
212
|
+
costs_per_bar: list[float] = [] # total explicit cost charged to equity each bar
|
|
213
|
+
slip_cost_total = comm_cost_total = impact_cost_total = 0.0
|
|
214
|
+
gross_notional = 0.0 # |delta|·exec traded, for turnover
|
|
215
|
+
n_capacity_limited = 0
|
|
216
|
+
|
|
217
|
+
def _mark_equity(d: str) -> float:
|
|
218
|
+
eq = cash
|
|
219
|
+
for tk in tickers:
|
|
220
|
+
bar = prices_n[tk].get(d)
|
|
221
|
+
if bar and pos[tk]["shares"] != 0.0:
|
|
222
|
+
eq += pos[tk]["shares"] * bar["close"]
|
|
223
|
+
return eq
|
|
224
|
+
|
|
225
|
+
for idx, d in enumerate(all_dates):
|
|
226
|
+
bar_cost = 0.0
|
|
227
|
+
for tk in tickers:
|
|
228
|
+
bar = prices_n[tk].get(d)
|
|
229
|
+
if bar is None:
|
|
230
|
+
continue
|
|
231
|
+
if fill_open:
|
|
232
|
+
fillp = bar["open"] if bar["open"] is not None else bar["close"]
|
|
233
|
+
ref_date = all_dates[idx - 1] if idx > 0 else d # act on prior close's signal
|
|
234
|
+
else:
|
|
235
|
+
fillp = bar["close"]
|
|
236
|
+
ref_date = d
|
|
237
|
+
if not fillp or fillp <= 0:
|
|
238
|
+
continue
|
|
239
|
+
tw = targets[tk].get(ref_date, 0.0)
|
|
240
|
+
# Trade ONLY when the target weight CHANGES, enter on signal, hold,
|
|
241
|
+
# exit on signal. No rebalance-to-weight drift between signals (which
|
|
242
|
+
# would manufacture phantom trades every bar).
|
|
243
|
+
if abs(tw - applied[tk]) < 1e-12:
|
|
244
|
+
continue
|
|
245
|
+
equity = _mark_equity(d)
|
|
246
|
+
target_shares = tw * equity / fillp
|
|
247
|
+
delta = target_shares - pos[tk]["shares"]
|
|
248
|
+
applied[tk] = tw
|
|
249
|
+
exec_price = fillp * (1 + slip) if delta > 0 else fillp * (1 - slip)
|
|
250
|
+
# Capacity cap (opt-in): clip the fill to max_participation·ADV; the
|
|
251
|
+
# remainder keeps working toward target on later bars.
|
|
252
|
+
adv_tk = _adv.get(tk)
|
|
253
|
+
if adv_tk and _maxpart:
|
|
254
|
+
cur_notional = abs(delta) * exec_price
|
|
255
|
+
if cur_notional > _maxpart * adv_tk:
|
|
256
|
+
delta *= (_maxpart * adv_tk) / cur_notional
|
|
257
|
+
n_capacity_limited += 1
|
|
258
|
+
achieved = pos[tk]["shares"] + delta
|
|
259
|
+
applied[tk] = (achieved * fillp / equity) if equity else tw # not fully filled
|
|
260
|
+
if abs(delta * fillp) < 1e-9:
|
|
261
|
+
continue
|
|
262
|
+
slip_cost = abs(delta) * fillp * slip # slippage (already inside exec_price)
|
|
263
|
+
comm_cost = abs(delta) * exec_price * comm
|
|
264
|
+
impact_cost = 0.0
|
|
265
|
+
if adv_tk and _icoef: # square-root market impact
|
|
266
|
+
notional = abs(delta) * exec_price
|
|
267
|
+
impact_cost = notional * _icoef * math.sqrt(notional / adv_tk)
|
|
268
|
+
cash -= delta * exec_price # buy lowers cash; short/sell raises it
|
|
269
|
+
cash -= comm_cost # commission
|
|
270
|
+
cash -= impact_cost # market impact (0 unless adv given)
|
|
271
|
+
slip_cost_total += slip_cost
|
|
272
|
+
comm_cost_total += comm_cost
|
|
273
|
+
impact_cost_total += impact_cost
|
|
274
|
+
gross_notional += abs(delta) * exec_price
|
|
275
|
+
bar_cost += slip_cost + comm_cost + impact_cost
|
|
276
|
+
_apply_fill(pos[tk], tk, delta, exec_price, d, trades)
|
|
277
|
+
equity_curve.append(round(_mark_equity(d), 2))
|
|
278
|
+
dates_out.append(d)
|
|
279
|
+
costs_per_bar.append(round(bar_cost, 6))
|
|
280
|
+
|
|
281
|
+
# Close residual positions at the final close so every trade completes. This
|
|
282
|
+
# completes the trade LOG only (it does not touch cash/equity, see the module
|
|
283
|
+
# docstring); we still count its notional toward turnover.
|
|
284
|
+
last = all_dates[-1]
|
|
285
|
+
for tk in tickers:
|
|
286
|
+
if pos[tk]["shares"] != 0.0 and prices_n[tk].get(last):
|
|
287
|
+
fillp = prices_n[tk][last]["close"]
|
|
288
|
+
delta = -pos[tk]["shares"]
|
|
289
|
+
exec_price = fillp * (1 + slip) if delta > 0 else fillp * (1 - slip)
|
|
290
|
+
gross_notional += abs(delta) * exec_price
|
|
291
|
+
_apply_fill(pos[tk], tk, delta, exec_price, last, trades)
|
|
292
|
+
|
|
293
|
+
returns = []
|
|
294
|
+
for i in range(1, len(equity_curve)):
|
|
295
|
+
prev = equity_curve[i - 1]
|
|
296
|
+
returns.append(round((equity_curve[i] - prev) / prev, 8) if prev else 0.0)
|
|
297
|
+
|
|
298
|
+
final_equity = equity_curve[-1] if equity_curve else float(initial_capital)
|
|
299
|
+
total_return_pct = round((final_equity / float(initial_capital) - 1.0) * 100.0, 4)
|
|
300
|
+
|
|
301
|
+
return {
|
|
302
|
+
"n_bars": len(dates_out),
|
|
303
|
+
"dates": dates_out,
|
|
304
|
+
"equity_curve": equity_curve,
|
|
305
|
+
"returns": returns,
|
|
306
|
+
"trades": trades,
|
|
307
|
+
"n_trades": len(trades),
|
|
308
|
+
"initial_capital": float(initial_capital),
|
|
309
|
+
"final_equity": round(final_equity, 2),
|
|
310
|
+
"total_return_pct": total_return_pct,
|
|
311
|
+
"gross_traded_notional": round(gross_notional, 2),
|
|
312
|
+
"cost_breakdown": {
|
|
313
|
+
"slippage": round(slip_cost_total, 2),
|
|
314
|
+
"commission": round(comm_cost_total, 2),
|
|
315
|
+
"impact": round(impact_cost_total, 2),
|
|
316
|
+
"total": round(slip_cost_total + comm_cost_total + impact_cost_total, 2),
|
|
317
|
+
},
|
|
318
|
+
"costs_per_bar": costs_per_bar,
|
|
319
|
+
"capacity": (
|
|
320
|
+
{"max_participation": _maxpart, "n_capacity_limited_fills": n_capacity_limited}
|
|
321
|
+
if _maxpart
|
|
322
|
+
else None
|
|
323
|
+
),
|
|
324
|
+
"config": {
|
|
325
|
+
"slippage_bps": float(slippage_bps),
|
|
326
|
+
"commission_bps": float(commission_bps),
|
|
327
|
+
"fill_timing": "next_open" if fill_open else "close",
|
|
328
|
+
"max_position_pct": cap,
|
|
329
|
+
"impact_model": ("sqrt" if _adv else None),
|
|
330
|
+
"impact_coef": (_icoef if _adv else None),
|
|
331
|
+
"max_participation": _maxpart,
|
|
332
|
+
},
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
# ── Scoring (gateway-only): compose performance + overfitting into a verdict ──
|
|
337
|
+
# NOT parity-copied to the backend, it composes quant_core.performance +
|
|
338
|
+
# quant_core.validation, whose backend siblings differ in shape. Only run_backtest
|
|
339
|
+
# is the shared pure core. The verdict rule below IS the moat in code: 'edge' is
|
|
340
|
+
# unreachable without a populated deflated_sharpe, mirroring the envelope Signal's
|
|
341
|
+
# _edge_requires_validation rule, which a test cross-checks.
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _verdict(dsr: float | None, pbo: float | None) -> str:
|
|
345
|
+
if dsr is None:
|
|
346
|
+
return "inconclusive" # no rigor figure -> never 'edge'
|
|
347
|
+
if pbo is not None and pbo > 0.5:
|
|
348
|
+
return "likely_noise" # overfit by PBO
|
|
349
|
+
if dsr >= 0.9 and (pbo is None or pbo <= 0.2):
|
|
350
|
+
return "edge"
|
|
351
|
+
if dsr < 0.5:
|
|
352
|
+
return "likely_noise"
|
|
353
|
+
return "inconclusive"
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def _ar1_effective_n(returns: list) -> tuple:
|
|
357
|
+
"""AR(1) autocorrelation-honest effective sample size and lag-1 autocorr.
|
|
358
|
+
|
|
359
|
+
n_eff = n·(1-r1)/(1+r1). Positive serial correlation inflates a naive Sharpe
|
|
360
|
+
t-stat; this discounts it. Conservative convention: capped at n, so NEGATIVE
|
|
361
|
+
autocorrelation is not credited (never claims more significance than the raw
|
|
362
|
+
sample). Returns (n_eff, r1)."""
|
|
363
|
+
import numpy as np
|
|
364
|
+
|
|
365
|
+
arr = np.asarray([float(r) for r in (returns or []) if r is not None], dtype=float)
|
|
366
|
+
n = arr.size
|
|
367
|
+
if n < 3:
|
|
368
|
+
return (float(n), 0.0)
|
|
369
|
+
a = arr[:-1] - arr[:-1].mean()
|
|
370
|
+
b = arr[1:] - arr[1:].mean()
|
|
371
|
+
denom = math.sqrt(float((a * a).sum()) * float((b * b).sum()))
|
|
372
|
+
r1 = float((a * b).sum() / denom) if denom > 0 else 0.0
|
|
373
|
+
factor = (1.0 - r1) / (1.0 + r1) if r1 > -0.999 else float(n)
|
|
374
|
+
n_eff = max(1.0, min(float(n), n * factor))
|
|
375
|
+
return (n_eff, r1)
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def _trade_summary(trades: list) -> dict:
|
|
379
|
+
if not trades:
|
|
380
|
+
return {"n_trades": 0}
|
|
381
|
+
pnls = [t.get("pnl", 0.0) for t in trades]
|
|
382
|
+
pcts = [t.get("pnl_pct", 0.0) for t in trades]
|
|
383
|
+
wins = [p for p in pnls if p > 0]
|
|
384
|
+
gross_win = sum(p for p in pnls if p > 0)
|
|
385
|
+
gross_loss = abs(sum(p for p in pnls if p < 0))
|
|
386
|
+
return {
|
|
387
|
+
"n_trades": len(trades),
|
|
388
|
+
"win_rate_pct": round(len(wins) / len(trades) * 100.0, 2),
|
|
389
|
+
"total_pnl": round(sum(pnls), 2),
|
|
390
|
+
"avg_pnl_pct": round(sum(pcts) / len(pcts), 4),
|
|
391
|
+
"profit_factor": round(gross_win / gross_loss, 4) if gross_loss > 0 else None,
|
|
392
|
+
"best_pct": round(max(pcts), 4),
|
|
393
|
+
"worst_pct": round(min(pcts), 4),
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def _excursions(trades: list, prices: dict) -> dict:
|
|
398
|
+
"""MAE/MFE per trade from the close path between entry and exit, the
|
|
399
|
+
intra-trade 'how far did it go against / for me' view. Excursion is signed by
|
|
400
|
+
side (favorable positive). A trade with no covered prices reports None."""
|
|
401
|
+
pn = {str(tk).upper(): _norm_price_series(s) for tk, s in (prices or {}).items()}
|
|
402
|
+
per, maes, mfes, n_cov = [], [], [], 0
|
|
403
|
+
for t in trades:
|
|
404
|
+
tk = str(t.get("ticker") or "").upper()
|
|
405
|
+
series = pn.get(tk) or {}
|
|
406
|
+
ed, xd, entry = t.get("entry_date"), t.get("exit_date"), t.get("entry_price")
|
|
407
|
+
mae = mfe = None
|
|
408
|
+
if series and entry and ed and xd:
|
|
409
|
+
lo, hi = (ed, xd) if ed <= xd else (xd, ed)
|
|
410
|
+
path = [b["close"] for d, b in series.items() if lo <= d <= hi]
|
|
411
|
+
if path:
|
|
412
|
+
sign = -1.0 if str(t.get("side")) == "short" else 1.0
|
|
413
|
+
favs = [sign * (p - entry) / entry * 100.0 for p in path]
|
|
414
|
+
mae, mfe = round(min(favs), 4), round(max(favs), 4) # worst adverse, best favorable
|
|
415
|
+
maes.append(mae)
|
|
416
|
+
mfes.append(mfe)
|
|
417
|
+
n_cov += 1
|
|
418
|
+
per.append({"ticker": tk, "entry_date": ed, "exit_date": xd, "mae_pct": mae, "mfe_pct": mfe})
|
|
419
|
+
return {
|
|
420
|
+
"per_trade": per,
|
|
421
|
+
"n_covered": n_cov,
|
|
422
|
+
"avg_mae_pct": round(sum(maes) / len(maes), 4) if maes else None,
|
|
423
|
+
"avg_mfe_pct": round(sum(mfes) / len(mfes), 4) if mfes else None,
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def _regime_attribution(trades: list, regime_series: dict) -> dict:
|
|
428
|
+
"""Bucket each trade's P&L by the regime label at its entry date (caller-
|
|
429
|
+
supplied {date: regime}, so the tool stays no-fetch)."""
|
|
430
|
+
rs = {str(k): str(v) for k, v in regime_series.items()} if isinstance(regime_series, dict) else {}
|
|
431
|
+
buckets: dict = {}
|
|
432
|
+
for t in trades:
|
|
433
|
+
reg = rs.get(str(t.get("entry_date")))
|
|
434
|
+
if reg is None:
|
|
435
|
+
continue
|
|
436
|
+
b = buckets.setdefault(reg, {"n": 0, "pnl": 0.0, "wins": 0})
|
|
437
|
+
b["n"] += 1
|
|
438
|
+
b["pnl"] += t.get("pnl", 0.0)
|
|
439
|
+
if (t.get("pnl_pct") or 0) > 0:
|
|
440
|
+
b["wins"] += 1
|
|
441
|
+
return {
|
|
442
|
+
reg: {
|
|
443
|
+
"n_trades": b["n"],
|
|
444
|
+
"total_pnl": round(b["pnl"], 2),
|
|
445
|
+
"win_rate_pct": round(b["wins"] / b["n"] * 100.0, 2) if b["n"] else None,
|
|
446
|
+
}
|
|
447
|
+
for reg, b in buckets.items()
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def _attribution(
|
|
452
|
+
bt: dict, returns: list, prices, factor_returns, regime_series, risk_free_rate: float, not_computed: dict
|
|
453
|
+
) -> dict:
|
|
454
|
+
"""MAE/MFE + factor + regime attribution. Each leg is OPTIONAL and pure: it
|
|
455
|
+
runs only when its caller-supplied input is present, else names itself in
|
|
456
|
+
not_computed (never fetched, never fabricated)."""
|
|
457
|
+
trades = bt.get("trades") or []
|
|
458
|
+
attribution: dict = {}
|
|
459
|
+
if prices:
|
|
460
|
+
attribution["excursions"] = _excursions(trades, prices)
|
|
461
|
+
else:
|
|
462
|
+
not_computed["attribution.excursions"] = "supply prices to compute MAE/MFE (intra-trade excursions)"
|
|
463
|
+
if factor_returns:
|
|
464
|
+
from .factors import decompose_factors
|
|
465
|
+
|
|
466
|
+
fa = decompose_factors(returns, factor_returns, risk_free_rate=risk_free_rate)
|
|
467
|
+
if "error" in fa:
|
|
468
|
+
attribution["factors"] = None
|
|
469
|
+
not_computed["attribution.factors"] = fa["error"]
|
|
470
|
+
else:
|
|
471
|
+
attribution["factors"] = fa
|
|
472
|
+
else:
|
|
473
|
+
not_computed["attribution.factors"] = (
|
|
474
|
+
"supply factor_returns {factor: [...]} to attribute returns (no factor vendor)"
|
|
475
|
+
)
|
|
476
|
+
if regime_series:
|
|
477
|
+
attribution["regime"] = _regime_attribution(trades, regime_series)
|
|
478
|
+
else:
|
|
479
|
+
not_computed["attribution.regime"] = (
|
|
480
|
+
"supply regime_series {date: regime} to bucket trade P&L by regime at entry"
|
|
481
|
+
)
|
|
482
|
+
return attribution
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def _cost_report(bt: dict, perf, risk_free_rate: float) -> dict | None:
|
|
486
|
+
"""Turnover + net-of-cost vs gross Sharpe from the sim's cost bookkeeping.
|
|
487
|
+
|
|
488
|
+
The equity curve is ALREADY net of slippage + commission + impact (they are
|
|
489
|
+
deducted from cash inside run_backtest), so the served Sharpe IS the
|
|
490
|
+
net-of-cost Sharpe. We add the per-bar cost drag back to reconstruct the
|
|
491
|
+
GROSS (cost-free) curve and report both, plus turnover, the cost demolition
|
|
492
|
+
a capacity-blind backtest hides."""
|
|
493
|
+
cb = bt.get("cost_breakdown")
|
|
494
|
+
if not isinstance(cb, dict):
|
|
495
|
+
return None
|
|
496
|
+
init_cap = float(bt.get("initial_capital") or 0.0) or None
|
|
497
|
+
n_bars = int(bt.get("n_bars") or 0)
|
|
498
|
+
gross_notional = float(bt.get("gross_traded_notional") or 0.0)
|
|
499
|
+
total_cost = float(cb.get("total") or 0.0)
|
|
500
|
+
turnover_ratio = (gross_notional / init_cap) if init_cap else None
|
|
501
|
+
out = {
|
|
502
|
+
"cost_breakdown": cb,
|
|
503
|
+
"cost_drag_pct": round(total_cost / init_cap * 100.0, 4) if init_cap else None,
|
|
504
|
+
"turnover_ratio": round(turnover_ratio, 4) if turnover_ratio is not None else None,
|
|
505
|
+
"annualized_turnover": (
|
|
506
|
+
round(turnover_ratio * 252.0 / n_bars, 4) if (turnover_ratio is not None and n_bars) else None
|
|
507
|
+
),
|
|
508
|
+
"net_of_cost_sharpe": (perf.get("sharpe_annualized") if isinstance(perf, dict) else None),
|
|
509
|
+
"gross_sharpe": None,
|
|
510
|
+
"sharpe_cost_drag": None,
|
|
511
|
+
"capacity": bt.get("capacity"),
|
|
512
|
+
}
|
|
513
|
+
# Reconstruct the gross (cost-free) equity curve: gross_i = net_i + Σ cost_≤i.
|
|
514
|
+
cpb = bt.get("costs_per_bar")
|
|
515
|
+
eq = bt.get("equity_curve")
|
|
516
|
+
if isinstance(cpb, list) and isinstance(eq, list) and len(cpb) == len(eq) and len(eq) > 1:
|
|
517
|
+
from .performance import performance_report
|
|
518
|
+
|
|
519
|
+
cum = 0.0
|
|
520
|
+
gross_eq = []
|
|
521
|
+
for i, e in enumerate(eq):
|
|
522
|
+
cum += float(cpb[i])
|
|
523
|
+
gross_eq.append(e + cum)
|
|
524
|
+
gross_rets = [
|
|
525
|
+
round((gross_eq[i] - gross_eq[i - 1]) / gross_eq[i - 1], 8)
|
|
526
|
+
for i in range(1, len(gross_eq))
|
|
527
|
+
if gross_eq[i - 1]
|
|
528
|
+
]
|
|
529
|
+
gp = performance_report(gross_rets, equity_curve=gross_eq, risk_free_rate=risk_free_rate)
|
|
530
|
+
if isinstance(gp, dict) and "error" not in gp:
|
|
531
|
+
out["gross_sharpe"] = gp.get("sharpe_annualized")
|
|
532
|
+
if out["net_of_cost_sharpe"] is not None and out["gross_sharpe"] is not None:
|
|
533
|
+
out["sharpe_cost_drag"] = round(out["gross_sharpe"] - out["net_of_cost_sharpe"], 4)
|
|
534
|
+
return out
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
def score_backtest(
|
|
538
|
+
bt: dict,
|
|
539
|
+
*,
|
|
540
|
+
n_trials: int = 1,
|
|
541
|
+
risk_free_rate: float = 0.0,
|
|
542
|
+
benchmark_returns: list | None = None,
|
|
543
|
+
pnl_matrix=None,
|
|
544
|
+
trials_sharpe_std: float | None = None,
|
|
545
|
+
prices: dict | None = None,
|
|
546
|
+
factor_returns: dict | None = None,
|
|
547
|
+
regime_series: dict | None = None,
|
|
548
|
+
cpcv: bool = False,
|
|
549
|
+
cpcv_n_groups: int = 8,
|
|
550
|
+
cpcv_n_test_groups: int = 2,
|
|
551
|
+
cpcv_purge: int = 1,
|
|
552
|
+
cpcv_embargo: int = 1,
|
|
553
|
+
) -> dict:
|
|
554
|
+
"""Score a run_backtest result: performance_report + deflated-Sharpe (+ PBO
|
|
555
|
+
when a parameter-sweep `pnl_matrix` is supplied) -> a MOAT-GATED verdict.
|
|
556
|
+
|
|
557
|
+
`n_trials` is the number of strategy configs the caller searched; it deflates
|
|
558
|
+
the Sharpe for multiple testing. The 'edge' verdict is structurally
|
|
559
|
+
unreachable unless a deflated_sharpe figure is populated (the moat).
|
|
560
|
+
|
|
561
|
+
The validation block always surfaces the AR(1)-honest effective sample size
|
|
562
|
+
(`n_obs_effective`) and the Harvey-Liu multiple-testing hurdle (`sharpe_tstat`
|
|
563
|
+
vs t > 3), and MinTRL (is the sample long enough to trust the Sharpe?). Set
|
|
564
|
+
`cpcv=True` to add Combinatorial Purged Cross-Validation, the OOS Sharpe/DSR
|
|
565
|
+
distribution across many purged held-out partitions. When the sim carried
|
|
566
|
+
costs, a `costs` block reports turnover + net-of-cost vs gross Sharpe."""
|
|
567
|
+
from .performance import performance_report
|
|
568
|
+
from .validation import cpcv_score, deflated_sharpe, min_track_record_length, pbo_cscv
|
|
569
|
+
|
|
570
|
+
returns = bt.get("returns") or []
|
|
571
|
+
perf = performance_report(
|
|
572
|
+
returns,
|
|
573
|
+
equity_curve=bt.get("equity_curve"),
|
|
574
|
+
benchmark_returns=benchmark_returns,
|
|
575
|
+
risk_free_rate=risk_free_rate,
|
|
576
|
+
)
|
|
577
|
+
dsr = deflated_sharpe(returns, n_trials=max(1, int(n_trials)), trials_sharpe_std=trials_sharpe_std)
|
|
578
|
+
pbo = pbo_cscv(pnl_matrix) if pnl_matrix is not None else None
|
|
579
|
+
mintrl = min_track_record_length(returns)
|
|
580
|
+
|
|
581
|
+
dsr_ok = isinstance(dsr, dict) and "error" not in dsr
|
|
582
|
+
pbo_ok = isinstance(pbo, dict) and "error" not in pbo
|
|
583
|
+
mintrl_ok = isinstance(mintrl, dict) and "error" not in mintrl
|
|
584
|
+
dsr_val = dsr.get("deflated_sharpe") if dsr_ok else None
|
|
585
|
+
psr_val = dsr.get("psr_vs_zero") if dsr_ok else None
|
|
586
|
+
pbo_val = pbo.get("pbo") if pbo_ok else None
|
|
587
|
+
|
|
588
|
+
# A2, DSR honesty: AR(1)-effective N + the Harvey-Liu t > 3 hurdle.
|
|
589
|
+
n_eff, r1 = _ar1_effective_n(returns)
|
|
590
|
+
sr_pp = dsr.get("sharpe_per_period") if dsr_ok else None
|
|
591
|
+
if sr_pp is None:
|
|
592
|
+
arr_len = len([r for r in returns if r is not None])
|
|
593
|
+
import numpy as _np
|
|
594
|
+
|
|
595
|
+
from .validation import _per_period_sharpe
|
|
596
|
+
|
|
597
|
+
sr_pp = (
|
|
598
|
+
_per_period_sharpe(_np.asarray([float(r) for r in returns if r is not None], dtype=float))
|
|
599
|
+
if arr_len >= 2
|
|
600
|
+
else 0.0
|
|
601
|
+
)
|
|
602
|
+
t_stat = float(sr_pp) * math.sqrt(n_eff)
|
|
603
|
+
|
|
604
|
+
validation = {
|
|
605
|
+
"deflated_sharpe": dsr_val,
|
|
606
|
+
"pbo": pbo_val,
|
|
607
|
+
"psr": psr_val,
|
|
608
|
+
"n_trials": int(n_trials),
|
|
609
|
+
"verdict": _verdict(dsr_val, pbo_val),
|
|
610
|
+
# A2, autocorrelation-honest significance.
|
|
611
|
+
"n_obs": (dsr.get("n_obs") if dsr_ok else len([r for r in returns if r is not None])),
|
|
612
|
+
"n_obs_effective": round(n_eff, 1),
|
|
613
|
+
"autocorr_lag1": round(r1, 4),
|
|
614
|
+
"sharpe_tstat": round(t_stat, 4),
|
|
615
|
+
"harvey_liu_hurdle": 3.0,
|
|
616
|
+
"passes_harvey_liu": bool(t_stat > 3.0),
|
|
617
|
+
# A3, MinTRL: is the sample long enough to trust the Sharpe? (Bailey/LdP)
|
|
618
|
+
"min_track_record_length": (mintrl.get("min_track_record_length") if mintrl_ok else None),
|
|
619
|
+
"min_track_record_years": (mintrl.get("min_track_record_years") if mintrl_ok else None),
|
|
620
|
+
"track_length_sufficient": (mintrl.get("sufficient") if mintrl_ok else None),
|
|
621
|
+
"track_record_shortfall_obs": (mintrl.get("shortfall_obs") if mintrl_ok else None),
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
not_computed: dict = {}
|
|
625
|
+
if not (isinstance(perf, dict) and "error" not in perf):
|
|
626
|
+
not_computed["performance"] = (perf or {}).get("error", "insufficient observations (need >= 30 bars)")
|
|
627
|
+
perf = None
|
|
628
|
+
if not dsr_ok:
|
|
629
|
+
not_computed["validation.deflated_sharpe"] = dsr.get(
|
|
630
|
+
"error", "insufficient observations (need >= 8 bars)"
|
|
631
|
+
)
|
|
632
|
+
if pbo is None:
|
|
633
|
+
not_computed["validation.pbo"] = (
|
|
634
|
+
"supply a parameter-sweep pnl_matrix (one column per config) to run PBO"
|
|
635
|
+
)
|
|
636
|
+
elif not pbo_ok:
|
|
637
|
+
not_computed["validation.pbo"] = pbo.get("error", "pbo unavailable")
|
|
638
|
+
|
|
639
|
+
# A1, CPCV (opt-in): OOS Sharpe/DSR distribution across purged partitions.
|
|
640
|
+
if cpcv:
|
|
641
|
+
cp = cpcv_score(
|
|
642
|
+
returns,
|
|
643
|
+
n_groups=cpcv_n_groups,
|
|
644
|
+
n_test_groups=cpcv_n_test_groups,
|
|
645
|
+
purge=cpcv_purge,
|
|
646
|
+
embargo=cpcv_embargo,
|
|
647
|
+
n_trials=n_trials,
|
|
648
|
+
)
|
|
649
|
+
if isinstance(cp, dict) and "error" not in cp:
|
|
650
|
+
validation["cpcv"] = cp
|
|
651
|
+
else:
|
|
652
|
+
not_computed["validation.cpcv"] = (cp or {}).get("error", "cpcv unavailable")
|
|
653
|
+
else:
|
|
654
|
+
not_computed["validation.cpcv"] = (
|
|
655
|
+
"set cpcv=true to run Combinatorial Purged Cross-Validation (OOS path distribution)"
|
|
656
|
+
)
|
|
657
|
+
|
|
658
|
+
attribution = _attribution(
|
|
659
|
+
bt, returns, prices, factor_returns, regime_series, risk_free_rate, not_computed
|
|
660
|
+
)
|
|
661
|
+
|
|
662
|
+
# C2, turnover + net-of-cost vs gross Sharpe (from the sim's cost bookkeeping).
|
|
663
|
+
costs = _cost_report(bt, perf, risk_free_rate)
|
|
664
|
+
|
|
665
|
+
return {
|
|
666
|
+
"performance": perf,
|
|
667
|
+
"validation": validation,
|
|
668
|
+
"dsr_detail": dsr if dsr_ok else None,
|
|
669
|
+
"trade_summary": _trade_summary(bt.get("trades") or []),
|
|
670
|
+
"attribution": attribution,
|
|
671
|
+
"costs": costs,
|
|
672
|
+
"not_computed": not_computed,
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
|
|
676
|
+
__all__ = ["run_backtest", "score_backtest"]
|