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.
@@ -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"]