{ "cells": [ { "cell_type": "markdown", "id": "12bfc759", "metadata": {}, "source": [ "---\n", "title: Betting Against Volatility\n", "summary: Long low-volatility names, short high-volatility names — the low-risk anomaly.\n", "tags: [low-volatility, defensive, anomaly]\n", "---\n", "\n", "# Betting Against Volatility\n", "\n", "One of the most stubborn anomalies: **low-volatility stocks earn as much or more than high-volatility\n", "ones**, on a risk-adjusted basis — the opposite of what the textbook risk-return tradeoff predicts.\n", "Leverage-constrained investors overpay for exciting, high-vol names. We lean the other way." ] }, { "cell_type": "code", "execution_count": null, "id": "8a18b929", "metadata": {}, "outputs": [], "source": [ "try:\n", " import convexpi.lab # noqa\n", "except ImportError:\n", " import subprocess, sys\n", " subprocess.run([sys.executable, \"-m\", \"pip\", \"install\", \"-q\", \"convexpi-lab\"])\n", "%matplotlib inline\n", "import numpy as np\n", "import matplotlib.pyplot as plt\n", "print(\"ready\")" ] }, { "cell_type": "markdown", "id": "68e642ee", "metadata": {}, "source": [ "## The idea\n", "\n", "`vol_1m` is each stock's recent volatility (high = risky). We want to be **long low vol / short high\n", "vol**, so we sort on the *negative* of the signal — high score = calm stock." ] }, { "cell_type": "code", "execution_count": null, "id": "9df3e042", "metadata": {}, "outputs": [], "source": [ "import numpy as np\n", "from convexpi.lab import Strategy\n", "\n", "def _long_short(signal, frac=0.2):\n", " \"\"\"Dollar-neutral long/short: long the top `frac`, short the bottom `frac`, equal-weighted.\"\"\"\n", " s = np.nan_to_num(np.asarray(signal, dtype=float))\n", " n = len(s); k = max(1, int(n * frac))\n", " order = np.argsort(s)\n", " w = np.zeros(n); w[order[-k:]] = 1.0 / k; w[order[:k]] = -1.0 / k\n", " return w\n", "\n", "class MyStrategy(Strategy):\n", " \"\"\"Long low-volatility names, short high-volatility names.\"\"\"\n", " def on_day(self, day, features, prices, portfolio):\n", " vol = features.get(\"vol_1m\", np.zeros(len(prices)))\n", " return _long_short(-np.nan_to_num(vol), frac=0.2) # negate: long the calmest" ] }, { "cell_type": "markdown", "id": "b976d115", "metadata": {}, "source": [ "## Out-of-sample evaluation\n", "\n", "Train on the first half of a synthetic market, evaluate on the held-out second half — the same discipline as the leaderboard." ] }, { "cell_type": "code", "execution_count": null, "id": "bb1027ca", "metadata": {}, "outputs": [], "source": [ "from convexpi.lab import SyntheticMarket, Grader\n", "market = SyntheticMarket(n_stocks=80, n_days=1800, seed=1)\n", "report = Grader(market).evaluate(MyStrategy())\n", "print(f\"in-sample Sharpe : {report.is_sharpe:+.2f}\")\n", "print(f\"out-of-sample Sharpe: {report.oos_sharpe:+.2f}\")\n", "print(f\"overfitting ratio : {report.overfitting_ratio:+.2f}\")\n", "oos = report.oos_result.daily_returns\n", "fig, ax = plt.subplots(figsize=(8, 3))\n", "ax.plot(np.cumprod(1 + oos)); ax.set_title(\"Out-of-sample equity curve\"); ax.set_ylabel(\"growth of $1\")\n", "plt.tight_layout(); plt.show()\n", "\n", "print(\"The low-vol leg is defensive — it tends to shine in drawdowns.\")" ] }, { "cell_type": "markdown", "id": "efaa1804", "metadata": {}, "source": [ "## What I'd try next\n", "\n", "- True **betting-against-beta** rescales each leg to equal beta (see the library's `frazzini_pedersen_bab` replication).\n", "- Combine low-vol with **quality** — calm *and* profitable.\n", "- Check whether the edge is just a sector tilt (utilities/staples) in disguise." ] } ], "metadata": { "kernelspec": { "display_name": "Python 3", "language": "python", "name": "python3" }, "language_info": { "name": "python" } }, "nbformat": 4, "nbformat_minor": 5 }