Code Walkthrough · Statistics · Wave 2

Reading Backtest Stats in Python: Sharpe, Sortino, MaxDD, Profit Factor

Every backtesting library reports a Sharpe ratio, but if you do not know how it was computed, you cannot trust it or compare it across tools. This walkthrough computes four core performance metrics by hand in numpy — using the same SPY SMA strategy as the hero walkthrough — and then reconciles each number against vectorbt’s native output. The reconciliation reveals a real methodology difference that affects every Sharpe you read from a library.

Versions: numpy 2.4.6 · vectorbt 1.0.0 · yfinance 1.4.1 · pandas 2.3.3 · run 2026-06-06
Ticker: SPY · Range: 2015-01-01 to 2024-12-31 (fixed)

← Code Walkthroughs · First Bot · SMA Backtest · WFA Split · Paper Order

reading_backtest_stats.py
"""AlgoDrill — Computing backtest stats from a returns series by hand.

Sharpe Ratio, Sortino Ratio, Max Drawdown, and Profit Factor computed
from daily portfolio returns in numpy, then reconciled against
vectorbt's native output on the same strategy.

The reconciliation section shows two real methodology choices:
(1) annualization: 252 trading days vs 365 calendar days
(2) downside deviation: std(negatives) vs sqrt(mean(negatives^2))
vectorbt uses 365-day annualization and the RMS downside method.

Versions: numpy 2.4.6 · vectorbt 1.0.0 · yfinance 1.4.1 · pandas 2.3.3
          (run 2026-06-06)
Install:  pip install numpy vectorbt yfinance pandas
"""
import numpy as np
import vectorbt as vbt

# ── Replicate the hero backtest ────────────────────────────────────────────────
data  = vbt.YFData.download("SPY", start="2015-01-01", end="2024-12-31")
close = data.get("Close")
fa    = vbt.MA.run(close, 10)
sl    = vbt.MA.run(close, 50)
pf    = vbt.Portfolio.from_signals(
    close,
    entries=fa.ma_crossed_above(sl),
    exits=fa.ma_crossed_below(sl),
    init_cash=10_000, fees=0.0005, freq="d",
)

# Daily equity → daily portfolio returns
equity    = pf.value()
daily_ret = equity.pct_change().dropna()

# ── Sharpe Ratio ───────────────────────────────────────────────────────────────
# 252-day (trading-day convention, most common in finance literature)
sharpe_252 = round(float(daily_ret.mean() / daily_ret.std() * np.sqrt(252)), 2)
# 365-day (calendar-day convention — vectorbt's default via freq='d')
sharpe_365 = round(float(daily_ret.mean() / daily_ret.std() * np.sqrt(365)), 2)

# ── Sortino Ratio (matching vectorbt: RMS downside, 365-day) ──────────────────
neg_arr    = daily_ret.values.copy()
neg_arr[neg_arr > 0] = 0
downside   = np.sqrt(np.nanmean(neg_arr ** 2)) * np.sqrt(365)
sortino    = round(float(daily_ret.mean()) * 365 / downside, 2)

# ── Max Drawdown ───────────────────────────────────────────────────────────────
rolling_max = equity.cummax()
drawdowns   = (equity - rolling_max) / rolling_max
max_dd      = round(float(drawdowns.min()) * 100, 1)

# ── Profit Factor (gross gains / |gross losses| on trade P&L) ─────────────────
pnl         = pf.trades.records_readable["PnL"]
gross_gains = float(pnl[pnl > 0].sum())
gross_loss  = float(abs(pnl[pnl < 0].sum()))
prof_factor = round(gross_gains / gross_loss, 2) if gross_loss > 0 else float("inf")

print("=== Hand-computed: SPY 10/50 SMA, 2015-2024, 5 bps fees ===")
print(f"Sharpe Ratio (252 trading-day)   {sharpe_252:>8.2f}  <- finance literature standard")
print(f"Sharpe Ratio (365 calendar-day)  {sharpe_365:>8.2f}  <- vectorbt convention")
print(f"Sortino Ratio (365, RMS down.)   {sortino:>8.2f}  <- matching vectorbt method")
print(f"Max Drawdown                     {max_dd:>8.1f}%")
print(f"Profit Factor                    {prof_factor:>8.2f}  (not in vectorbt natively)")
print(f"  Trades: {len(pnl)}  Gross gains: ${gross_gains:.2f}  Gross losses: ${gross_loss:.2f}")
print()

# ── Reconcile with vectorbt native output ─────────────────────────────────────
vbt_sharpe  = round(float(pf.sharpe_ratio()), 2)
vbt_sortino = round(float(pf.sortino_ratio()), 2)
vbt_maxdd   = round(float(pf.max_drawdown()) * 100, 1)

