Portfolio Analytics: Weights, Frontier, Drawdown¶
Equal-weight vs inverse-vol vs in-sample max Sharpe on a live NSE basket, Monte Carlo frontier, equity curves — PyPortfolioOpt-style, no extra optimiser.
Part 34 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__)
A portfolio is a set of weights, not a list of tickers¶
Kaggle "optimized portfolio" notebooks usually: download 8–15 names, print a correlation heatmap (notebook 32), throw 5,000 random weights at the cloud, circle the max-Sharpe point, and stop. The better ones then call PyPortfolioOpt (EfficientFrontier.max_sharpe, HRP, Black-Litterman).
Worth reading, then rewriting:
- Yahoo Finance: Building Optimized Portfolio & CAPM
- Portfolio Optimization — MC and PyPortfolioOpt
- PortfolioDesign using Efficient Frontier & K-Means
- PyPortfolioOpt cookbook — the clean OSS reference, especially
2-Mean-Variance-Optimisation.ipynb
This chapter does the Monte Carlo frontier without an extra optimiser dependency, then compares three honest baselines: equal weight, inverse-volatility, and the in-sample max-Sharpe point. The last one will look best. That is the trap — it was chosen on the same data you are scoring.
!pip install -q matplotlib
import matplotlib.pyplot as plt
PORTFOLIO = {
"RELIANCE.NS": "Reliance", "TCS.NS": "TCS", "HDFCBANK.NS": "HDFC Bank",
"INFY.NS": "Infosys", "ICICIBANK.NS": "ICICI Bank", "BHARTIARTL.NS": "Airtel",
"ITC.NS": "ITC", "LT.NS": "L&T", "SBIN.NS": "SBI",
}
raw = yf.download(
list(PORTFOLIO), period="3y", interval="1d",
auto_adjust=True, progress=False, group_by="ticker", threads=True,
)
def close_of(symbol):
if isinstance(raw.columns, pd.MultiIndex):
frame = raw[symbol]
col = "Close" if "Close" in frame.columns else frame.columns[0]
return frame[col].rename(symbol)
return raw[symbol].rename(symbol)
prices = pd.concat([close_of(s) for s in PORTFOLIO], axis=1).dropna(how="any")
prices.columns = [PORTFOLIO[c] for c in prices.columns]
returns = prices.pct_change(fill_method=None).dropna()
mu = returns.mean() * 252
cov = returns.cov() * 252
print(f"{len(returns)} sessions, {returns.index.min().date()} -> {returns.index.max().date()}")
print("annualized mean returns (%):")
print((mu * 100).round(1).to_string())
Three weighting rules¶
- Equal weight. 1/N. No estimate, no in-sample gift.
- Inverse volatility. Weight ∝ 1/σ. Cuts the jumpy names without estimating expected return (expected return is the noisiest input in Markowitz).
- Max Sharpe (in-sample). Search random long-only weights; keep the best Sharpe on this 3-year window. This is the Kaggle circle. It is also a description of the past.
rng = np.random.default_rng(7)
n_assets = returns.shape[1]
rf = 0.06 # illustrative INR cash rate, not a forecast
def stats(weights):
weights = np.asarray(weights, dtype=float)
weights = weights / weights.sum()
ret = float(weights @ mu.values)
vol = float(np.sqrt(weights @ cov.values @ weights))
sharpe = (ret - rf) / vol if vol else float("nan")
return weights, ret, vol, sharpe
eq_w, eq_r, eq_v, eq_s = stats(np.ones(n_assets))
inv_w, inv_r, inv_v, inv_s = stats(1 / returns.std().values)
n_pts = 4000
rand_w = rng.dirichlet(np.ones(n_assets), size=n_pts)
rand_r = rand_w @ mu.values
rand_v = np.sqrt(np.einsum("ij,jk,ik->i", rand_w, cov.values, rand_w))
rand_s = (rand_r - rf) / rand_v
best = int(np.nanargmax(rand_s))
ms_w, ms_r, ms_v, ms_s = stats(rand_w[best])
alloc = pd.DataFrame({
"equal": eq_w, "inv_vol": inv_w, "max_sharpe_in_sample": ms_w,
}, index=returns.columns)
alloc.loc["return"] = [eq_r, inv_r, ms_r]
alloc.loc["vol"] = [eq_v, inv_v, ms_v]
alloc.loc["sharpe"] = [eq_s, inv_s, ms_s]
print(alloc.round(3))
Efficient-frontier cloud¶
Each dot is one long-only random portfolio. The left edge of the cloud is the empirical frontier on this sample. The star is in-sample max Sharpe; the square is 1/N; the triangle is inverse-vol. If the star is far above the square, you are looking at estimation luck as much as at "optimization."
fig, ax = plt.subplots(figsize=(8.5, 5.2))
sc = ax.scatter(rand_v, rand_r, c=rand_s, s=8, cmap="viridis", alpha=0.45)
fig.colorbar(sc, ax=ax, label=f"Sharpe vs {rf:.0%} cash")
ax.scatter([eq_v], [eq_r], marker="s", s=80, color="#1a1a1a", label="equal weight", zorder=3)
ax.scatter([inv_v], [inv_r], marker="^", s=90, color="#2563eb", label="inverse vol", zorder=3)
ax.scatter([ms_v], [ms_r], marker="*", s=180, color="#be123c", label="max Sharpe (in-sample)", zorder=3)
ax.set_xlabel("Annualized volatility")
ax.set_ylabel("Annualized return")
ax.set_title("Long-only random portfolios — empirical frontier, this window only")
ax.legend(frameon=False)
ax.grid(True, alpha=0.25)
fig.tight_layout()
plt.show()
Equity curves and max drawdown¶
Convert each weight vector into a daily portfolio return, compound it, and measure the worst peak-to-trough. The in-sample max-Sharpe curve will often win the return race and still surprise you on drawdown. Costs, lot sizes and taxes are not in this picture — notebook 29 is where those go.
def equity(weights):
w = np.asarray(weights, dtype=float)
w = w / w.sum()
port = returns.values @ w
curve = (1 + pd.Series(port, index=returns.index)).cumprod()
dd = curve / curve.cummax() - 1
return curve, float(dd.min()), pd.Series(port, index=returns.index)
curves = {}
print(f"{'rule':<24} {'end':>8} {'maxDD':>8} {'vol':>8} {'sharpe':>8}")
for name, weights in [("equal", eq_w), ("inv_vol", inv_w), ("max_sharpe_is", ms_w)]:
curve, mdd, port = equity(weights)
curves[name] = curve
vol = port.std() * np.sqrt(252)
sharpe = (port.mean() * 252 - rf) / vol
print(f"{name:<24} {curve.iloc[-1]-1:>+7.1%} {mdd:>+7.1%} {vol:>7.1%} {sharpe:>8.2f}")
fig, ax = plt.subplots(figsize=(11, 4))
for name, curve in curves.items():
ax.plot(curve.index, curve, lw=1.2, label=name)
ax.set_title("Growth of Rs.1 — same names, three weight rules, no costs")
ax.legend(frameon=False)
ax.grid(True, alpha=0.25)
fig.tight_layout()
plt.show()
How to read this without fooling yourself¶
- 1/N is the benchmark, not the dumb option. DeMiguel, Garlappi and Uppal (2009) showed that a pile of "optimal" estimators lose to 1/N out of sample. If inverse-vol or a shrinking estimator cannot beat it after costs, keep 1/N.
- Expected returns are the weak input. Inverse-vol only needs volatilities, which are more stable than means. That is why it is the grown-up default when you do not have a real return forecast (and notebook 33 just showed you probably do not).
- The red star is not a recommendation. Re-run this cell in six months and it will move. PyPortfolioOpt's HRP and Black-Litterman exist because mean-variance on raw historical μ is fragile — use the cookbook when you want those estimators, and still score them on a later window.
- Correlation clustering (notebook 32) first. Five bank names in this basket are not five diversifiers; the frontier cannot invent independence the returns do not have.
Back to indicators: notebook 21's scenarios are single-name stories. A portfolio is how those stories are sized relative to each other.
« Previous: Return Prediction Baselines (Beat Naive First)
Next: Prophet, Drive Lab & Three Projectors »
Try the concepts above interactively: Options Strategy Builder · Docs · Get your static IP