Performance measurement

TWR, MWR, drawdown, Sharpe, Sortino. Computed from an equity curve and cashflow stream.

Two returns matter, and they answer different questions:

MetricMeasuresAnswers
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

python
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:

text
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:

text
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:

text
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

text
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=252 for daily, 52 for weekly, 12 for 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

python
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:

python
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):

python
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.