print("=== Reconciliation: hand vs vectorbt native ===")
print(f"{'Metric':<36}  {'Hand':>6}  {'vectorbt':>8}  {'Match':>5}")
print(f"  Sharpe (365-day)               {sharpe_365:>6.2f}  {vbt_sharpe:>8.2f}  {'YES' if abs(sharpe_365-vbt_sharpe)<=0.01 else 'DIFF':>5}")
print(f"  Sortino (365, RMS downside)    {sortino:>6.2f}  {vbt_sortino:>8.2f}  {'YES' if abs(sortino-vbt_sortino)<=0.01 else 'DIFF':>5}")
print(f"  Max Drawdown                   {max_dd:>6.1f}%  {vbt_maxdd:>8.1f}%  {'YES' if abs(max_dd-vbt_maxdd)<=0.5 else 'DIFF':>5}")
print()
print("Note: Sharpe_365 = Sharpe_252 x sqrt(365/252) = Sharpe_252 x 1.2035")
print("When comparing across tools, confirm the annualization convention first.")

Script Output

=== Hand-computed: SPY 10/50 SMA, 2015-2024, 5 bps fees ===
Sharpe Ratio (252 trading-day)       0.72  <- finance literature standard
Sharpe Ratio (365 calendar-day)      0.87  <- vectorbt convention
Sortino Ratio (365, RMS down.)       1.19  <- matching vectorbt method
Max Drawdown                        -15.1%
Profit Factor                        3.78  (not in vectorbt natively)
  Trades: 30  Gross gains: $14489.54  Gross losses: $3832.90

=== Reconciliation: hand vs vectorbt native ===
Metric                                  Hand  vectorbt  Match
  Sharpe (365-day)                 0.87      0.87    YES
  Sortino (365, RMS downside)      1.19      1.19    YES
  Max Drawdown                    -15.1%     -15.1%    YES

Note: Sharpe_365 = Sharpe_252 x sqrt(365/252) = Sharpe_252 x 1.2035
When comparing across tools, confirm the annualization convention first.

Line-by-Line Walkthrough

Lines 1–24: Setup and the hero backtest

The script replicates the exact backtest from Your First Trading Bot: SPY 10/50 SMA crossover, 2015–2024, 5 bps fees. From the final portfolio object, pf.value() returns the portfolio equity series (starting at $10,000 and fluctuating with trades). equity.pct_change().dropna() converts this to a series of daily percentage returns, which is the input for all four metrics.

Lines 26–30: Sharpe Ratio — the annualization surprise

The textbook Sharpe Ratio formula for daily returns is:

Sharpe = (mean_daily_return / std_daily_return) * sqrt(annualization_factor)

The script computes it twice: once with 252 (trading days per year — the standard in the finance literature, including the original Sharpe 1966 paper and CFA curriculum) and once with 365 (calendar days per year — vectorbt’s default when you pass freq='d').

The result: Sharpe_252 = 0.72 vs Sharpe_365 = 0.87. The difference is a factor of sqrt(365/252) = 1.2035 — a 20% inflation from convention alone, with no change to the underlying strategy or data. When you read a Sharpe ratio from any tool — vectorbt, QuantConnect, pyfolio, a fund’s tear sheet — you need to confirm which annualization it uses before comparing it to any other number.

Lines 32–35: Sortino Ratio — two definitions of downside

Sortino ratio replaces the total volatility in the denominator with downside volatility — penalizing losses without penalizing gains. But “downside volatility” can be computed two ways:

The script uses the RMS method (matching vectorbt) to achieve a YES reconciliation. The key implementation is lines 32–34: copy the daily return array, zero out all positive values, then compute sqrt(nanmean(negative_squared)). This is slightly different from std(negatives) because it does not subtract the mean of the negative sub-series before squaring.

Lines 37–40: Max Drawdown

Max drawdown is the largest peak-to-trough decline in the portfolio equity curve:

rolling_max = equity.cummax()
drawdowns   = (equity - rolling_max) / rolling_max
max_dd      = drawdowns.min()

equity.cummax() at each date gives the highest equity value seen up to that point. The drawdown at each date is the percentage below that high-water mark. min() finds the deepest trough: −15.1%, matching vectorbt exactly.

Lines 43–47: Profit Factor

Profit Factor is the ratio of gross gains to gross losses across all trades. It is not a standard output in vectorbt, but is trivial to compute from the trade-level records that vectorbt does expose:

pnl         = pf.trades.records_readable["PnL"]
gross_gains = pnl[pnl > 0].sum()
gross_loss  = abs(pnl[pnl < 0].sum())
prof_factor = gross_gains / gross_loss

For the SPY SMA strategy: gross gains = $14,489.54 across winning trades, gross losses = $3,832.90 across losing trades, Profit Factor = 3.78. A Profit Factor above 2.0 is generally considered strong for a trend-following strategy. The 30-trade sample is small enough that this number carries a confidence interval of roughly ±0.5–1.0; a 200-trade history would give a tighter estimate.

The Reconciliation Lesson

The reconciliation table shows all YES for three metrics — but only because the script was written to match vectorbt’s specific methodology. The initial naive computation of Sharpe (252 trading days, simple std of negatives for Sortino) would have produced DIFF on two of three metrics. The path to YES required:

  1. Identifying that vectorbt uses 365-day annualization (not 252-day)
  2. Identifying that vectorbt uses RMS downside deviation (not std of negatives)

Neither of these is "wrong" — they are methodology choices that produce systematically different numbers. The practical rule: when you compare a strategy’s Sharpe across two tools, or against a benchmark in a research paper, confirm the convention. A Sharpe of 0.87 (vectorbt, 365-day) and 0.72 (standard, 252-day) describe the exact same strategy on the exact same data.

The Honesty Checklist

Before treating any of these numbers as deployment signals, three questions are mandatory:

1. Trial count

This backtest ran on one parameter set (10/50 SMA) on one ticker (SPY) over one period (2015–2024). That is approximately 1 trial. If you ran 50 parameter combinations and chose the best-performing one, the observed Sharpe is biased upward by the multiple-testing problem. The Deflated Sharpe Ratio quantifies this inflation: it reports the probability that the true Sharpe exceeds zero once the number of trials is accounted for — a probability, not an adjusted Sharpe. For a 50-trial search, a raw Sharpe of 0.87 over this track length may no longer be statistically distinguishable from zero.

2. Sample size

30 trades over 10 years is a small sample. The 95% confidence interval on a 46.7% win rate at n=30 is roughly 28%–66% — too wide to be actionable. A Sharpe of 0.87 from 30 trades has an uncertainty of approximately ±0.3–0.4. Strategies deployed on fewer than 50–100 trades of out-of-sample history require aggressive position-sizing conservatism to account for estimation error.

3. Regime coverage

The 2015–2024 period includes a long bull market, the 2020 COVID crash and recovery, and the 2022 drawdown. It does not include a prolonged bear market (2001–2002, 2008–2009), a low-volatility secular grind, or a sustained inflation-driven regime. A strategy calibrated on this window may not generalize to regimes it has not seen. Walk-forward analysis (see Walk-Forward Split walkthrough) partially addresses this by measuring OOS performance in different sub-periods.

Frequently Asked Questions

Why does my hand-computed Sharpe ratio not match vectorbt's output?
The most common reason is annualization convention. vectorbt's freq='d' accessor uses 365 calendar days per year, not 252 trading days. If you compute Sharpe as mean(daily_returns) / std(daily_returns) * sqrt(252), you get 0.72 for the SPY SMA walkthrough. vectorbt reports 0.87 because it uses sqrt(365): 0.72 * sqrt(365/252) = 0.87. Always check which annualization your tool uses before comparing Sharpe values across tools. The formula is: Sharpe_365 = Sharpe_252 * sqrt(365/252) = Sharpe_252 * 1.2035.
What is Profit Factor and how is it calculated?
Profit Factor is the ratio of gross gains to gross losses across all trades: Profit Factor = sum(winning trade P&L) / abs(sum(losing trade P&L)). A Profit Factor above 1.0 means the strategy made more from winners than it lost from losers. For the SPY 10/50 SMA strategy over 2015-2024, gross gains across 30 trades totaled $14,489.54 and gross losses totaled $3,832.90, giving Profit Factor = 3.78. A Profit Factor of 3.78 is strong for a trend-following SMA strategy. However, Profit Factor does not adjust for the number of trades or the distribution of returns, so a strategy with 2 large winning trades and 1 large loss can have a high Profit Factor while being statistically unreliable.
What is the Sortino ratio and how does it differ from Sharpe ratio?
Sortino ratio is like Sharpe ratio but penalizes only downside volatility. Sharpe uses the standard deviation of all returns in the denominator; Sortino uses only the standard deviation (or RMS) of negative returns. Strategies with frequent small losses and occasional large gains can have a low Sharpe (because all-returns std is inflated by the losses) but a decent Sortino (because the downside deviation is relatively small). vectorbt computes Sortino as: annualized_mean_return / (sqrt(mean(negative_returns^2)) * sqrt(365)), using RMS of negative returns rather than their std. For the SPY SMA walkthrough, Sortino is 1.19 vs Sharpe 0.87 (vectorbt's 365-day convention for both) -- the Sortino is higher because the strategy's losses are relatively small and frequent, not large and clustered.
What is a good Sharpe ratio for an algorithmic trading strategy?
There is no universal threshold, but the following ranges are common reference points in the industry: below 0.5 is generally considered poor; 0.5-1.0 is acceptable for a strategy with low operational complexity; 1.0-2.0 is good; above 2.0 is excellent and warrants skepticism about overfitting or data mining unless there is a clear structural reason. The SPY 10/50 SMA strategy has a Sharpe of 0.87 (365-day convention) -- acceptable for a simple trend strategy on liquid equities. Strategies with higher turnover, more complex signals, or more degrees of freedom during optimization should clear a higher bar before deployment, because the Deflated Sharpe Ratio (DSR) adjustment for multiple testing reduces any raw Sharpe by an amount proportional to the number of trials tested.

DSR quantifies the Sharpe inflation from multiple testing — the adjustment that separates a genuine 0.87 from a cherry-picked one.

Deflated Sharpe Ratio →   All Code Walkthroughs →