Performance measurement
TWR, MWR, drawdown, Sharpe, Sortino. Computed from an equity curve and cashflow stream.
Two returns matter, and they answer different questions:
| Metric | Measures | Answers |
|---|---|---|
| TWR (time-weighted) | Strategy return, stripped of deposit and withdrawal timing. | “How well did the manager do?” |
| MWR (money-weighted) | Client return given deposit and withdrawal timing. | “How did I do?” |
TWR is what firms market (GIPS requires it). MWR is what the client experienced. They diverge when a client deposits right before a drawdown (MWR < TWR) or right before a rally (MWR > TWR).
horizon.reporting.performance computes both, plus drawdown and risk metrics, from any equity curve and optional cashflow list. For backtest metrics see backtest overview.
Quickest start
from horizon.reporting import PerformanceBuilder, Cashflow, EquitySample
builder = PerformanceBuilder(
equity_samples=result.equity_curve, # list[(datetime, value)]
cashflows=[
Cashflow(funded_at, +100_000, "deposit"),
],
)
report = builder.build(
period_labels=["YTD", "QTD", "1y", "3y", "ITD"],
risk_free_rate_annual=0.045,
samples_per_year=252, # daily samples
)
print(f"TWR: {report.twr_pct:+.2f}% (ann. {report.twr_annualized_pct:+.2f}%)")
print(f"MWR: {report.mwr_pct:+.2f}% (ann. {report.mwr_annualized_pct:+.2f}%)")
print(f"Max DD: {report.max_drawdown_pct:.2f}%")
print(f"Sharpe: {report.sharpe:.2f} Sortino: {report.sortino:.2f}")
TWR
Geometric linking of sub-period returns where each boundary is a cashflow event:
TWR = product(1 + r_i) - 1
where r_i = V_end_i / V_start_i - 1 (within sub-period)
Two clients in the same strategy with different deposit timing produce identical TWRs.
MWR
Modified Dietz:
MWR = (V_end - V_start - sum CF_i) / (V_start + sum w_i * CF_i)
where w_i = (T - t_i) / T (time weight from start)
Modified Dietz is GIPS-sanctioned for periodic money-weighted reporting. Numerically stable over short and long periods (unlike IRR bisection on short horizons). Matches classic IRR in the limit of small returns.
Drawdown
Peak-to-trough, continuous:
max_drawdown_pct = max over time of:
(running_peak - current_value) / running_peak
The report also returns max_drawdown_start (peak date) and max_drawdown_trough (trough date) for chart annotation.
Sharpe, Sortino, volatility
Sharpe = (mean_excess_return / stdev_return) * sqrt(samples_per_year)
Sortino = (mean_excess_return / downside_stdev) * sqrt(samples_per_year)
downside_stdev counts only returns below risk-free
samples_per_year=252for daily,52for weekly,12for monthly.risk_free_rate_annual=0.045(or current).
Period breakdowns
period_labels=["YTD", "QTD", "1y", "3y", "5y", "ITD"] produces one PeriodReturn per label. Custom labels accepted:
"YTD","QTD","MTD"."Ny","Nm": N years or N months trailing."ITD": inception-to-date.
Each row has start and end values, TWR, annualized TWR, MWR, annualized MWR, and net cashflow.
JSON export
import json
with open("perf_2026_Q1.json", "w") as f:
json.dump(report.to_dict(), f, indent=2, default=str)
Equity curve source
hz.run() returns a BacktestResult whose equity_curve is a list of (datetime, value) tuples sampled per tick. Live runs populate the same structure as ticks arrive. Construct equity samples manually for custom aggregation (end-of-day marks, for example).
Benchmark comparison
horizon.reporting.attribution.compare_to_benchmark runs an OLS regression of portfolio returns against a benchmark return series and returns a BenchmarkComparison:
from horizon.reporting.attribution import compare_to_benchmark
cmp = compare_to_benchmark(
portfolio_equity_curve=result.equity_curve, # list[(datetime, value)]
benchmark_equity_curve=spy_curve,
)
print(f"Alpha (ann.): {cmp.alpha:+.4%}")
print(f"Beta: {cmp.beta:+.2f}")
print(f"Tracking error: {cmp.tracking_error:.4%}")
print(f"Info ratio: {cmp.information_ratio:.2f}")
print(f"R²: {cmp.r_squared:.3f}")
print(f"Correlation: {cmp.correlation:+.3f}")
All fields are populated from a single return-alignment pass. Pure Python — no numpy runtime dependency. Missing or misaligned samples are dropped rather than extrapolated.
Contribution attribution
build_contribution partitions total P&L by an arbitrary key (strategy, sector, or a custom callable):
from horizon.reporting.attribution import build_contribution
by_strategy = build_contribution(ledger, scheme="strategy")
by_sector = build_contribution(ledger, scheme="sector")
custom = build_contribution(ledger, scheme="custom",
group_fn=lambda pos: pos.metadata.get("region"))
for row in by_strategy.rows:
print(f"{row.group:20s} P&L={row.pnl:+,.0f} weight={row.weight_pct:.1%}")
Out of scope
- Risk-adjusted metrics beyond the above. Treynor, M², drawdown-based ratios are one-liners from the raw return series.
- Non-Dietz MWR. TWR uses geometric linking; MWR uses Modified Dietz. GIPS-compliant in the standard case.