Returns, Volatility & the Numbers Courses Skip¶
Daily and log returns, annualized volatility, Sharpe, Sortino and max drawdown computed on real Nifty and S&P history.
Part 25 of 36 in the ServLoci algo/options trading notebook series — full index in notebooks/README.md.
Setup — no broker account needed¶
!pip install -q yfinance
import yfinance as yf
import pandas as pd
import numpy as np
# No broker account, no API key, no ServLoci setup needed for this chapter —
# yfinance reads public end-of-day data from Yahoo Finance. Indian tickers take
# an ".NS" suffix (NSE) or ".BO" (BSE); index tickers are prefixed with "^"
# (^NSEI = Nifty 50, ^BSESN = Sensex, ^GSPC = S&P 500).
NSE_TICKER = "RELIANCE.NS"
US_TICKER = "AAPL"
INDEX_TICKER = "^NSEI"
print("yfinance", yf.__version__)
The number that matters is never just "the return"¶
"This strategy returned 40%!" is not a complete sentence — over what period, with what volatility, and how far underwater did it go before that 40%? Free trading content routinely leads with a headline return and drops the other three numbers, because the other three are usually less flattering. This notebook computes all four, on real data, so you can see why they matter together.
Simple vs log returns. Simple return is (P1 - P0) / P0 — intuitive, but simple returns don't add across time (a +50% day followed by a -50% day does not net to 0%). Log return, ln(P1 / P0), does add across time and is what you should use for anything involving compounding, multi-period aggregation, or volatility math.
nse = yf.download(NSE_TICKER, period="2y", interval="1d", progress=False, auto_adjust=True, multi_level_index=False)
prices = nse["Close"]
simple_returns = prices.pct_change().dropna()
log_returns = np.log(prices / prices.shift(1)).dropna()
print("Simple return, first 3 days:\n", simple_returns.head(3))
print("\nLog return, first 3 days:\n", log_returns.head(3))
# The gap between them widens with the size of the daily move — small moves,
# simple and log returns are almost identical; large moves, they diverge.
Volatility: the risk you're actually taking to earn that return¶
Annualized volatility scales daily return standard deviation up by sqrt(252) (the approximate number of trading sessions in a year) — it's the standard way to compare "how bumpy was the ride" across strategies or instruments regardless of how much history you pulled.
daily_vol = log_returns.std()
annualized_vol = daily_vol * np.sqrt(252)
print(f"{NSE_TICKER} daily vol: {daily_vol:.4%}, annualized: {annualized_vol:.2%}")
# Compare against the index, over the SAME window — a stock more volatile
# than the index it belongs to is carrying idiosyncratic (stock-specific) risk
# on top of market risk.
idx = yf.download(INDEX_TICKER, period="2y", interval="1d", progress=False, auto_adjust=True, multi_level_index=False)
idx_log_returns = np.log(idx["Close"] / idx["Close"].shift(1)).dropna()
idx_annualized_vol = idx_log_returns.std() * np.sqrt(252)
print(f"{INDEX_TICKER} annualized vol: {idx_annualized_vol:.2%}")
Sharpe, Sortino, and drawdown — the numbers that keep a headline return honest¶
- Sharpe ratio — excess return over the risk-free rate, divided by volatility. High return with low Sharpe means the return came with a rough ride; a strategy claim without a Sharpe number is an incomplete claim.
- Sortino ratio — the same idea, but only penalizes downside volatility. Two strategies with identical Sharpe can have very different Sortino if one strategy's volatility is mostly big up-days (which Sharpe punishes even though nobody minds them).
- Max drawdown — the largest peak-to-trough decline in the equity curve. This is the number that answers "could I have psychologically and financially survived holding through this strategy's worst stretch?" — and it's the number retail marketing omits most often.
RISK_FREE_RATE = 0.065 # approx. Indian 10Y G-Sec yield — swap for the current rate when you run this
excess_daily = log_returns - (RISK_FREE_RATE / 252)
sharpe = excess_daily.mean() / log_returns.std() * np.sqrt(252)
downside = log_returns[log_returns < 0]
sortino = excess_daily.mean() / downside.std() * np.sqrt(252)
cumulative = (1 + simple_returns).cumprod()
running_max = cumulative.cummax()
drawdown = (cumulative - running_max) / running_max
max_drawdown = drawdown.min()
print(f"Sharpe: {sharpe:.2f}")
print(f"Sortino: {sortino:.2f}")
print(f"Max drawdown: {max_drawdown:.2%}")
The cherry-picking trap, demonstrated¶
Pull the same ticker over two different windows and compare the headline return. This is exactly how misleading "look at this return!" screenshots get made — not usually through outright fabrication, just through choosing a window that happens to start at a low and end at a high.
window_a = yf.download(NSE_TICKER, start="2020-03-23", end="2021-03-23", progress=False, auto_adjust=True, multi_level_index=False)
window_b = yf.download(NSE_TICKER, period="1y", progress=False, auto_adjust=True, multi_level_index=False)
return_a = window_a["Close"].iloc[-1] / window_a["Close"].iloc[0] - 1
return_b = window_b["Close"].iloc[-1] / window_b["Close"].iloc[0] - 1
print(f"Return from the 2020 crash low, 1 year forward: {return_a:.1%}")
print(f"Return over the most recent 1 year: {return_b:.1%}")
print("Same ticker. Same length of window. Very different number — the start date did all the work.")
Next: notebook 26 puts the company itself under the microscope — fundamentals, not just price history.
« Previous: Reading the Market with yfinance
Next: Fundamental Analysis with Real Filings »
Try the concepts above interactively: Options Strategy Builder · Docs · Get your static IP