Reading an equity curve: the metrics
Worked examples
Read the claim, then run it and check the machine agrees. One at a time — nothing here is taken on trust.
import numpy as np
import qadata
prices = qadata.trending_prices()
rets = prices.pct_change().fillna(0.0)
held = (prices > prices.rolling(20).mean()).astype(float).shift(1).fillna(0.0)
strat = held * rets # the m02-1 strategy's daily returns
mean, std = strat.mean(), strat.std() # pandas std: ddof=1
downside = np.sqrt((strat.clip(upper=0.0) ** 2).mean()) # RMS of losses, ALL days
print(f"daily mean {mean:+.6%}, std {std:.6%}, downside dev {downside:.6%}")
print(f"Sharpe : {mean / std * np.sqrt(252):.10f}")
print(f"Sortino : {mean / downside * np.sqrt(252):.10f}")
losers_only = strat[strat < 0].std() # the classic WRONG denominator
print(f"wrong 'Sortino' (std of losing days): {mean / losers_only * np.sqrt(252):.10f}")
Three lines of arithmetic each. Sharpe 0.7372849538, Sortino 1.0943037411 — Sortino is higher because the filter's volatility is disproportionately upside: min(r, 0) zeroes out every good day before the RMS, and flat-in-cash days contribute zeros that count in the average (they dilute risk, as they should — cash has none). The wrong version — std of the 205 losing days only — prints 0.9045471019: numerically close enough to fool you, conceptually a different quantity (dispersion OF losses, not exposure TO them).
import numpy as np
import qadata
prices = qadata.trending_prices()
rets = prices.pct_change().fillna(0.0)
held = (prices > prices.rolling(20).mean()).astype(float).shift(1).fillna(0.0)
for name, r in [("20d filter", held * rets), ("buy & hold", rets)]:
equity = (1.0 + r).cumprod()
dd = 1.0 - equity / equity.cummax() # shortfall from running peak
trough = dd.idxmax()
peak = equity[:trough].idxmax()
rec = dd[trough:][dd[trough:] < 1e-12]
recovered = rec.index[0].date() if len(rec) else "never"
under = dd > 1e-12
longest = under.groupby((~under).cumsum()).sum().max()
cagr = equity.iloc[-1] ** (252 / len(r)) - 1.0
print(f"{name}: max drawdown {dd.max():.4%}")
print(f" peak {peak.date()} -> trough {trough.date()} -> recovered {recovered}")
print(f" days below a prior peak: {under.sum()} of {len(r)} (longest run {longest})")
print(f" CAGR {cagr:+.4%} -> Calmar {cagr / dd.max():.4f}")
The running maximum (cummax) is the whole trick — every other
drawdown mistake comes from skipping it. The ledger reads: filter
falls at most 9.7286% (Oct-2020 peak, Feb-2021 trough, seven months
to a new high, 232 days in its longest underwater run) and spends
687 of 756 days below some prior peak; buy-and-hold falls 18.0032%.
Divide each CAGR by each drawdown and the stand-off breaks: Calmar
0.8601 vs 0.5132. Drawdown numbers are also how m07-2 will connect
Sharpe to the pain a strategy implies — this ledger is the raw
material.
import numpy as np
r = np.array([0.02, -0.01, 0.03, -0.02, 0.01])
equity = np.cumprod(1.0 + r)
peak = np.maximum.accumulate(equity)
print("equity:", equity.round(6))
print("peak :", peak.round(6))
print(f"max drawdown: {(1.0 - equity / peak).max():.10f}")
downside = np.sqrt(np.mean(np.minimum(r, 0.0) ** 2))
print(f"downside dev: {downside:.10f}")
print(f"Sortino : {r.mean() / downside * np.sqrt(252):.10f}")
print(f"Sharpe : {r.mean() / r.std(ddof=1) * np.sqrt(252):.10f}")
Small enough to verify every digit. Two dips leave the running peak: the −1% day sits 0.98% below its peak (1.0098 vs 1.02), and the −2% day sits exactly 2% below its (1.019292 vs 1.040094) — so max drawdown is 0.02, exactly, and any other answer means the running peak is wrong. Downside deviation: min(r,0)² is [0, 1bp, 0, 4bp, 0], mean 1bp, root 1% — so Sortino = 0.6%/1% × √252 = 9.5247047198. When a vectorized metric surprises you, shrink to five days and hand-check, same as m02-1's board work.
Check the concept
One question at a time. Unsure? Revisit the lecture, then answer.
The challenge
Pass the quiz to unlock the challenge — your code will still be waiting here.