Created
September 7, 2024 16:03
-
-
Save danwagnerco/b5faa603b15a318b74212c06fef11311 to your computer and use it in GitHub Desktop.
How much value is there in attempting to out-model your counterparty?
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
{ | |
"cells": [ | |
{ | |
"cell_type": "code", | |
"execution_count": 1, | |
"metadata": {}, | |
"outputs": [ | |
{ | |
"data": { | |
"text/html": [ | |
"\n", | |
" <div id=\"BRu2Fx\"></div>\n", | |
" <script type=\"text/javascript\" data-lets-plot-script=\"library\">\n", | |
" if(!window.letsPlotCallQueue) {\n", | |
" window.letsPlotCallQueue = [];\n", | |
" }; \n", | |
" window.letsPlotCall = function(f) {\n", | |
" window.letsPlotCallQueue.push(f);\n", | |
" };\n", | |
" (function() {\n", | |
" var script = document.createElement(\"script\");\n", | |
" script.type = \"text/javascript\";\n", | |
" script.src = \"https://cdn.jsdelivr.net/gh/JetBrains/[email protected]/js-package/distr/lets-plot.min.js\";\n", | |
" script.onload = function() {\n", | |
" window.letsPlotCall = function(f) {f();};\n", | |
" window.letsPlotCallQueue.forEach(function(f) {f();});\n", | |
" window.letsPlotCallQueue = [];\n", | |
" \n", | |
" };\n", | |
" script.onerror = function(event) {\n", | |
" window.letsPlotCall = function(f) {}; // noop\n", | |
" window.letsPlotCallQueue = [];\n", | |
" var div = document.createElement(\"div\");\n", | |
" div.style.color = 'darkred';\n", | |
" div.textContent = 'Error loading Lets-Plot JS';\n", | |
" document.getElementById(\"BRu2Fx\").appendChild(div);\n", | |
" };\n", | |
" var e = document.getElementById(\"BRu2Fx\");\n", | |
" e.appendChild(script);\n", | |
" })()\n", | |
" </script>\n", | |
" " | |
] | |
}, | |
"metadata": {}, | |
"output_type": "display_data" | |
} | |
], | |
"source": [ | |
"import math\n", | |
"import QuantLib as ql # 1.35\n", | |
"import polars as pl # 0.20.13\n", | |
"from lets_plot import * # 4.3.3\n", | |
"LetsPlot.setup_html()" | |
] | |
}, | |
{ | |
"cell_type": "code", | |
"execution_count": 2, | |
"metadata": {}, | |
"outputs": [], | |
"source": [ | |
"def black_scholes_euro_price(spot: float,\n", | |
" strike: float,\n", | |
" rfr: float,\n", | |
" vol: float,\n", | |
" maturity_days: int,\n", | |
" tenor: int = 365,\n", | |
" option_type: str = 'call') -> float:\n", | |
" \"\"\"\n", | |
" Calculate the Black-Scholes price for a European call or put option.\n", | |
" \n", | |
" Parameters:\n", | |
" - spot: Current stock price (S)\n", | |
" - strike: Strike price (K)\n", | |
" - rfr: Risk-free interest rate (r) as a decimal (e.g., 0.05 for 5%)\n", | |
" - vol: Volatility of the underlying stock (σ) as a decimal (e.g., 0.2 for 20%)\n", | |
" - maturity_days: Time to maturity in days (T)\n", | |
" - tenor: Number of days in a year (e.g., 365 for daily)\n", | |
" - option_type: 'call' or 'put'\n", | |
" \n", | |
" Returns:\n", | |
" - Option price\n", | |
" \"\"\"\n", | |
" # Convert maturity from days to years\n", | |
" maturity = maturity_days / tenor\n", | |
"\n", | |
" # Calculate d1 and d2\n", | |
" d1 = (math.log(spot / strike) + (rfr + 0.5 * vol ** 2) * maturity) / (vol * math.sqrt(maturity))\n", | |
" d2 = d1 - vol * math.sqrt(maturity)\n", | |
"\n", | |
" # Calculate N(d1) and N(d2) for call, or N(-d1) and N(-d2) for put\n", | |
" cdf = ql.CumulativeNormalDistribution()\n", | |
" if option_type.lower() == 'call':\n", | |
" N_d1 = cdf(d1)\n", | |
" N_d2 = cdf(d2)\n", | |
" # Call option price\n", | |
" price = spot * N_d1 - strike * math.exp(-rfr * maturity) * N_d2\n", | |
" elif option_type.lower() == 'put':\n", | |
" N_minus_d1 = cdf(-d1)\n", | |
" N_minus_d2 = cdf(-d2)\n", | |
" # Put option price\n", | |
" price = strike * math.exp(-rfr * maturity) * N_minus_d2 - spot * N_minus_d1\n", | |
" else:\n", | |
" raise ValueError(\"option_type must be 'call' or 'put'\")\n", | |
"\n", | |
" return price\n" | |
] | |
}, | |
{ | |
"cell_type": "code", | |
"execution_count": 4, | |
"metadata": {}, | |
"outputs": [], | |
"source": [ | |
"# Let's plot the Black-Scholes Euro price for the URA\n", | |
"# call option in Jan'26 against a varying implied volatility\n", | |
"# value to better understand what the market is offering\n", | |
"# us right now (well not technically right now, technically\n", | |
"# as of the close on Fri Sep 6th, 2024, but you know what\n", | |
"# I mean)." | |
] | |
}, | |
{ | |
"cell_type": "code", | |
"execution_count": 5, | |
"metadata": {}, | |
"outputs": [], | |
"source": [ | |
"spot = 23.18\n", | |
"strike = 20\n", | |
"risk_free_rate = 0.0533\n", | |
"maturity_days = 496\n", | |
"tenor = 365\n", | |
"option_type = 'call'\n", | |
"implied_vols = [\n", | |
" 0.25,\n", | |
" 0.26,\n", | |
" 0.27,\n", | |
" 0.28,\n", | |
" 0.29,\n", | |
" 0.30,\n", | |
" 0.31,\n", | |
" 0.32,\n", | |
" 0.33,\n", | |
" 0.34,\n", | |
" 0.35,\n", | |
" 0.36,\n", | |
" 0.37,\n", | |
" 0.38,\n", | |
" 0.39,\n", | |
" 0.40\n", | |
"]" | |
] | |
}, | |
{ | |
"cell_type": "code", | |
"execution_count": 6, | |
"metadata": {}, | |
"outputs": [ | |
{ | |
"data": { | |
"text/html": [ | |
" <div id=\"QwIhdN\"></div>\n", | |
" <script type=\"text/javascript\" data-lets-plot-script=\"plot\">\n", | |
" (function() {\n", | |
" var plotSpec={\n", | |
"\"data\":{\n", | |
"\"implied_vol\":[0.25,0.26,0.27,0.28,0.29,0.3,0.31,0.32,0.33,0.34,0.35,0.36,0.37,0.38,0.39,0.4],\n", | |
"\"price\":[5.35970318421672,5.432328142434251,5.506376153646071,5.581708642535213,5.658201834535841,5.735744952652185,5.814238637813466,5.893593569811271,5.973729265687588,6.054573033650895,6.136059062472778,6.218127628438625,6.300724404055801,6.383799854734672,6.467308711492144,6.551209509362076]\n", | |
"},\n", | |
"\"mapping\":{\n", | |
"\"x\":\"implied_vol\",\n", | |
"\"y\":\"price\"\n", | |
"},\n", | |
"\"data_meta\":{\n", | |
"},\n", | |
"\"ggtitle\":{\n", | |
"\"text\":\"BS Euro Prices vs. Implied Volatility\"\n", | |
"},\n", | |
"\"theme\":{\n", | |
"\"legend_position\":\"bottom\",\n", | |
"\"plot_title\":{\n", | |
"\"size\":18.0,\n", | |
"\"hjust\":0.5,\n", | |
"\"blank\":false\n", | |
"}\n", | |
"},\n", | |
"\"kind\":\"plot\",\n", | |
"\"scales\":[{\n", | |
"\"name\":\"Implied Volatility\",\n", | |
"\"aesthetic\":\"x\"\n", | |
"},{\n", | |
"\"name\":\"Option Price\",\n", | |
"\"aesthetic\":\"y\"\n", | |
"}],\n", | |
"\"layers\":[{\n", | |
"\"geom\":\"line\",\n", | |
"\"mapping\":{\n", | |
"},\n", | |
"\"data_meta\":{\n", | |
"},\n", | |
"\"color\":\"blue\",\n", | |
"\"data\":{\n", | |
"}\n", | |
"}],\n", | |
"\"metainfo_list\":[]\n", | |
"};\n", | |
" var plotContainer = document.getElementById(\"QwIhdN\");\n", | |
" window.letsPlotCall(function() {{\n", | |
" LetsPlot.buildPlotFromProcessedSpecs(plotSpec, -1, -1, plotContainer);\n", | |
" }});\n", | |
" })();\n", | |
" </script>" | |
], | |
"text/plain": [ | |
"<lets_plot.plot.core.PlotSpec at 0x7f8c90ba2050>" | |
] | |
}, | |
"execution_count": 6, | |
"metadata": {}, | |
"output_type": "execute_result" | |
} | |
], | |
"source": [ | |
"bs_euro_option_prices = []\n", | |
"for implied_vol in implied_vols:\n", | |
" price = black_scholes_euro_price(spot,\n", | |
" strike,\n", | |
" risk_free_rate,\n", | |
" implied_vol,\n", | |
" maturity_days,\n", | |
" tenor,\n", | |
" option_type)\n", | |
" bs_euro_option_prices.append(price)\n", | |
"\n", | |
"data = {\n", | |
" 'implied_vol': implied_vols,\n", | |
" 'price': bs_euro_option_prices\n", | |
"}\n", | |
"df = pl.DataFrame(data)\n", | |
"\n", | |
"ggplot(df, aes(x='implied_vol', y='price')) + \\\n", | |
" geom_line(color='blue') + \\\n", | |
" ggtitle('BS Euro Prices vs. Implied Volatility') + \\\n", | |
" labs(x='Implied Volatility', y='Option Price') + \\\n", | |
" theme(plot_title=element_text(size=18, hjust=0.5), legend_position='bottom')" | |
] | |
}, | |
{ | |
"cell_type": "code", | |
"execution_count": 7, | |
"metadata": {}, | |
"outputs": [], | |
"source": [ | |
"# The market pricing is 6.00x6.40 for this option with\n", | |
"# non-zero volume (13 contracts traded on Fri Sep 6th)\n", | |
"# so we are certain that we can buy the 20C for 6.40\n", | |
"# and sell the 20C for 6.00.\n", | |
"#\n", | |
"# Using the interactive chart above, we see that at\n", | |
"# 0.34 implied vol, the BS Euro price is 6.05, close enough\n", | |
"# to the 6.00 bid, and at 0.38 implied vol, the BS Euro\n", | |
"# price is 6.38, close enough to the 6.40 ask.\n", | |
"#\n", | |
"# From this we can infer that your counterparty's model\n", | |
"# is predicting that 0.36 implied vol is roughly \"fair\"\n", | |
"# and in order to trade profitably against you, they will\n", | |
"# buy the option from you at a 0.34 implied vol or they will\n", | |
"# sell the option to you at a 0.38 implied vol, a 2 vol point\n", | |
"# edge in their favor in either direction.\n", | |
"#\n", | |
"# Remember that your counterparty is not a directional trader\n", | |
"# and this volatility edge is how they stay in business. Your\n", | |
"# trade will be delta-hedged on their book, while your trade\n", | |
"# is likely a levered directional bet (i.e. a delta bet).\n", | |
"#\n", | |
"# Let's assume you decide to take the trade and fill at 6.40.\n", | |
"# Suppose that 0.34 implied vol is \"fair\" and that the market\n", | |
"# will continue to demand a 2 vol point edge in either direction:\n", | |
"# What will the projected price of the BS Euro option be at\n", | |
"# a variety of underlying prices 90 calendar days from now, and\n", | |
"# how much difference in that price exists between \"fair\"\n", | |
"# implied volatility at 0.34 and our counterparty's 2 vol point edge?" | |
] | |
}, | |
{ | |
"cell_type": "code", | |
"execution_count": 8, | |
"metadata": {}, | |
"outputs": [], | |
"source": [ | |
"spots = [\n", | |
" 15,\n", | |
" 16,\n", | |
" 17,\n", | |
" 18,\n", | |
" 19,\n", | |
" 20,\n", | |
" 21,\n", | |
" 22,\n", | |
" 23,\n", | |
" 24,\n", | |
" 25,\n", | |
" 26,\n", | |
" 27,\n", | |
" 28,\n", | |
" 29,\n", | |
" 30\n", | |
"]\n", | |
"strike = 20\n", | |
"risk_free_rate = 0.0533\n", | |
"maturity_days = 406 # 90 calendar days forward from now\n", | |
"tenor = 365\n", | |
"option_type = 'call'\n", | |
"fair_implied_vol = 0.34\n", | |
"edge_implied_vol = 0.32 # 2 vol pts below 0.34 \"fair\" implied vol" | |
] | |
}, | |
{ | |
"cell_type": "code", | |
"execution_count": 9, | |
"metadata": {}, | |
"outputs": [ | |
{ | |
"data": { | |
"text/html": [ | |
" <div id=\"35scxG\"></div>\n", | |
" <script type=\"text/javascript\" data-lets-plot-script=\"plot\">\n", | |
" (function() {\n", | |
" var plotSpec={\n", | |
"\"data\":{\n", | |
"\"spot\":[15.0,16.0,17.0,18.0,19.0,20.0,21.0,22.0,23.0,24.0,25.0,26.0,27.0,28.0,29.0,30.0,15.0,16.0,17.0,18.0,19.0,20.0,21.0,22.0,23.0,24.0,25.0,26.0,27.0,28.0,29.0,30.0],\n", | |
"\"price\":[0.9489119309295426,1.306026313770456,1.7298818515560912,2.2186723204813097,2.7691058878838444,3.3768661085887697,4.0370242515954295,4.744380213431745,5.493726019351287,6.280036314916746,7.098595714057575,7.945074916119321,8.815567429263192,9.706597508711308,10.615108191839576,11.53843649391819,0.8365605815431771,1.1771991621764624,1.5879032176753798,2.0673346246465583,2.612292948639068,3.2182571060878633,3.8798899180178186,4.591465661672345,5.347205373155996,6.141521009376321,6.969178858756253,7.825396673345155,8.705889691972214,9.606879483883334,10.525077391654833,11.457651948222225],\n", | |
"\"dataset\":[\"fair\",\"fair\",\"fair\",\"fair\",\"fair\",\"fair\",\"fair\",\"fair\",\"fair\",\"fair\",\"fair\",\"fair\",\"fair\",\"fair\",\"fair\",\"fair\",\"edge\",\"edge\",\"edge\",\"edge\",\"edge\",\"edge\",\"edge\",\"edge\",\"edge\",\"edge\",\"edge\",\"edge\",\"edge\",\"edge\",\"edge\",\"edge\"]\n", | |
"},\n", | |
"\"mapping\":{\n", | |
"},\n", | |
"\"data_meta\":{\n", | |
"},\n", | |
"\"ggtitle\":{\n", | |
"\"text\":\"BS Euro Prices vs. Spot Price at T+90\"\n", | |
"},\n", | |
"\"theme\":{\n", | |
"\"legend_position\":\"bottom\",\n", | |
"\"plot_title\":{\n", | |
"\"size\":18.0,\n", | |
"\"hjust\":0.5,\n", | |
"\"blank\":false\n", | |
"}\n", | |
"},\n", | |
"\"kind\":\"plot\",\n", | |
"\"scales\":[{\n", | |
"\"aesthetic\":\"color\",\n", | |
"\"values\":[\"gray\",\"magenta\"]\n", | |
"},{\n", | |
"\"name\":\"Spot Price\",\n", | |
"\"aesthetic\":\"x\"\n", | |
"},{\n", | |
"\"name\":\"Option Price\",\n", | |
"\"aesthetic\":\"y\"\n", | |
"}],\n", | |
"\"layers\":[{\n", | |
"\"geom\":\"line\",\n", | |
"\"mapping\":{\n", | |
"\"x\":\"spot\",\n", | |
"\"y\":\"price\",\n", | |
"\"color\":\"dataset\"\n", | |
"},\n", | |
"\"data_meta\":{\n", | |
"},\n", | |
"\"data\":{\n", | |
"}\n", | |
"}],\n", | |
"\"metainfo_list\":[]\n", | |
"};\n", | |
" var plotContainer = document.getElementById(\"35scxG\");\n", | |
" window.letsPlotCall(function() {{\n", | |
" LetsPlot.buildPlotFromProcessedSpecs(plotSpec, -1, -1, plotContainer);\n", | |
" }});\n", | |
" })();\n", | |
" </script>" | |
], | |
"text/plain": [ | |
"<lets_plot.plot.core.PlotSpec at 0x7f8c90ba1240>" | |
] | |
}, | |
"execution_count": 9, | |
"metadata": {}, | |
"output_type": "execute_result" | |
} | |
], | |
"source": [ | |
"bs_fair_euro_option_prices = []\n", | |
"bs_edge_euro_option_prices = []\n", | |
"for spot in spots:\n", | |
" fair_price = black_scholes_euro_price(\n", | |
" spot,\n", | |
" strike,\n", | |
" risk_free_rate,\n", | |
" fair_implied_vol,\n", | |
" maturity_days,\n", | |
" tenor,\n", | |
" option_type\n", | |
" )\n", | |
" bs_fair_euro_option_prices.append(fair_price)\n", | |
"\n", | |
" edge_price = black_scholes_euro_price(\n", | |
" spot,\n", | |
" strike,\n", | |
" risk_free_rate,\n", | |
" edge_implied_vol,\n", | |
" maturity_days,\n", | |
" tenor,\n", | |
" option_type\n", | |
" )\n", | |
" bs_edge_euro_option_prices.append(edge_price)\n", | |
"\n", | |
"fair_data = {\n", | |
" 'spot': spots,\n", | |
" 'price': bs_fair_euro_option_prices,\n", | |
" 'dataset': ['fair'] * len(spots)\n", | |
"}\n", | |
"fair_df = pl.DataFrame(fair_data)\n", | |
"\n", | |
"edge_data = {\n", | |
" 'spot': spots,\n", | |
" 'price': bs_edge_euro_option_prices,\n", | |
" 'dataset': ['edge'] * len(spots)\n", | |
"}\n", | |
"edge_df = pl.DataFrame(edge_data)\n", | |
"\n", | |
"combined_df = pl.concat([fair_df, edge_df])\n", | |
"\n", | |
"ggplot(combined_df) + \\\n", | |
" geom_line(aes(x='spot', y='price', color='dataset')) + \\\n", | |
" scale_color_manual(values=['gray', 'magenta']) + \\\n", | |
" ggtitle('BS Euro Prices vs. Spot Price at T+90') + \\\n", | |
" labs(x='Spot Price', y='Option Price') + \\\n", | |
" theme(plot_title=element_text(size=18, hjust=0.5), legend_position='bottom')\n" | |
] | |
}, | |
{ | |
"cell_type": "code", | |
"execution_count": 10, | |
"metadata": {}, | |
"outputs": [], | |
"source": [ | |
"# So how much damage does this 2 vol point edge really do?\n", | |
"# At 90 days, almost none lol. You would be better off spending\n", | |
"# your time improving your forecasts for price than hand-wringing\n", | |
"# over the \"fair\" price of an option as a retail trader.\n", | |
"#\n", | |
"# If you're a professional working on a volatility trading\n", | |
"# desk, that's a different story. This edge can be meaningful\n", | |
"# as you trade large quantities of contracts (e.g. 1000+) and \n", | |
"# have the capital necessary to hedge your deltas effectively.\n", | |
"# Time spent building a faster, more accurate volatility model\n", | |
"# for the surface of a particular security can be lucrative." | |
] | |
} | |
], | |
"metadata": { | |
"kernelspec": { | |
"display_name": "dans-magic-house-uvW-KuUb-py3.10", | |
"language": "python", | |
"name": "python3" | |
}, | |
"language_info": { | |
"codemirror_mode": { | |
"name": "ipython", | |
"version": 3 | |
}, | |
"file_extension": ".py", | |
"mimetype": "text/x-python", | |
"name": "python", | |
"nbconvert_exporter": "python", | |
"pygments_lexer": "ipython3", | |
"version": "3.10.12" | |
} | |
}, | |
"nbformat": 4, | |
"nbformat_minor": 2 | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment