{ "cells": [ { "cell_type": "markdown", "id": "597a9a6c", "metadata": {}, "source": [ "---\n", "title: Cross-Sectional Momentum — a worked example\n", "summary: A 12-1 month momentum strategy on a synthetic cross-section, with an out-of-sample check.\n", "tags: [momentum, equities, template]\n", "---\n", "\n", "# Cross-Sectional Momentum — a worked example\n", "\n", "This is a **template post**. Replace the narrative and code with your own strategy, keep the\n", "structure: a clear story, runnable code, charts, and an out-of-sample section. Define your strategy\n", "as a class named `MyStrategy` so it can also be scored on the permanent leaderboard." ] }, { "cell_type": "markdown", "id": "f1aad8b6", "metadata": {}, "source": [ "## The idea\n", "\n", "Stocks that outperformed over the past ~12 months tend to keep outperforming over the next month.\n", "We rank a cross-section by trailing return, go long the top quintile and short the bottom, and check\n", "whether the edge survives out of sample." ] }, { "cell_type": "code", "execution_count": null, "id": "351eb951", "metadata": {}, "outputs": [], "source": [ "import numpy as np\n", "import pandas as pd\n", "%matplotlib inline\n", "import matplotlib.pyplot as plt\n", "\n", "rng = np.random.default_rng(0)\n", "# Synthetic monthly returns for 30 names over 240 months, with a small persistent component.\n", "n_assets, n_months = 30, 240\n", "persistent = rng.normal(0, 0.01, (1, n_assets))\n", "rets = rng.normal(0, 0.05, (n_months, n_assets)) + persistent\n", "rets = pd.DataFrame(rets, columns=[f\"A{i:02d}\" for i in range(n_assets)])\n", "rets.index = pd.period_range(\"2005-01\", periods=n_months, freq=\"M\")\n", "rets.head()" ] }, { "cell_type": "markdown", "id": "4bc66584", "metadata": {}, "source": [ "## The strategy" ] }, { "cell_type": "code", "execution_count": null, "id": "3d26d44b", "metadata": {}, "outputs": [], "source": [ "class MyStrategy:\n", " \"\"\"12-1 cross-sectional momentum: long top quintile, short bottom, monthly.\"\"\"\n", " def signal(self, past_returns: pd.DataFrame) -> pd.Series:\n", " mom = (1 + past_returns).rolling(12).apply(np.prod, raw=True) - 1\n", " return mom.shift(1) # use information available before the month traded\n", "\n", " def weights(self, signal_row: pd.Series) -> pd.Series:\n", " r = signal_row.rank(pct=True)\n", " w = (r > 0.8).astype(float) - (r <= 0.2).astype(float)\n", " s = w.abs().sum()\n", " return w / s if s else w\n", "\n", "strat = MyStrategy()\n", "sig = strat.signal(rets)\n", "w = sig.apply(strat.weights, axis=1)\n", "port = (w.shift(0) * rets).sum(axis=1).dropna()\n", "print(f\"months traded: {len(port)}\")" ] }, { "cell_type": "markdown", "id": "df7bf75a", "metadata": {}, "source": [ "## In-sample vs out-of-sample" ] }, { "cell_type": "code", "execution_count": null, "id": "6be055ab", "metadata": {}, "outputs": [], "source": [ "split = port.index[len(port)//2]\n", "ins, oos = port[port.index <= split], port[port.index > split]\n", "def sharpe(x): return np.sqrt(12) * x.mean() / x.std() if x.std() else float(\"nan\")\n", "print(f\"in-sample Sharpe : {sharpe(ins):.2f}\")\n", "print(f\"out-of-sample : {sharpe(oos):.2f}\")\n", "\n", "eq = (1 + port).cumprod()\n", "fig, ax = plt.subplots(figsize=(8, 3))\n", "ax.plot(eq.index.to_timestamp(), eq.values)\n", "ax.set_title(\"Strategy equity curve\"); ax.set_ylabel(\"growth of $1\")\n", "plt.tight_layout(); plt.show()" ] }, { "cell_type": "markdown", "id": "1889780e", "metadata": {}, "source": [ "## What I'd try next\n", "\n", "- A real universe (swap the synthetic returns for `yfinance` or Ken-French portfolios).\n", "- Skip the most recent month (the 12-1 convention) and test transaction costs.\n", "- Compare against the library's `jegadeesh_titman_momentum` replication.\n", "\n", "*Fork this post to build on it.*" ] } ], "metadata": { "kernelspec": { "display_name": "Python 3", "language": "python", "name": "python3" }, "language_info": { "name": "python" } }, "nbformat": 4, "nbformat_minor": 5 }