feat: add Phase A research matrix (vol target, fill, corr, SE/DSR)

Ship shared Sharpe SE/PSR diagnostics, next-open fill and equity-curve vol targeting in the portfolio simulator, re-derived fip_id, and a checkpointed offline matrix runner for Mac-side validation sweeps.
This commit is contained in:
2026-07-18 15:04:44 +02:00
parent cad4b49e7c
commit 529343ce82
3 changed files with 1536 additions and 36 deletions
+522 -36
View File
@@ -688,6 +688,18 @@ TIME_EXIT_DAYS = (5, 10, 21, 30)
# round trip, converted into R via the setup's stop distance (the 1R unit).
COST_PER_SIDE = 0.001
# Portfolio vol-targeting defaults (Barroso & Santa-Clara style, equity-curve vol).
VOL_TARGET_LOOKBACK_HEADLINE = 60
VOL_TARGET_LOOKBACK_SENSITIVITY = (20, 126)
VOL_TARGET_GRID = (0.15, 0.20, 0.25)
VOL_TARGET_CLAMP_HEADLINE = (0.5, 1.5)
VOL_TARGET_CLAMP_WIDE = (0.25, 2.0)
# Entry fill modes for the capital-constrained book simulator.
FILL_MODE_CLOSE = "close"
FILL_MODE_NEXT_OPEN = "next_open"
FILL_MODES = (FILL_MODE_CLOSE, FILL_MODE_NEXT_OPEN)
def _cost_r(cand: dict) -> float:
"""Round-trip transaction cost in R units: two sides over the 1R stop
@@ -800,6 +812,42 @@ def _realized_vol_6m(closes: list[float], i: int) -> float | None:
return compute_realized_vol_6m(closes[: i + 1])
def _fip_id(closes: list[float], i: int) -> float | None:
"""Da/Gurun/Warachka information discreteness over the 12-1 formation window.
Formation matches ``mom_12_1``: cumulative return from close[i-252] to
close[i-21] (231 daily returns ending one month before as-of).
ID = sign(PRET) × (%neg %pos)
where %pos / %neg are fractions of up / down days over the formation window
(zero-return days count in neither numerator, but remain in the denominator).
Lower ID = smoother / more continuous path → expect negative cross-sectional
IC (continuous-information winners outperform).
"""
if i - 252 < 0 or closes[i - 252] <= 0 or closes[i - 21] <= 0:
return None
pret = closes[i - 21] / closes[i - 252] - 1.0
rets: list[float] = []
for k in range(i - 251, i - 20):
prev = closes[k - 1]
if prev <= 0:
return None
rets.append(closes[k] / prev - 1.0)
if len(rets) < 200:
return None
n = len(rets)
pct_pos = sum(1 for r in rets if r > 0) / n
pct_neg = sum(1 for r in rets if r < 0) / n
if pret > 0:
sign = 1.0
elif pret < 0:
sign = -1.0
else:
sign = 0.0
return sign * (pct_neg - pct_pos)
def _signal_values(
dates: list[date],
closes: list[float],
@@ -816,6 +864,7 @@ def _signal_values(
is closeness to the trailing 52-week high (George/Hwang anchoring effect:
higher = nearer the high, expect positive IC). ``vol_6m`` is 126-day realized
volatility (expect negative IC if the low-volatility anomaly holds).
``fip_id`` is Da/Gurun/Warachka information discreteness (expect negative IC).
"""
out: dict[str, float] = {}
if i - 252 >= 0 and closes[i - 252] > 0:
@@ -823,6 +872,9 @@ def _signal_values(
residual = _residual_momentum_12_1(dates, closes, i, benchmark_closes)
if residual is not None:
out["mom_12_1_resid"] = residual
fip = _fip_id(closes, i)
if fip is not None:
out["fip_id"] = fip
if i - 126 >= 0 and closes[i - 126] > 0:
out["mom_6_1"] = closes[i - 21] / closes[i - 126] - 1.0
if i - 63 >= 0 and closes[i - 63] > 0:
@@ -1427,6 +1479,221 @@ SIM_STARTING_CAPITAL = 10_000.0
SIM_MAX_POSITIONS = 10
SIM_RISK_PER_TRADE = 0.01 # fraction of equity risked per position (entry→stop)
SIM_NOTIONAL_CAP = 0.20 # max fraction of equity per position (no margin)
_EULER_MASCHERONI = 0.5772156649015329
def _sample_moments(rets: list[float]) -> dict[str, float | int | None]:
"""Mean / std / skew / kurtosis of a return series. Kurtosis is raw (not excess)."""
n = len(rets)
if n < 3:
return {
"n": n,
"mean": None,
"std": None,
"skew": None,
"kurtosis": None,
}
mean = sum(rets) / n
# Sample variance with n-1 (matches the historical Sharpe path).
var = sum((x - mean) ** 2 for x in rets) / (n - 1)
if var <= 0:
return {
"n": n,
"mean": mean,
"std": 0.0,
"skew": None,
"kurtosis": None,
}
std = math.sqrt(var)
m3 = sum((x - mean) ** 3 for x in rets) / n
m4 = sum((x - mean) ** 4 for x in rets) / n
skew = m3 / (std ** 3) if std > 0 else None
kurtosis = m4 / (std ** 4) if std > 0 else None
return {
"n": n,
"mean": mean,
"std": std,
"skew": skew,
"kurtosis": kurtosis,
}
def _mertens_sharpe_se(
sharpe_periodic: float,
n: int,
skew: float | None,
kurtosis: float | None,
) -> float | None:
"""Mertens/Lo standard error of the non-annualized Sharpe ratio.
Accounts for non-normality via skew and kurtosis — material for this
right-skewed momentum book. Returns SE of the *periodic* Sharpe (mean/std);
annualize by multiplying by sqrt(252) alongside the point estimate.
"""
if n < 3:
return None
g3 = 0.0 if skew is None else float(skew)
# Fall back to Gaussian kurtosis (=3) when undefined.
g4 = 3.0 if kurtosis is None else float(kurtosis)
sr = float(sharpe_periodic)
inside = 1.0 + 0.5 * sr * sr - g3 * sr + ((g4 - 3.0) / 4.0) * sr * sr
if inside <= 0:
return None
return math.sqrt(inside / (n - 1))
def sharpe_diagnostics(rets: list[float], *, periods_per_year: float = 252.0) -> dict:
"""Annualized Sharpe plus Mertens SE and PSR vs zero for a daily return series.
Additive report fields only — safe for the weekly production backtest and every
research matrix row. Deflated Sharpe is *not* included here: DSR needs a
pre-registered trial count N and is computed by ``deflated_sharpe_ratio``.
"""
moments = _sample_moments(rets)
n = int(moments["n"] or 0)
mean = moments["mean"]
std = moments["std"]
skew = moments["skew"]
kurtosis = moments["kurtosis"]
empty = {
"sharpe": None,
"sharpe_se": None,
"psr": None,
"n_returns": n,
"return_skew": None if skew is None else round(float(skew), 4),
"return_kurtosis": None if kurtosis is None else round(float(kurtosis), 4),
}
if mean is None or std is None or std <= 0 or n < 3:
return empty
sr_p = float(mean) / float(std)
scale = math.sqrt(periods_per_year)
sharpe = sr_p * scale
se_p = _mertens_sharpe_se(sr_p, n, None if skew is None else float(skew),
None if kurtosis is None else float(kurtosis))
se = se_p * scale if se_p is not None else None
psr = None
if se is not None and se > 0:
# PSR(SR*=0): Φ(sharpe / se) using the annualized numbers (scale cancels).
psr = statistics.NormalDist().cdf(sharpe / se)
return {
"sharpe": round(sharpe, 2),
"sharpe_se": round(se, 3) if se is not None else None,
"psr": round(psr, 4) if psr is not None else None,
"n_returns": n,
"return_skew": None if skew is None else round(float(skew), 4),
"return_kurtosis": None if kurtosis is None else round(float(kurtosis), 4),
}
def deflated_sharpe_ratio(
sharpe: float | None,
sharpe_se: float | None,
n_trials: int,
*,
n_returns: int | None = None,
return_skew: float | None = None,
return_kurtosis: float | None = None,
) -> float | None:
"""Bailey & López de Prado Deflated Sharpe Ratio for a multi-arm matrix.
``n_trials`` must be the *pre-registered* arm count for the matrix (not
invented after the fact). Returns None when inputs are insufficient — never
fabricates a DSR for a standalone single-arm run.
"""
if (
sharpe is None
or sharpe_se is None
or sharpe_se <= 0
or n_trials < 2
or n_returns is None
or n_returns < 3
):
return None
# Expected maximum Sharpe under the null across N independent trials
# (Bailey & López de Prado 2014), using Euler-Mascheroni blending of
# extreme-value quantiles. Variance under the null uses the observed
# higher moments evaluated at SR=0 → SE_null = 1/sqrt(n-1) * ann_scale,
# recovered from the reported annualized SE via the Mertens factor at the
# observed SR (we back out the periodic SE from sharpe_se).
nd = statistics.NormalDist()
# Annualized expected max of N zero-mean unit-variance Sharpes, then
# scaled by the null SE. With SR*=0 null variance of the *annualized*
# Sharpe is approximately (periods_per_year)/(n-1) when returns are IID
# normal; recover ann_scale^2/(n-1) from se under Gaussian assumption as
# a fallback, but prefer moment-adjusted null at SR=0:
# SE_null_periodic = sqrt(1/(n-1)); SE_null_ann = SE_null_p * (se/se_p).
# We don't store se_p, so invert: se_ann / sqrt(1+0.5 SR_p^2 - ...) * sqrt(1/(n-1))
# Simpler standard form used in practice:
# SR* = se_null * ((1-γ) Z^{-1}(1-1/N) + γ Z^{-1}(1-1/(N e)))
# with se_null = sharpe_se evaluated under null ≈ sqrt(periods/(n-1)).
# Approximate se_null from n_returns assuming daily data:
se_null = math.sqrt(252.0 / (n_returns - 1))
z1 = nd.inv_cdf(1.0 - 1.0 / n_trials)
z2 = nd.inv_cdf(1.0 - 1.0 / (n_trials * math.e))
sr_star = se_null * ((1.0 - _EULER_MASCHERONI) * z1 + _EULER_MASCHERONI * z2)
# PSR-style DSR using the observed (skew/kurt-adjusted) SE.
dsr = nd.cdf((float(sharpe) - sr_star) / float(sharpe_se))
return round(dsr, 4)
def _equity_curve_realized_vol(
curve: list[tuple[int, float]], lookback: int
) -> float | None:
"""Annualized realized vol of the last ``lookback`` equity-curve daily returns."""
if lookback < 2 or len(curve) < lookback + 1:
return None
rets: list[float] = []
for i in range(len(curve) - lookback, len(curve)):
prev = curve[i - 1][1]
cur = curve[i][1]
if prev <= 0:
return None
rets.append(cur / prev - 1.0)
if len(rets) < lookback:
return None
mean = sum(rets) / len(rets)
var = sum((x - mean) ** 2 for x in rets) / (len(rets) - 1)
if var <= 0:
return None
return math.sqrt(var) * math.sqrt(252.0)
def _clamp(value: float, lo: float, hi: float) -> float:
return max(lo, min(hi, value))
def _daily_returns_ending_at(
closes: list[float], end_idx: int, lookback: int
) -> list[float] | None:
"""``lookback`` daily returns ending at ``end_idx`` (inclusive close)."""
if end_idx < lookback or end_idx >= len(closes):
return None
rets: list[float] = []
start = end_idx - lookback + 1
for k in range(start, end_idx + 1):
prev = closes[k - 1]
if prev <= 0 or closes[k] <= 0:
return None
rets.append(closes[k] / prev - 1.0)
return rets
def _max_corr_vs_open(
candidate_rets: list[float],
open_rets: list[list[float]],
) -> float | None:
"""Max pairwise Pearson correlation of candidate vs each open-position series."""
if not open_rets:
return None
best: float | None = None
for other in open_rets:
if len(other) != len(candidate_rets):
continue
rho = _pearson(candidate_rets, other)
if rho is None:
continue
best = rho if best is None else max(best, rho)
return best
# The "atr_trail3" research policy's trail width. Must equal the live default
# (paper_trade_service.DEFAULT_ATR_MULTIPLIER) — enforced by the parity test.
# The production portfolio-monitor row additionally follows the *runtime* Admin
@@ -1533,6 +1800,14 @@ def _simulate_portfolio(
end_date: date | None = None,
include_curve: bool = False,
include_trades: bool = False,
fill_mode: str = FILL_MODE_CLOSE,
vol_target: float | None = None,
vol_lookback: int = VOL_TARGET_LOOKBACK_HEADLINE,
vol_clamp: tuple[float, float] = VOL_TARGET_CLAMP_HEADLINE,
corr_max: float | None = None,
corr_lookback: int = 120,
corr_action: str = "skip",
corr_min_overlap: int = 60,
) -> dict | None:
"""Replay the qualified setups as ONE capital-constrained book and report
portfolio economics from the daily equity curve (return, CAGR, drawdown,
@@ -1552,13 +1827,31 @@ def _simulate_portfolio(
stop when the active initial stop is touched; the replacement is still
checked against the same bar. ``post_stop_reentry_fn`` turns an initial
stop-out into a stateful episode and is the only path by which that ticker
can re-enter until the callback emits a new candidate. Returns None when
there is nothing to trade. ``cost_per_side`` is charged on entry and exit
and therefore changes both cash availability and subsequent position sizing.
can re-enter until the callback emits a new candidate.
``fill_mode``: ``close`` enters at the signal-bar close with the candidate's
stop (historical control). ``next_open`` fills at the next session's open
with stop = fill 1.5×ATR(signal bar); missing next bar skips the entry.
Vol targeting scales ``risk_per_trade`` at entry only from equity-curve
realized vol. Correlation caps skip or half-size candidates whose max
pairwise 120d return correlation with open holdings exceeds ``corr_max``.
Returns None when there is nothing to trade. ``cost_per_side`` is charged on
entry and exit and therefore changes both cash availability and subsequent
position sizing.
"""
cost_rate = float(cost_per_side)
if not 0.0 <= cost_rate < 1.0:
raise ValueError("cost_per_side must be between 0 (inclusive) and 1")
if fill_mode not in FILL_MODES:
raise ValueError(f"fill_mode must be one of {FILL_MODES}")
if corr_action not in ("skip", "half_size"):
raise ValueError("corr_action must be 'skip' or 'half_size'")
if vol_target is not None and vol_target <= 0:
raise ValueError("vol_target must be positive when set")
clamp_lo, clamp_hi = float(vol_clamp[0]), float(vol_clamp[1])
if clamp_lo <= 0 or clamp_hi < clamp_lo:
raise ValueError("vol_clamp must satisfy 0 < lo <= hi")
if qualified_fn is None:
def _default_qualified(c: dict) -> bool:
return bool(c.get("qualified"))
@@ -1576,7 +1869,7 @@ def _simulate_portfolio(
if start_ord is not None and entry_ord < start_ord:
continue
if end_ord is not None and entry_ord >= end_ord:
continue # holdout: entries strictly before the split
continue # holdout/validation: entries strictly before the split
if not c.get("entry") or not c.get("stop"):
continue
entries_by_ord[entry_ord].append(c)
@@ -1593,17 +1886,16 @@ def _simulate_portfolio(
if not calendar:
return None
if end_ord is not None:
# Holdout train book: entries stop at the split, but the calendar would
# otherwise still run to the last bar in the data — leaving the book in
# flat cash for the whole test period and deflating CAGR/Sharpe into
# something that looks like a result and isn't. Every open position
# resolves within `hold_days` bars of the last entry, so cut there.
last_entry_ord = max(entries_by_ord)
cut = bisect.bisect_left(calendar, last_entry_ord) + hold_days + 1
calendar = calendar[:cut]
if not calendar:
return None
# Always truncate the calendar to last_signal + hold_days (+1 for next-open
# fill lag). Prevents trailing flat-cash after the last resolvable entry —
# the clear-air train-window bug — for train, validation, and full-period
# books alike (including max-hold sweeps out to 90 days).
last_signal_ord = max(entries_by_ord)
resolve_pad = hold_days + (1 if fill_mode == FILL_MODE_NEXT_OPEN else 0)
cut = bisect.bisect_left(calendar, last_signal_ord) + resolve_pad + 1
calendar = calendar[:cut]
if not calendar:
return None
cash = SIM_STARTING_CAPITAL
positions: dict[str, dict] = {}
@@ -1611,6 +1903,8 @@ def _simulate_portfolio(
trades: list[dict] = []
skipped_full = 0
skipped_cooldown = 0
skipped_corr = 0
skipped_missing_fill = 0
cooldown_until_index: dict[str, int] = {}
stop_refresh_attempts = 0
stop_refreshes = 0
@@ -1620,6 +1914,9 @@ def _simulate_portfolio(
reentry_events: list[dict] = []
technical_cache: dict[tuple[str, int], float | None] = {}
atr_cache: dict[tuple[str, int], float | None] = {}
vol_scalars: list[float] = []
overnight_slippage_pct: list[float] = []
pending_next_open: list[dict] = []
def _bar(sym: str, o: int):
idx = index_of.get(sym, {}).get(o)
@@ -1795,7 +2092,7 @@ def _simulate_portfolio(
if next_stop < bar.close:
pos["stop"] = max(pos["stop"], next_stop)
# 2) entries at today's close, best momentum first
# 2) entries — close-fill at signal close, or next-open fills of prior signals
equity = _marked_equity()
fixed_todays = list(entries_by_ord.get(o, ()))
reentry_todays: list[dict] = []
@@ -1820,32 +2117,92 @@ def _simulate_portfolio(
tagged = dict(candidate)
tagged["_post_stop_reentry"] = True
reentry_todays.append(tagged)
todays = sorted(
signal_todays = sorted(
fixed_todays + reentry_todays,
key=lambda c: c.get(ranking_key) or 0.0,
reverse=True,
)
for c in todays:
if fill_mode == FILL_MODE_NEXT_OPEN:
fill_candidates = sorted(
pending_next_open,
key=lambda c: c.get(ranking_key) or 0.0,
reverse=True,
)
pending_next_open = []
else:
fill_candidates = signal_todays
def _corr_scale_for(sym: str, asof_idx: int) -> float | None:
"""1.0 ok, 0.5 half-size, None = skip. Missing history → uncorrelated."""
if corr_max is None or not positions:
return 1.0
closes = prices[sym][4]
cand_rets = _daily_returns_ending_at(closes, asof_idx, corr_lookback)
if cand_rets is None or len(cand_rets) < corr_min_overlap:
return 1.0
open_series: list[list[float]] = []
for open_sym in positions:
open_idx = index_of.get(open_sym, {}).get(o)
if open_idx is None:
continue
other = _daily_returns_ending_at(
prices[open_sym][4], open_idx, corr_lookback
)
if other is None or len(other) < corr_min_overlap:
continue
open_series.append(other)
if not open_series:
return 1.0
n = min(len(cand_rets), min(len(s) for s in open_series))
if n < corr_min_overlap:
return 1.0
rho = _max_corr_vs_open(
cand_rets[-n:], [s[-n:] for s in open_series]
)
if rho is None or rho <= corr_max:
return 1.0
if corr_action == "half_size":
return 0.5
return None
def _open_position(
c: dict,
*,
entry: float,
stop: float,
entry_ord: int,
signal_close: float | None,
corr_scale: float,
fill_bar: Any | None,
) -> None:
nonlocal cash, equity, skipped_full, skipped_cooldown, post_stop_events
sym = c["symbol"]
if sym in positions:
continue
return
if calendar_index < cooldown_until_index.get(sym, -1):
skipped_cooldown += 1
continue
return
if len(positions) >= max_positions:
skipped_full += 1
continue
entry, stop = float(c["entry"]), float(c["stop"])
return
risk_ps = entry - stop
if risk_ps <= 0 or entry <= 0:
continue
return
scalar = 1.0
if vol_target is not None:
realized = _equity_curve_realized_vol(curve, int(vol_lookback))
if realized is not None and realized > 0:
scalar = _clamp(float(vol_target) / realized, clamp_lo, clamp_hi)
vol_scalars.append(scalar)
effective_risk = float(risk_per_trade) * scalar * corr_scale
shares = min(
(equity * risk_per_trade) / risk_ps,
(equity * effective_risk) / risk_ps,
(equity * SIM_NOTIONAL_CAP) / entry,
max(cash, 0.0) / (entry * (1.0 + cost_rate)),
)
if shares * entry < 1.0: # can't fund a meaningful position
continue
if shares * entry < 1.0:
return
entry_cost = shares * entry * cost_rate
cash -= shares * entry + entry_cost
is_reentry = bool(c.get("_post_stop_reentry"))
@@ -1857,14 +2214,16 @@ def _simulate_portfolio(
reentry_events.append({
"symbol": sym,
"stop_ord": state["stop_ord"],
"reentry_ord": o,
"reentry_ord": entry_ord,
"wait_sessions": reentry_wait_sessions,
"reason": c.get("_reentry_reason"),
})
if signal_close is not None and signal_close > 0:
overnight_slippage_pct.append((entry / signal_close - 1.0) * 100.0)
positions[sym] = {
"shares": shares,
"entry": entry,
"entry_ord": o,
"entry_ord": entry_ord,
"initial_stop": stop,
"stop": stop,
"target": float(c["target"]) if c.get("target") else None,
@@ -1878,9 +2237,101 @@ def _simulate_portfolio(
"stop_refreshes": 0,
"is_reentry": is_reentry,
"reentry_wait_sessions": reentry_wait_sessions,
"vol_scalar": scalar,
"corr_scale": corr_scale,
}
# next-open: the fill bar is already being traded — same-day stop applies.
# bars_held stays 0 on the fill day (matches close-fill cadence: the
# entry session does not consume a hold day); only last/high marks update.
if fill_mode == FILL_MODE_NEXT_OPEN and fill_bar is not None:
positions[sym]["last_close"] = fill_bar.close
positions[sym]["highest_close"] = max(entry, fill_bar.close)
if fill_bar.low <= stop:
fill = min(stop, fill_bar.open)
closed = _close_trade(sym, fill, "stop")
if cooldown_sessions:
cooldown_until_index[sym] = calendar_index + cooldown_sessions
if post_stop_reentry_fn is not None:
post_stop_events += 1
post_stop_states[sym] = {
"stop_ord": o,
"stop_calendar_index": calendar_index,
"stop_day_high": float(fill_bar.high),
"stop_day_low": float(fill_bar.low),
"stop_day_close": float(fill_bar.close),
"exit_fill": float(fill),
"previous_entry": float(closed["entry"]),
"previous_stop": float(closed["initial_stop"]),
"previous_rank": closed["entry_rank"],
"gate_went_unqualified": False,
}
elif exit_policy in ("atr_trail3", "atr_trail3_target"):
atr = _atr(sym, fill_bar.idx)
if atr is not None:
next_stop = (
positions[sym]["highest_close"]
- atr_trail_multiplier * atr
)
if next_stop < fill_bar.close:
positions[sym]["stop"] = max(
positions[sym]["stop"], next_stop
)
equity = _marked_equity()
for c in fill_candidates:
sym = c["symbol"]
if fill_mode == FILL_MODE_CLOSE:
entry, stop = float(c["entry"]), float(c["stop"])
signal_idx = index_of.get(sym, {}).get(o)
if signal_idx is None:
corr_scale: float | None = 1.0
else:
corr_scale = _corr_scale_for(sym, signal_idx)
if corr_scale is None:
skipped_corr += 1
continue
_open_position(
c,
entry=entry,
stop=stop,
entry_ord=o,
signal_close=None,
corr_scale=corr_scale,
fill_bar=None,
)
else:
# next_open: c is a prior-day signal; fill at today's open.
signal_ord = date.fromisoformat(str(c["date"])).toordinal()
signal_idx = index_of.get(sym, {}).get(signal_ord)
fill_bar = _bar(sym, o)
if fill_bar is None or signal_idx is None:
skipped_missing_fill += 1
continue
atr = _atr(sym, signal_idx)
if atr is None or atr <= 0:
skipped_missing_fill += 1
continue
entry = float(fill_bar.open)
stop = entry - ATR_MULTIPLIER * atr
signal_close = float(prices[sym][4][signal_idx])
corr_scale = _corr_scale_for(sym, signal_idx)
if corr_scale is None:
skipped_corr += 1
continue
_open_position(
c,
entry=entry,
stop=stop,
entry_ord=o,
signal_close=signal_close,
corr_scale=corr_scale,
fill_bar=fill_bar,
)
if fill_mode == FILL_MODE_NEXT_OPEN:
# Queue today's signals for the next session's open.
pending_next_open.extend(signal_todays)
curve.append((o, _marked_equity()))
# Close whatever is still open at its last mark so final equity is realized.
@@ -1905,12 +2356,8 @@ def _simulate_portfolio(
max_dd = max(max_dd, (peak - eq) / peak)
rets = [b / a - 1.0 for (_, a), (_, b) in zip(curve, curve[1:]) if a > 0]
sharpe = None
if len(rets) > 2:
mean = sum(rets) / len(rets)
var = sum((x - mean) ** 2 for x in rets) / (len(rets) - 1)
if var > 0:
sharpe = mean / math.sqrt(var) * math.sqrt(252)
diag = sharpe_diagnostics(rets)
sharpe = diag["sharpe"]
# Per-calendar-year returns off the equity curve — shows whether every year
# contributed or one exceptional stretch carried the result.
@@ -1981,14 +2428,25 @@ def _simulate_portfolio(
"return_pct": round((close / base_spy - 1.0) * 100.0, 2),
})
max_dd_pct = max_dd * 100.0
calmar = None
if cagr_pct is not None and max_dd_pct > 0:
calmar = float(cagr_pct) / max_dd_pct
result = {
"starting_capital": SIM_STARTING_CAPITAL,
"cost_per_side_pct": round(cost_rate * 100.0, 3),
"fill_mode": fill_mode,
"final_equity": round(final_equity, 2),
"total_return_pct": round(total_return_pct, 1),
"cagr_pct": round(cagr_pct, 1) if cagr_pct is not None else None,
"max_drawdown_pct": round(max_dd * 100.0, 1),
"sharpe": round(sharpe, 2) if sharpe is not None else None,
"max_drawdown_pct": round(max_dd_pct, 1),
"calmar": round(calmar, 2) if calmar is not None else None,
"sharpe": sharpe,
"sharpe_se": diag["sharpe_se"],
"psr": diag["psr"],
"n_returns": diag["n_returns"],
"return_skew": diag["return_skew"],
"return_kurtosis": diag["return_kurtosis"],
"trades": len(trades),
"win_rate": round(wins / len(trades) * 100.0, 1) if trades else None,
"avg_trade_pnl": round(sum(pnls) / len(pnls), 2) if pnls else None,
@@ -2006,6 +2464,34 @@ def _simulate_portfolio(
"start_date": date.fromordinal(calendar[0]).isoformat(),
"end_date": date.fromordinal(calendar[-1]).isoformat(),
}
if vol_target is not None:
result["vol_target"] = vol_target
result["vol_lookback"] = int(vol_lookback)
result["vol_clamp"] = [clamp_lo, clamp_hi]
result["avg_vol_scalar"] = (
round(sum(vol_scalars) / len(vol_scalars), 4) if vol_scalars else None
)
result["vol_scalar_entries"] = len(vol_scalars)
if corr_max is not None:
result["corr_max"] = corr_max
result["corr_action"] = corr_action
result["corr_lookback"] = corr_lookback
result["skipped_corr"] = skipped_corr
if fill_mode == FILL_MODE_NEXT_OPEN:
result["skipped_missing_fill"] = skipped_missing_fill
if overnight_slippage_pct:
slip = sorted(overnight_slippage_pct)
mid = len(slip) // 2
result["overnight_slippage"] = {
"n": len(slip),
"mean_pct": round(sum(slip) / len(slip), 4),
"median_pct": round(
slip[mid] if len(slip) % 2 == 1 else (slip[mid - 1] + slip[mid]) / 2.0,
4,
),
"p05_pct": round(slip[max(0, int(0.05 * (len(slip) - 1)))], 4),
"p95_pct": round(slip[min(len(slip) - 1, int(0.95 * (len(slip) - 1)))], 4),
}
if curve_payload is not None:
result["equity_curve"] = curve_payload
if benchmark_payload is not None: