├── .github └── workflows │ └── test_build_publish.yaml ├── .gitignore ├── LICENSE ├── README.md ├── notebooks ├── Lognormal SABR vs Normal SABR.ipynb ├── Lognormal SABR vs Normal SABR.pdf └── Lognormal SABR vs Normal SABR.png ├── papers ├── Andreasen - ZABR - 2011.pdf ├── Antonov - Free SABR - 2015.pdf ├── Antonov - Mixture SABR - 2015.pdf ├── Hagan - Arbitrage Free SABR - June 2013 (latest).pdf ├── Hagan - Managing Smile Risk - 2002.pdf └── Hagan - Universal Smiles - 2016.pdf ├── pysabr ├── __init__.py ├── black.py ├── examples │ ├── option_expiries.csv │ ├── premiums.csv │ └── vols.csv ├── helpers.py └── models │ ├── __init__.py │ ├── base_sabr.py │ ├── hagan_2002_lognormal_sabr.py │ ├── hagan_2002_normal_sabr.py │ └── hagan_2013_normal_sabr.py ├── pytest.ini ├── requirements.txt ├── setup.py ├── tests ├── black │ ├── test_conversion_generic.py │ ├── test_conversion_round_trip.py │ ├── test_lognormal.py │ ├── test_normal.py │ └── test_performance.py ├── hagan_2002_lognormal_sabr │ ├── conftest.py │ ├── test_alpha.py │ ├── test_cube.py │ ├── test_density.py │ ├── test_fit.py │ └── test_interpolation.py ├── hagan_2002_normal_sabr │ ├── test_h2002n_alpha.py │ ├── test_h2002n_interpolation.py │ └── test_normal_fit.py └── limit_cases │ └── test_flat_smile.py └── web ├── app.py ├── pySABR_web.bas └── test_web.py /.github/workflows/test_build_publish.yaml: -------------------------------------------------------------------------------- 1 | name: Build, Test & Publish 2 | 3 | on: 4 | push 5 | 6 | jobs: 7 | build_test_publish: 8 | name: Build, Test & Publish 9 | runs-on: ubuntu-latest 10 | 11 | steps: 12 | - name: Checkout 13 | uses: actions/checkout@v3 14 | 15 | - name: Setup Python 16 | uses: actions/setup-python@v3 17 | with: 18 | python-version: "3.10" 19 | 20 | - name: Build 21 | run: | 22 | pip install wheel 23 | python setup.py bdist_wheel 24 | 25 | - name: Install 26 | run: | 27 | rm -rf pysabr/**/*py # needed to ensure we work from the wheel 28 | pip install dist/*.whl # install wheel 29 | pip install pytest pandas # test pre-requisites 30 | 31 | - name: Test 32 | run: pytest tests 33 | 34 | - name: Publish to PyPI 35 | if: github.ref == 'refs/heads/master' 36 | uses: pypa/gh-action-pypi-publish@v1.5.0 37 | with: 38 | user: __token__ 39 | password: ${{ secrets.PYPI_API_TOKEN }} 40 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | .pytest_cache/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | 58 | # Flask stuff: 59 | instance/ 60 | .webassets-cache 61 | 62 | # Scrapy stuff: 63 | .scrapy 64 | 65 | # Sphinx documentation 66 | docs/_build/ 67 | 68 | # PyBuilder 69 | target/ 70 | 71 | # Jupyter Notebook 72 | .ipynb_checkpoints 73 | 74 | # pyenv 75 | .python-version 76 | 77 | # celery beat schedule file 78 | celerybeat-schedule 79 | 80 | # SageMath parsed files 81 | *.sage.py 82 | 83 | # dotenv 84 | .env 85 | 86 | # virtualenv 87 | .venv 88 | venv/ 89 | ENV/ 90 | 91 | # Spyder project settings 92 | .spyderproject 93 | .spyproject 94 | 95 | # Rope project settings 96 | .ropeproject 97 | 98 | # mkdocs documentation 99 | /site 100 | 101 | # mypy 102 | .mypy_cache/ 103 | 104 | # config file 105 | config.py 106 | 107 | # stuff 108 | etherscan_scraper_ex.py 109 | mongodb-users.js 110 | .DS_Store 111 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Yacine NOURI 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # pysabr 2 | Python implementation of SABR model. 3 | 4 | ![Lognormal SABR vs Normal SABR](./notebooks/Lognormal%20SABR%20vs%20Normal%20SABR.png "Lognormal SABR vs Normal SABR") 5 | 6 | 7 | # Introduction 8 | SABR (Stochastic Alpha Beta Rho) is a financial volatility smile model widely used for interest rates options such as swaptions or cap/floors. This Python library implements its Hagan 2002 specification. For more information about the model itself, please consult the [original paper](./papers/Hagan%20-%20Managing%20Smile%20Risk%20-%202002.pdf) or [Wikipedia](https://en.wikipedia.org/wiki/SABR_volatility_model). 9 | 10 | # Requirements 11 | Core pySABR functions require `numpy` & `scipy` to run. The web microservice is based on `falcon`, which can itself be run with `waitress` (Windows) or `gunicorn` (Linux). Finally, the Excel function wrapper for the web microservice requires Windows and Excel 2013+. 12 | 13 | # Installation 14 | ```bash 15 | pip install pysabr 16 | 17 | ``` 18 | 19 | # Examples 20 | 21 | `pysabr` provides two interface levels: 22 | * A high-level, SABR model object interface, that lets the user work with the standard market inputs (ATM normal vol) and easily access model results (SLN or N vols, option premiums, density). 23 | * A low-level interface to the Hagan expansions formulas and to the Black Scholes model. 24 | 25 | ## Notebook: Lognormal vs Normal SABR 26 | 27 | [This example notebook](./notebooks/Lognormal%20SABR%20vs%20Normal%20SABR.ipynb) runs an interesting comparison between the Lognormal and Normal SABR expansions available in Hagan's 2002 paper. Make sure to check it out! 28 | 29 | ## SABR model object 30 | 31 | Interpolate a volatility using ATM normal vol input: 32 | ```Python 33 | from pysabr import Hagan2002LognormalSABR 34 | # Forward = 2.5%, Shift = 3%, ATM Normal Vol = 40bps 35 | # Beta = 0.5, Rho = -20%, Volvol = 0.30 36 | sabr = Hagan2002LognormalSABR(f=0.025, shift=0.03, t=1., v_atm_n=0.0040, 37 | beta=0.5, rho=-0.2, volvol=0.30) 38 | k = 0.025 39 | sabr.lognormal_vol(k) * 100 40 | # returns 7.27 41 | sabr.normal_vol(k) *1e4 42 | # returns 40 43 | ``` 44 | 45 | Calibrate alpha, rho and volvol from a discrete shift-lognormal smile: 46 | ```Python 47 | from pysabr import Hagan2002LognormalSABR 48 | import numpy as np 49 | sabr = Hagan2002LognormalSABR(f=2.5271/100, shift=3/100, t=10, beta=0.5) 50 | k = np.array([-0.4729, 0.5271, 1.0271, 1.5271, 1.7771, 2.0271, 2.2771, 2.4021, 51 | 2.5271, 2.6521, 2.7771, 3.0271, 3.2771, 3.5271, 4.0271, 4.5271, 52 | 5.5271]) / 100 53 | v_sln = np.array([19.641923, 15.785344, 14.305103, 13.073869, 12.550007, 12.088721, 54 | 11.691661, 11.517660, 11.360133, 11.219058, 11.094293, 10.892464, 55 | 10.750834, 10.663653, 10.623862, 10.714479, 11.103755]) 56 | [alpha, rho, volvol] = sabr.fit(k, v_sln) 57 | # returns [0.025299981543599154, -0.24629917636394097, 0.2908005625794777] 58 | ``` 59 | 60 | ## Hagan 2002 lognormal expansion 61 | 62 | Interpolate a shifted-lognormal volatility: 63 | ```Python 64 | from pysabr import hagan_2002_lognormal_sabr as hagan2002 65 | [s, k, f, t, alpha, beta, rho, volvol] = [0.03, 0.02, 0.025, 1.0, 0.025, 0.50, -0.24, 0.29] 66 | hagan2002.lognormal_vol(k + s, f + s, t, alpha, beta, rho, volvol) 67 | # returns 0.11408307 68 | ``` 69 | 70 | Calibrate alpha from an ATM lognormal vol: 71 | ```Python 72 | from pysabr import hagan_2002_lognormal_sabr as hagan2002 73 | [v_atm_sln, f, t, beta, rho, volvol] = [0.60, 0.02, 1.5, 1.0, 0.0, 0.0] 74 | hagan2002.alpha(v_atm_sln, f, t, beta, rho, volvol) 75 | # returns 0.60 76 | ``` 77 | 78 | ## Black Scholes 79 | 80 | Compute an option premium using Black formula: 81 | ```Python 82 | from pysabr import black 83 | [k, f, t, v, r, cp] = [0.012, 0.013, 10., 0.20, 0.02, 'call'] 84 | black.lognormal_call(k, f, t, v, r, cp) * 1e5 85 | # returns 296.8806106707276 86 | ``` 87 | 88 | # Web microservice 89 | 90 | pySABR includes a web microservice exposing the two main functions of the library: volatility interpolation and alpha calibration. Those two 91 | functions are available through a simple REST API: 92 | 93 | ```bash 94 | # Returns a lognormal vol 95 | curl http://127.0.0.1:5000/sabr?k=1.0&f=1.0&t=1.0&a=0.20&b=1.0&r=0.0&n=0.2 96 | 97 | # Returns a calibrated alpha parameter 98 | curl 99 | http://127.0.0.1:5000/alpha?v=0.6&f=1.0&t=1.0&b=1.0&r=0.0&n=0.2 100 | ``` 101 | 102 | To run the microservice on Linux: 103 | ```bash 104 | gunicorn -b '0.0.0.0:5000' web.app:app &>> pysabr_web.log & 105 | ``` 106 | 107 | To run the microservice on Windows: 108 | ```bash 109 | python -mwaitress --port=5000 web.app:app 110 | ``` 111 | 112 | # Excel wrapper 113 | 114 | The web microservice can conveniently be called from Excel 2013+ using the ```WEBSERVICE``` spreadsheet function. For even more convenience, pySABR provides a small VBA wrapper mapping directly to the /sabr and /alpha resources. VBA code is available under [pySABR_web.bas](./web/pySABR_web.bas) 115 | 116 | 117 | # Run the tests 118 | ```bash 119 | $ python -m pytest 120 | ``` 121 | -------------------------------------------------------------------------------- /notebooks/Lognormal SABR vs Normal SABR.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ynouri/pysabr/be22436625f4c0b605f362444b7aafeecda15959/notebooks/Lognormal SABR vs Normal SABR.pdf -------------------------------------------------------------------------------- /notebooks/Lognormal SABR vs Normal SABR.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ynouri/pysabr/be22436625f4c0b605f362444b7aafeecda15959/notebooks/Lognormal SABR vs Normal SABR.png -------------------------------------------------------------------------------- /papers/Andreasen - ZABR - 2011.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ynouri/pysabr/be22436625f4c0b605f362444b7aafeecda15959/papers/Andreasen - ZABR - 2011.pdf -------------------------------------------------------------------------------- /papers/Antonov - Free SABR - 2015.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ynouri/pysabr/be22436625f4c0b605f362444b7aafeecda15959/papers/Antonov - Free SABR - 2015.pdf -------------------------------------------------------------------------------- /papers/Antonov - Mixture SABR - 2015.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ynouri/pysabr/be22436625f4c0b605f362444b7aafeecda15959/papers/Antonov - Mixture SABR - 2015.pdf -------------------------------------------------------------------------------- /papers/Hagan - Arbitrage Free SABR - June 2013 (latest).pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ynouri/pysabr/be22436625f4c0b605f362444b7aafeecda15959/papers/Hagan - Arbitrage Free SABR - June 2013 (latest).pdf -------------------------------------------------------------------------------- /papers/Hagan - Managing Smile Risk - 2002.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ynouri/pysabr/be22436625f4c0b605f362444b7aafeecda15959/papers/Hagan - Managing Smile Risk - 2002.pdf -------------------------------------------------------------------------------- /papers/Hagan - Universal Smiles - 2016.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ynouri/pysabr/be22436625f4c0b605f362444b7aafeecda15959/papers/Hagan - Universal Smiles - 2016.pdf -------------------------------------------------------------------------------- /pysabr/__init__.py: -------------------------------------------------------------------------------- 1 | """pysabr - Python implementation of the SABR model.""" 2 | 3 | from .models import hagan_2002_lognormal_sabr 4 | from .models.hagan_2002_lognormal_sabr import Hagan2002LognormalSABR 5 | from .models import hagan_2002_normal_sabr 6 | from .models.hagan_2002_normal_sabr import Hagan2002NormalSABR 7 | 8 | 9 | __all__ = ["hagan_2002_lognormal_sabr", 10 | "Hagan2002LognormalSABR", 11 | "hagan_2002_normal_sabr", 12 | "Hagan2002NormalSABR"] 13 | -------------------------------------------------------------------------------- /pysabr/black.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | from scipy.stats import norm 3 | from scipy.optimize import minimize 4 | 5 | 6 | def lognormal_call(k, f, t, v, r, cp='call'): 7 | """Compute an option premium using a lognormal vol.""" 8 | if k <= 0 or f <= 0 or t <= 0 or v <= 0: 9 | return 0. 10 | d1 = (np.log(f/k) + v**2 * t/2) / (v * t**0.5) 11 | d2 = d1 - v * t**0.5 12 | if cp == 'call': 13 | pv = np.exp(-r*t) * (f * norm.cdf(d1) - k * norm.cdf(d2)) 14 | elif cp == 'put': 15 | pv = np.exp(-r*t) * (-f * norm.cdf(-d1) + k * norm.cdf(-d2)) 16 | else: 17 | pv = 0 18 | return pv 19 | 20 | 21 | def shifted_lognormal_call(k, f, s, t, v, r, cp='call'): 22 | """Compute an option premium using a shifted-lognormal vol.""" 23 | return lognormal_call(k+s, f+s, t, v, r, cp) 24 | 25 | 26 | def normal_call(k, f, t, v, r, cp='call'): 27 | """Compute the premium for a call or put option using a normal vol.""" 28 | d1 = (f - k) / (v * t**0.5) 29 | cp_sign = {'call': 1., 'put': -1.}[cp] 30 | pv = np.exp(-r*t) * ( 31 | cp_sign * (f - k) * norm.cdf(cp_sign * d1) + 32 | v * (t / (2 * np.pi))**0.5 * np.exp(-d1**2 / 2)) 33 | return pv 34 | 35 | 36 | def normal_to_shifted_lognormal(k, f, s, t, v_n): 37 | """Convert a normal vol for a given strike to a shifted lognormal vol.""" 38 | n = 1e2 # Plays an important role in the optimizer convergence. 39 | eps = 1e-07 # Numerical tolerance for K=F 40 | 41 | # If K=F, use simple first guess 42 | if abs(k-f) <= eps: 43 | v_sln_0 = v_n / (f + s) 44 | # Else, use Hagan's formula first guess 45 | else: 46 | v_sln_0 = hagan_normal_to_lognormal(k, f, s, t, v_n) 47 | 48 | target_premium = n * normal_call(k, f, t, v_n, 0.) 49 | 50 | def premium_square_error(v_sln): 51 | premium = n * shifted_lognormal_call(k, f, s, t, v_sln, 0.) 52 | return (premium - target_premium) ** 2 53 | 54 | res = minimize( 55 | fun=premium_square_error, 56 | x0=v_sln_0, 57 | jac=None, 58 | options={'gtol': 1e-8, 59 | 'eps': 1e-9, 60 | 'maxiter': 10, 61 | 'disp': False}, 62 | method='CG' 63 | ) 64 | return res.x[0] 65 | 66 | 67 | def hagan_normal_to_lognormal(k, f, s, t, v_n): 68 | """ 69 | Convert N vol to SLN using Hagan's 2002 paper formula (B.63). 70 | 71 | Warning: this function was initially implemented for performance gains, but 72 | its current implementation is actually very slow. For this reason it won't 73 | be used as a first guess in the normal_to_shifted_lognormal function. 74 | """ 75 | k = k + s 76 | f = f + s 77 | # Handle the ATM K=F case 78 | if abs(np.log(f/k)) <= 1e-8: 79 | factor = k 80 | else: 81 | factor = (f - k) / np.log(f/k) 82 | p = [ 83 | factor * (-1/24) * t, 84 | 0., 85 | factor, 86 | -v_n 87 | ] 88 | roots = np.roots(p) 89 | roots_real = np.extract(np.isreal(roots), np.real(roots)) 90 | v_sln_0 = v_n / f 91 | i_min = np.argmin(np.abs(roots_real - v_sln_0)) 92 | return roots_real[i_min] 93 | 94 | 95 | def hagan_lognormal_to_normal(k, f, s, t, v_sln): 96 | """Convert N vol to SLN using Hagan's 2002 paper formula (B.63).""" 97 | k = k + s 98 | f = f + s 99 | logfk = np.log(f/k) 100 | A = v_sln * np.sqrt(f*k) 101 | B = (1/24) * logfk**2 102 | C = (1/1920) * logfk**4 103 | D = (1/24) * (1 - (1/120) * logfk**2) * v_sln**2 * t 104 | E = (1/5760) * v_sln**4 * t**2 105 | v_n = A * (1 + B + C) / (1 + D + E) 106 | return v_n 107 | 108 | 109 | def shifted_lognormal_to_normal(k, f, s, t, v_sln): 110 | """Convert a normal vol for a given strike to a shifted lognormal vol.""" 111 | n = 1e2 # Plays an important role in the optimizer convergence. 112 | target_premium = n * shifted_lognormal_call(k, f, s, t, v_sln, 0.) 113 | # v_n_0 = v_sln * (f + s) 114 | v_n_0 = hagan_lognormal_to_normal(k, f, s, t, v_sln) 115 | 116 | def premium_square_error(v_n): 117 | premium = n * normal_call(k, f, t, v_n, 0.) 118 | return (premium - target_premium) ** 2 119 | 120 | res = minimize( 121 | fun=premium_square_error, 122 | x0=v_n_0, 123 | jac=None, 124 | options={'gtol': 1e-8, 125 | 'eps': 1e-9, 126 | 'maxiter': 10, 127 | 'disp': False}, 128 | method='CG' 129 | ) 130 | return res.x[0] 131 | 132 | 133 | def lognormal_to_lognormal(k, f, s, t, v_u_sln, u): 134 | """Convert a (u shifted) SLN vol to a (s shifted) SLN vol.""" 135 | n = 1e2 # Plays an important role in the optimizer convergence. 136 | 137 | # Use simple first guess 138 | v_sln_0 = v_u_sln * (f + u) / (f + s) 139 | 140 | target_premium = n * shifted_lognormal_call(k, f, u, t, v_u_sln, 0.) 141 | 142 | def premium_square_error(v_sln): 143 | premium = n * shifted_lognormal_call(k, f, s, t, v_sln, 0.) 144 | return (premium - target_premium) ** 2 145 | 146 | res = minimize( 147 | fun=premium_square_error, 148 | x0=v_sln_0, 149 | jac=None, 150 | options={'gtol': 1e-8, 151 | 'eps': 1e-9, 152 | 'maxiter': 10, 153 | 'disp': False}, 154 | method='CG' 155 | ) 156 | return res.x[0] 157 | -------------------------------------------------------------------------------- /pysabr/examples/option_expiries.csv: -------------------------------------------------------------------------------- 1 | Option_expiry,Year_frac 2 | 1D,0.002739726 3 | 1W,0.021917808 4 | 1M,0.084931507 5 | 2M,0.17260274 6 | 3M,0.246575342 7 | 6M,0.498630137 8 | 9M,0.750684932 9 | 1Y,1 10 | 1Y6M,1.498630137 11 | 2Y,2 12 | 3Y,3.008219178 13 | 5Y,5.002739726 14 | 7Y,7.005479452 15 | 10Y,10.00821918 16 | 15Y,15.01369863 17 | 20Y,20.01917808 18 | 30Y,30.01917808 19 | -------------------------------------------------------------------------------- /pysabr/examples/vols.csv: -------------------------------------------------------------------------------- 1 | Type,Option_expiry,1M,3M,6M,1Y,2Y,3Y,5Y,7Y,10Y,15Y,20Y,25Y,30Y,50Y 2 | Forward,1D,1.561711474,1.643005166,1.737452756,1.883433665,2.060063211,2.155619976,2.26604402,2.345456895,2.444243788,2.549446916,2.599291909,2.614452282,2.613134258,2.593894687 3 | Forward,1W,1.559876358,1.650249151,1.748991096,1.893086296,2.064035488,2.158672795,2.266940634,2.346391767,2.445133479,2.549156131,2.598654611,2.61335289,2.611763984,2.592427717 4 | Forward,1M,1.566361244,1.679972815,1.779965558,1.926593798,2.089900969,2.177813947,2.281386288,2.358069417,2.454511645,2.556814863,2.604981791,2.618919156,2.616853781,2.59663857 5 | Forward,2M,1.44083669,1.702713445,1.827713673,1.967215669,2.117715538,2.197356793,2.29529375,2.369823651,2.46377355,2.56354542,2.610180405,2.623052537,2.620287417,2.599208887 6 | Forward,3M,1.643599463,1.822928889,1.905798465,2.003287496,2.144554245,2.214944238,2.307595983,2.380003989,2.472767768,2.569660953,2.61496366,2.626871301,2.62366595,2.601745889 7 | Forward,6M,1.750756071,1.980615261,2.027154458,2.107761083,2.211085624,2.265625093,2.343784288,2.410917466,2.497509557,2.587655161,2.628683456,2.637616767,2.632521939,2.608365775 8 | Forward,9M,1.967745639,2.064517766,2.101696431,2.184717484,2.259489157,2.304519313,2.372564623,2.436096882,2.51819396,2.602641834,2.639924416,2.646274582,2.639534909,2.613648095 9 | Forward,1Y,2.130978311,2.129004664,2.189154168,2.240165988,2.295920316,2.33524151,2.396563046,2.457747356,2.536488286,2.615721865,2.649627872,2.653697044,2.645485642,2.618238553 10 | Forward,1Y6M,2.115558219,2.284589088,2.291137124,2.316227325,2.34700786,2.378586291,2.435950717,2.493374093,2.568326041,2.637406638,2.665243919,2.665597295,2.654601944,2.625433679 11 | Forward,2Y,2.281549406,2.319317013,2.341840991,2.353413519,2.384741333,2.410989425,2.468428069,2.524733551,2.596729546,2.656055148,2.678064033,2.674929655,2.661612667,2.63113952 12 | Forward,3Y,2.33513386,2.348845446,2.392631871,2.416738747,2.440708321,2.467864934,2.527724621,2.582035612,2.643818,2.687891111,2.699323463,2.689793304,2.672409537,2.640430159 13 | Forward,5Y,2.46050771,2.457634255,2.499793364,2.522866842,2.559062185,2.588700561,2.642833198,2.685295236,2.716678314,2.737963676,2.729282509,2.709582405,2.685741841,2.653674893 14 | Forward,7Y,2.606558681,2.585780782,2.629327821,2.650244144,2.676954134,2.702191555,2.740328491,2.757151117,2.767228874,2.765730181,2.743302947,2.715520463,2.68770219,2.659294352 15 | Forward,10Y,2.73270766,2.735851604,2.759683559,2.770658686,2.800733111,2.805733628,2.799769291,2.798518843,2.794629143,2.765571674,2.731743513,2.697735944,2.671462807,2.653172403 16 | Forward,15Y,2.745116325,2.742033635,2.78226107,2.79234068,2.795120939,2.79513903,2.78884495,2.77216592,2.745215744,2.703258326,2.663977727,2.635728827,2.627305608,2.624256103 17 | Forward,20Y,2.67907865,2.703587448,2.738298033,2.739262431,2.726832637,2.717643342,2.696154346,2.678817286,2.652509265,2.611949936,2.585348699,2.582555637,2.587592255,2.59349394 18 | Forward,30Y,2.454690298,2.538008313,2.55650137,2.55169289,2.539216422,2.529846855,2.516108764,2.504986917,2.501628114,2.521276397,2.542633225,2.55549958,2.562443193,2.570425069 19 | Normal_ATM_vol,1D,21.37,20.64,20.62,25.69,36.49,42.56,49.69,51.33,52.6,52.88,53.62,54.27,54.73,54.73 20 | Normal_ATM_vol,1W,21.37,20.64,20.62,25.69,36.49,42.56,49.69,51.33,52.6,52.88,53.62,54.27,54.73,54.73 21 | Normal_ATM_vol,1M,21.37,20.64,20.62,25.69,36.49,42.56,49.69,51.33,52.6,52.88,53.62,54.27,54.73,54.73 22 | Normal_ATM_vol,2M,22.58,21.81,21.79,27.3,37.37,43.92,50.21,52.48,53.7,54.06,54.65,55.12,55.52,55.52 23 | Normal_ATM_vol,3M,23.78,22.98,22.95,28.9,38.25,45.27,50.72,53.63,54.8,55.24,55.67,55.97,56.31,56.31 24 | Normal_ATM_vol,6M,27.56,26.62,26.59,34.01,43.21,48.73,53.26,55.51,56.6,56.75,56.67,56.76,56.76,56.76 25 | Normal_ATM_vol,9M,32.2,31.11,31.07,38.33,46.67,51.42,55.41,57.07,57.92,57.54,57.26,57.05,56.92,56.92 26 | Normal_ATM_vol,1Y,36.84,35.59,35.55,42.64,50.12,54.11,57.56,58.63,59.24,58.33,57.86,57.34,57.08,57.08 27 | Normal_ATM_vol,1Y6M,43.56,42.09,42.03,47.96,53.94,57.39,59.96,60.79,61.23,59.77,58.9,58.24,57.83,57.83 28 | Normal_ATM_vol,2Y,50.29,48.58,48.52,53.29,57.76,60.67,62.36,62.95,63.22,61.21,59.95,59.15,58.58,58.58 29 | Normal_ATM_vol,3Y,63.66,61.5,61.42,61.9,64.27,64.61,65.41,65.39,65.39,62.77,60.9,59.91,59.46,59.46 30 | Normal_ATM_vol,5Y,72.65,70.19,70.1,69.51,68.67,68.71,68.57,67.73,66.7,63.17,60.63,59.77,59.26,59.26 31 | Normal_ATM_vol,7Y,73.48,70.99,70.9,69.4,69.24,69.07,68.65,67.43,65.87,61.6,59.19,58.34,57.77,57.77 32 | Normal_ATM_vol,10Y,71.95,69.51,69.42,68.2,67.41,67.3,66.43,65.27,63.51,58.89,56.44,55.88,55.37,55.37 33 | Normal_ATM_vol,15Y,67.32,65.04,64.96,61.27,60.49,60.37,59.16,58.16,56.54,52.97,51.17,50.77,50.42,50.42 34 | Normal_ATM_vol,20Y,58.39,56.41,56.34,54.76,53.37,53.26,52.42,51.63,50.25,47.62,46.5,46.24,45.96,45.96 35 | Normal_ATM_vol,30Y,40.55,39.18,39.13,47,46.46,46.31,45.87,45.72,45.55,44.14,43.48,42.7,41.56,41.56 36 | Shift,1D,2,2,2,2,2,2,2,2,2,2,2,2,2,2 37 | Shift,1W,2,2,2,2,2,2,2,2,2,2,2,2,2,2 38 | Shift,1M,2,2,2,2,2,2,2,2,2,2,2,2,2,2 39 | Shift,2M,2,2,2,2,2,2,2,2,2,2,2,2,2,2 40 | Shift,3M,2,2,2,2,2,2,2,2,2,2,2,2,2,2 41 | Shift,6M,2,2,2,2,2,2,2,2,2,2,2,2,2,2 42 | Shift,9M,2,2,2,2,2,2,2,2,2,2,2,2,2,2 43 | Shift,1Y,2,2,2,2,2,2,2,2,2,2,2,2,2,2 44 | Shift,1Y6M,2,2,2,2,2,2,2,2,2,2,2,2,2,2 45 | Shift,2Y,2,2,2,2,2,2,2,2,2,2,2,2,2,2 46 | Shift,3Y,2,2,2,2,2,2,2,2,2,2,2,2,2,2 47 | Shift,5Y,2,2,2,2,2,2,2,2,2,2,2,2,2,2 48 | Shift,7Y,2,2,2,2,2,2,2,2,2,2,2,2,2,2 49 | Shift,10Y,2,2,2,2,2,2,2,2,2,2,2,2,2,2 50 | Shift,15Y,2,2,2,2,2,2,2,2,2,2,2,2,2,2 51 | Shift,20Y,2,2,2,2,2,2,2,2,2,2,2,2,2,2 52 | Shift,30Y,2,2,2,2,2,2,2,2,2,2,2,2,2,2 53 | Beta,1D,0.5,0.5,0.5,0.5,0.5,0.5,0.5,0.5,0.5,0.5,0.5,0.5,0.5,0.5 54 | Beta,1W,0.5,0.5,0.5,0.5,0.5,0.5,0.5,0.5,0.5,0.5,0.5,0.5,0.5,0.5 55 | Beta,1M,0.5,0.5,0.5,0.5,0.5,0.5,0.5,0.5,0.5,0.5,0.5,0.5,0.5,0.5 56 | Beta,2M,0.5,0.5,0.5,0.5,0.5,0.5,0.5,0.5,0.5,0.5,0.5,0.5,0.5,0.5 57 | Beta,3M,0.5,0.5,0.5,0.5,0.5,0.5,0.5,0.5,0.5,0.5,0.5,0.5,0.5,0.5 58 | Beta,6M,0.5,0.5,0.5,0.5,0.5,0.5,0.5,0.5,0.5,0.5,0.5,0.5,0.5,0.5 59 | Beta,9M,0.5,0.5,0.5,0.5,0.5,0.5,0.5,0.5,0.5,0.5,0.5,0.5,0.5,0.5 60 | Beta,1Y,0.5,0.5,0.5,0.5,0.5,0.5,0.5,0.5,0.5,0.5,0.5,0.5,0.5,0.5 61 | Beta,1Y6M,0.5,0.5,0.5,0.5,0.5,0.5,0.5,0.5,0.5,0.5,0.5,0.5,0.5,0.5 62 | Beta,2Y,0.5,0.5,0.5,0.5,0.5,0.5,0.5,0.5,0.5,0.5,0.5,0.5,0.5,0.5 63 | Beta,3Y,0.5,0.5,0.5,0.5,0.5,0.5,0.5,0.5,0.5,0.5,0.5,0.5,0.5,0.5 64 | Beta,5Y,0.5,0.5,0.5,0.5,0.5,0.5,0.5,0.5,0.5,0.5,0.5,0.5,0.5,0.5 65 | Beta,7Y,0.5,0.5,0.5,0.5,0.5,0.5,0.5,0.5,0.5,0.5,0.5,0.5,0.5,0.5 66 | Beta,10Y,0.5,0.5,0.5,0.5,0.5,0.5,0.5,0.5,0.5,0.5,0.5,0.5,0.5,0.5 67 | Beta,15Y,0.5,0.5,0.5,0.5,0.5,0.5,0.5,0.5,0.5,0.5,0.5,0.5,0.5,0.5 68 | Beta,20Y,0.5,0.5,0.5,0.5,0.5,0.5,0.5,0.5,0.5,0.5,0.5,0.5,0.5,0.5 69 | Beta,30Y,0.5,0.5,0.5,0.5,0.5,0.5,0.5,0.5,0.5,0.5,0.5,0.5,0.5,0.5 70 | Rho,1D,-0.23,-0.23,-0.23,-0.23,-0.22,-0.22,-0.21,-0.22,-0.23,-0.24,-0.24,-0.24,-0.24,-0.24 71 | Rho,1W,-0.23,-0.23,-0.23,-0.23,-0.22,-0.22,-0.21,-0.22,-0.23,-0.24,-0.24,-0.24,-0.24,-0.24 72 | Rho,1M,-0.23,-0.23,-0.23,-0.23,-0.22,-0.22,-0.21,-0.22,-0.23,-0.24,-0.24,-0.24,-0.24,-0.24 73 | Rho,2M,-0.14,-0.14,-0.14,-0.14,-0.13,-0.12,-0.12,-0.13,-0.14,-0.14,-0.14,-0.15,-0.15,-0.15 74 | Rho,3M,-0.07,-0.07,-0.07,-0.07,-0.06,-0.06,-0.06,-0.07,-0.07,-0.08,-0.08,-0.08,-0.09,-0.09 75 | Rho,6M,-0.05,-0.05,-0.05,-0.05,-0.05,-0.05,-0.05,-0.06,-0.06,-0.07,-0.08,-0.08,-0.09,-0.09 76 | Rho,9M,-0.03,-0.03,-0.03,-0.03,-0.04,-0.04,-0.05,-0.06,-0.06,-0.08,-0.08,-0.09,-0.09,-0.09 77 | Rho,1Y,-0.03,-0.03,-0.03,-0.03,-0.03,-0.04,-0.05,-0.06,-0.07,-0.08,-0.09,-0.1,-0.11,-0.11 78 | Rho,1Y6M,-0.03,-0.03,-0.03,-0.03,-0.04,-0.05,-0.07,-0.08,-0.1,-0.11,-0.12,-0.12,-0.13,-0.13 79 | Rho,2Y,-0.03,-0.03,-0.03,-0.03,-0.05,-0.07,-0.1,-0.11,-0.12,-0.13,-0.14,-0.15,-0.16,-0.16 80 | Rho,3Y,-0.08,-0.08,-0.08,-0.08,-0.1,-0.11,-0.14,-0.15,-0.16,-0.18,-0.19,-0.19,-0.2,-0.2 81 | Rho,5Y,-0.13,-0.13,-0.13,-0.13,-0.15,-0.17,-0.19,-0.2,-0.23,-0.23,-0.24,-0.25,-0.26,-0.26 82 | Rho,7Y,-0.15,-0.15,-0.15,-0.15,-0.17,-0.18,-0.2,-0.22,-0.23,-0.25,-0.26,-0.27,-0.28,-0.28 83 | Rho,10Y,-0.16,-0.16,-0.16,-0.16,-0.18,-0.19,-0.21,-0.23,-0.24,-0.25,-0.26,-0.26,-0.28,-0.28 84 | Rho,15Y,-0.15,-0.15,-0.15,-0.15,-0.17,-0.18,-0.2,-0.22,-0.24,-0.24,-0.25,-0.26,-0.26,-0.26 85 | Rho,20Y,-0.15,-0.15,-0.15,-0.15,-0.17,-0.17,-0.2,-0.21,-0.23,-0.24,-0.25,-0.25,-0.25,-0.25 86 | Rho,30Y,-0.14,-0.14,-0.14,-0.14,-0.14,-0.14,-0.14,-0.14,-0.14,-0.14,-0.14,-0.14,-0.14,-0.14 87 | Volvol,1D,1.21,1.21,1.21,1.21,1.17,1.17,1.25,1.3,1.36,1.35,1.33,1.32,1.3,1.3 88 | Volvol,1W,1.21,1.21,1.21,1.21,1.17,1.17,1.25,1.3,1.36,1.35,1.33,1.32,1.3,1.3 89 | Volvol,1M,1.21,1.21,1.21,1.21,1.17,1.17,1.25,1.3,1.36,1.35,1.33,1.32,1.3,1.3 90 | Volvol,2M,1.02,1.02,1.02,1.02,1.01,1.04,1.12,1.16,1.22,1.21,1.2,1.19,1.18,1.18 91 | Volvol,3M,0.97,0.97,0.97,0.97,0.91,0.92,0.99,1.01,1.06,1.05,1.04,1.04,1.04,1.04 92 | Volvol,6M,0.65,0.65,0.65,0.65,0.65,0.66,0.69,0.71,0.74,0.74,0.73,0.74,0.74,0.74 93 | Volvol,9M,0.61,0.61,0.61,0.61,0.56,0.56,0.57,0.59,0.61,0.61,0.61,0.61,0.62,0.62 94 | Volvol,1Y,0.47,0.47,0.47,0.47,0.45,0.45,0.46,0.48,0.5,0.5,0.5,0.5,0.51,0.51 95 | Volvol,1Y6M,0.41,0.41,0.41,0.41,0.41,0.41,0.42,0.42,0.44,0.44,0.45,0.45,0.45,0.45 96 | Volvol,2Y,0.37,0.37,0.37,0.37,0.36,0.36,0.37,0.38,0.39,0.39,0.4,0.4,0.4,0.4 97 | Volvol,3Y,0.33,0.33,0.33,0.33,0.33,0.34,0.34,0.35,0.35,0.35,0.36,0.36,0.36,0.36 98 | Volvol,5Y,0.3,0.3,0.3,0.3,0.3,0.31,0.31,0.31,0.32,0.32,0.32,0.32,0.32,0.32 99 | Volvol,7Y,0.3,0.3,0.3,0.3,0.3,0.3,0.31,0.31,0.3,0.31,0.31,0.31,0.31,0.31 100 | Volvol,10Y,0.28,0.28,0.28,0.28,0.28,0.29,0.29,0.29,0.29,0.29,0.29,0.29,0.3,0.3 101 | Volvol,15Y,0.27,0.27,0.27,0.27,0.27,0.27,0.28,0.28,0.28,0.28,0.28,0.28,0.28,0.28 102 | Volvol,20Y,0.26,0.26,0.26,0.26,0.26,0.26,0.26,0.27,0.27,0.27,0.27,0.27,0.27,0.27 103 | Volvol,30Y,0.25,0.25,0.25,0.25,0.25,0.25,0.25,0.25,0.25,0.25,0.25,0.25,0.25,0.25 104 | -------------------------------------------------------------------------------- /pysabr/helpers.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | 4 | p1 = re.compile(r'(\d+Y)?(\d+M)?(\d+W)?(\d+D)?') 5 | p2 = re.compile(r'(\d+)(\w)') 6 | 7 | 8 | def year_frac_from_maturity_label(maturity_label): 9 | """ 10 | Computes the year fraction from a maturity label. 11 | For example, '1Y6M' returns 1.5, and '1D' returns 1/360 12 | """ 13 | # Step 1: break into years/months/weeks/days 14 | m1 = p1.search(maturity_label) 15 | 16 | # Step 2: break into decimals and symbol 17 | maturity_codes = [] 18 | for g in m1.group(1, 2, 3, 4): 19 | if g: 20 | m2 = p2.search(g) 21 | maturity_codes.append((int(m2.group(1)), m2.group(2))) 22 | 23 | # Step 3: sum codes by their weights 24 | weights = {'Y': 360, 'M': 30, 'W': 7, 'D': 1} 25 | yearfrac = sum([n * weights[code] for (n, code) in maturity_codes]) / 360 26 | 27 | return yearfrac 28 | -------------------------------------------------------------------------------- /pysabr/models/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ynouri/pysabr/be22436625f4c0b605f362444b7aafeecda15959/pysabr/models/__init__.py -------------------------------------------------------------------------------- /pysabr/models/base_sabr.py: -------------------------------------------------------------------------------- 1 | from abc import ABCMeta, abstractmethod 2 | from pysabr import black 3 | import numpy as np 4 | 5 | 6 | class BaseSABR(metaclass=ABCMeta): 7 | """Base class for SABR models.""" 8 | 9 | def __init__(self, f=0.01, shift=0., t=1.0, v_atm_n=0.0010, 10 | beta=1., rho=0., volvol=0.): 11 | self.f = f 12 | self.t = t 13 | self.shift = shift 14 | self.v_atm_n = v_atm_n 15 | self.beta = beta 16 | self.rho = rho 17 | self.volvol = volvol 18 | self.params = dict() 19 | 20 | @abstractmethod 21 | def alpha(self): 22 | """Implies alpha parameter from the ATM normal volatility.""" 23 | 24 | @abstractmethod 25 | def fit(self, k, v): 26 | """Best fit the model to a discrete volatility smile.""" 27 | 28 | @abstractmethod 29 | def lognormal_vol(self, k): 30 | """Return lognormal volatility for a given strike.""" 31 | 32 | @abstractmethod 33 | def normal_vol(self, k): 34 | """Return normal volatility for a given strike.""" 35 | 36 | @abstractmethod 37 | def call(self, k, cp='Call'): 38 | """Abstract method for call prices.""" 39 | 40 | def density(self, k): 41 | """Compute the probability density function from call prices.""" 42 | std_dev = self.v_atm_n * np.sqrt(self.t) 43 | dk = 1e-4 * std_dev 44 | d2call = self.call(k+dk) - 2 * self.call(k) + self.call(k-dk) 45 | return d2call / dk**2 46 | 47 | def get_params(self): 48 | """Get parameters for this SABR model.""" 49 | return self.__dict__ 50 | 51 | def __repr__(self): 52 | class_name = self.__class__.__name__ 53 | return (class_name, _pprint(self.__dict__)) 54 | 55 | 56 | def _pprint(params): 57 | """Pretty print the dictionary 'params'.""" 58 | params_list = list() 59 | for i, (k, v) in enumerate(params): 60 | if type(v) is float: 61 | this_repr = '{}={:.4f}'.format(k, v) 62 | else: 63 | this_repr = '{}={}'.format(k, v) 64 | params_list.append(this_repr) 65 | return params_list 66 | 67 | 68 | class BaseLognormalSABR(BaseSABR): 69 | """Base SABR class for lognormal expansions with some generic methods.""" 70 | 71 | def normal_vol(self, k): 72 | """Return normal volatility for a given strike.""" 73 | f, s, t = self.f, self.shift, self.t 74 | v_sln = self.lognormal_vol(k) 75 | v_n = black.shifted_lognormal_to_normal(k, f, s, t, v_sln) 76 | return v_n 77 | 78 | def call(self, k, cp='call'): 79 | """Return call price.""" 80 | f, s, t = self.f, self.shift, self.t 81 | v_sln = self.lognormal_vol(k) 82 | pv = black.shifted_lognormal_call(k, f, s, t, v_sln, 0., cp) 83 | return pv 84 | 85 | 86 | class BaseNormalSABR(BaseSABR): 87 | """Base SABR class for normal expansions with some generic methods.""" 88 | 89 | def lognormal_vol(self, k): 90 | """Return lognormal volatility for a given strike.""" 91 | f, s, t = self.f, self.shift, self.t 92 | v_n = self.normal_vol(k) 93 | v_sln = black.normal_to_shifted_lognormal(k, f, s, t, v_n) 94 | return v_sln 95 | 96 | def call(self, k, cp='call'): 97 | """Return call price.""" 98 | f, t = self.f, self.t 99 | v_n = self.lognormal_vol(k) 100 | pv = black.normal_call(k, f, t, v_n, 0., cp) 101 | return pv 102 | -------------------------------------------------------------------------------- /pysabr/models/hagan_2002_lognormal_sabr.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | from .base_sabr import BaseLognormalSABR 3 | from pysabr import black 4 | from scipy.optimize import minimize 5 | 6 | 7 | class Hagan2002LognormalSABR(BaseLognormalSABR): 8 | """Hagan 2002 SABR lognormal vol expansion model - ATM normal vol input.""" 9 | 10 | def alpha(self): 11 | """Implies alpha parameter from the ATM normal volatility.""" 12 | f, s, t, v_atm_n = self.f, self.shift, self.t, self.v_atm_n 13 | beta, rho, volvol = self.beta, self.rho, self.volvol 14 | # Convert ATM normal vol to ATM shifted lognormal 15 | v_atm_sln = black.normal_to_shifted_lognormal(f, f, s, t, v_atm_n) 16 | return alpha(v_atm_sln, f+s, t, beta, rho, volvol) 17 | 18 | def lognormal_vol(self, k): 19 | """Return lognormal volatility for a given scalar strike.""" 20 | f, s, t = self.f, self.shift, self.t 21 | beta, rho, volvol = self.beta, self.rho, self.volvol 22 | alpha = self.alpha() 23 | v_sln = lognormal_vol(k+s, f+s, t, alpha, beta, rho, volvol) 24 | return v_sln 25 | 26 | def fit(self, k, v_sln, initial_guess = [0.01, 0.00, 0.10]): 27 | """ 28 | Calibrate SABR parameters alpha, rho and volvol. 29 | 30 | Best fit a smile of shifted lognormal volatilities passed through 31 | arrays k and v. Returns a tuple of SABR params (alpha, rho, 32 | volvol) 33 | """ 34 | f, s, t, beta = self.f, self.shift, self.t, self.beta 35 | 36 | def vol_square_error(x): 37 | vols = [lognormal_vol(k_+s, f+s, t, x[0], beta, x[1], 38 | x[2]) * 100 for k_ in k] 39 | return sum((vols - v_sln)**2) 40 | 41 | x0 = np.array(initial_guess) 42 | bounds = [(0.0001, None), (-0.9999, 0.9999), (0.0001, None)] 43 | res = minimize(vol_square_error, x0, method='L-BFGS-B', bounds=bounds) 44 | alpha, self.rho, self.volvol = res.x 45 | return [alpha, self.rho, self.volvol] 46 | 47 | 48 | def lognormal_vol(k, f, t, alpha, beta, rho, volvol): 49 | """ 50 | Hagan's 2002 SABR lognormal vol expansion. 51 | 52 | The strike k can be a scalar or an array, the function will return an array 53 | of lognormal vols. 54 | """ 55 | # Negative strikes or forwards 56 | if k <= 0 or f <= 0: 57 | return 0. 58 | eps = 1e-07 59 | logfk = np.log(f / k) 60 | fkbeta = (f*k)**(1 - beta) 61 | a = (1 - beta)**2 * alpha**2 / (24 * fkbeta) 62 | b = 0.25 * rho * beta * volvol * alpha / fkbeta**0.5 63 | c = (2 - 3*rho**2) * volvol**2 / 24 64 | d = fkbeta**0.5 65 | v = (1 - beta)**2 * logfk**2 / 24 66 | w = (1 - beta)**4 * logfk**4 / 1920 67 | z = volvol * fkbeta**0.5 * logfk / alpha 68 | # if |z| > eps 69 | if abs(z) > eps: 70 | vz = alpha * z * (1 + (a + b + c) * t) / (d * (1 + v + w) * _x(rho, z)) 71 | return vz 72 | # if |z| <= eps 73 | else: 74 | v0 = alpha * (1 + (a + b + c) * t) / (d * (1 + v + w)) 75 | return v0 76 | 77 | 78 | def _x(rho, z): 79 | """Return function x used in Hagan's 2002 SABR lognormal vol expansion.""" 80 | a = (1 - 2*rho*z + z**2)**.5 + z - rho 81 | b = 1 - rho 82 | return np.log(a / b) 83 | 84 | 85 | # TODO: refactor the interface to make it compliant with normal interface 86 | def alpha(v_atm_ln, f, t, beta, rho, volvol): 87 | """ 88 | Compute SABR parameter alpha to an ATM lognormal volatility. 89 | 90 | Alpha is determined as the root of a 3rd degree polynomial. Return a single 91 | scalar alpha. 92 | """ 93 | f_ = f ** (beta - 1) 94 | p = [ 95 | t * f_**3 * (1 - beta)**2 / 24, 96 | t * f_**2 * rho * beta * volvol / 4, 97 | (1 + t * volvol**2 * (2 - 3*rho**2) / 24) * f_, 98 | -v_atm_ln 99 | ] 100 | roots = np.roots(p) 101 | roots_real = np.extract(np.isreal(roots), np.real(roots)) 102 | # Note: the double real roots case is not tested 103 | alpha_first_guess = v_atm_ln * f**(1-beta) 104 | i_min = np.argmin(np.abs(roots_real - alpha_first_guess)) 105 | return roots_real[i_min] 106 | -------------------------------------------------------------------------------- /pysabr/models/hagan_2002_normal_sabr.py: -------------------------------------------------------------------------------- 1 | from .base_sabr import BaseNormalSABR 2 | import numpy as np 3 | from scipy.optimize import minimize 4 | 5 | 6 | class Hagan2002NormalSABR(BaseNormalSABR): 7 | 8 | def alpha(self): 9 | """Implies alpha parameter from the ATM normal volatility.""" 10 | f, s, t, v_atm_n = self.f, self.shift, self.t, self.v_atm_n 11 | beta, rho, volvol = self.beta, self.rho, self.volvol 12 | # Convert ATM normal vol to ATM shifted lognormal 13 | return alpha(f+s, t, v_atm_n, beta, rho, volvol) 14 | 15 | def normal_vol(self, k): 16 | """Return normal volatility for a given strike.""" 17 | f, s, t = self.f, self.shift, self.t 18 | beta, rho, volvol = self.beta, self.rho, self.volvol 19 | alpha = self.alpha() 20 | v_n = normal_vol(k+s, f+s, t, alpha, beta, rho, volvol) 21 | return v_n 22 | 23 | def fit(self, k, smile, initial_guess = [0.01, 0.00, 0.10]): 24 | """ 25 | Calibrate SABR parameters alpha, rho and volvol. 26 | Best fit a smile of normal volatilities passed through 27 | arrays k and v. Returns a tuple of SABR params (alpha, rho, 28 | volvol) 29 | """ 30 | f, s, t, beta = self.f, self.shift, self.t, self.beta 31 | 32 | def vol_square_error(x): 33 | vols = [normal_vol(k_+s, f+s, t, x[0], beta, x[1], 34 | x[2]) * 10000 for k_ in k] 35 | return sum((vols - smile)**2) 36 | 37 | x0 = np.array(initial_guess) 38 | bounds = [(0.0001, None), (-0.9999, 0.9999), (0.0001, None)] 39 | res = minimize(vol_square_error, x0, method='L-BFGS-B', bounds=bounds) 40 | alpha, self.rho, self.volvol = res.x 41 | return [alpha, self.rho, self.volvol] 42 | 43 | 44 | def normal_vol(k, f, t, alpha, beta, rho, volvol): 45 | """Hagan's 2002 SABR normal vol expansion - formula (B.67a).""" 46 | # We break down the complex formula into simpler sub-components 47 | f_av = np.sqrt(f * k) 48 | A = - beta * (2 - beta) * alpha**2 / (24 * f_av**(2 - 2 * beta)) 49 | B = rho * alpha * volvol * beta / (4 * f_av**(1 - beta)) 50 | C = (2 - 3 * rho**2) * volvol**2 / 24 51 | FMKR = _f_minus_k_ratio(f, k, beta) 52 | ZXZ = _zeta_over_x_of_zeta(k, f, t, alpha, beta, rho, volvol) 53 | # Aggregate all components into actual formula (B.67a) 54 | v_n = alpha * FMKR * ZXZ * (1 + (A + B + C) * t) 55 | return v_n 56 | 57 | 58 | def _f_minus_k_ratio(f, k, beta): 59 | """Hagan's 2002 f minus k ratio - formula (B.67a).""" 60 | eps = 1e-07 # Numerical tolerance for f-k and beta 61 | if abs(f-k) > eps: 62 | if abs(1-beta) > eps: 63 | return (1 - beta) * (f - k) / (f**(1-beta) - k**(1-beta)) 64 | else: 65 | return (f - k) / np.log(f / k) 66 | else: 67 | return k**beta 68 | 69 | 70 | def _zeta_over_x_of_zeta(k, f, t, alpha, beta, rho, volvol): 71 | """Hagan's 2002 zeta / x(zeta) function - formulas (B.67a)-(B.67b).""" 72 | eps = 1e-07 # Numerical tolerance for zeta 73 | f_av = np.sqrt(f * k) 74 | zeta = volvol * (f - k) / (alpha * f_av**beta) 75 | if abs(zeta) > eps: 76 | return zeta / _x(rho, zeta) 77 | else: 78 | # The ratio converges to 1 when zeta approaches 0 79 | return 1. 80 | 81 | 82 | def _x(rho, z): 83 | """Hagan's 2002 x function - formula (B.67b).""" 84 | a = (1 - 2*rho*z + z**2)**.5 + z - rho 85 | b = 1 - rho 86 | return np.log(a / b) 87 | 88 | 89 | def alpha(f, t, v_atm_n, beta, rho, volvol): 90 | """ 91 | Compute SABR parameter alpha from an ATM normal volatility. 92 | 93 | Alpha is determined as the root of a 3rd degree polynomial. Return a single 94 | scalar alpha. 95 | """ 96 | f_ = f ** (1 - beta) 97 | p = [ 98 | - beta * (2 - beta) / (24 * f_**2) * t * f**beta, 99 | t * f**beta * rho * beta * volvol / (4 * f_), 100 | (1 + t * volvol**2 * (2 - 3*rho**2) / 24) * f**beta, 101 | -v_atm_n 102 | ] 103 | roots = np.roots(p) 104 | roots_real = np.extract(np.isreal(roots), np.real(roots)) 105 | # Note: the double real roots case is not tested 106 | alpha_first_guess = v_atm_n * f**(-beta) 107 | i_min = np.argmin(np.abs(roots_real - alpha_first_guess)) 108 | return roots_real[i_min] 109 | 110 | 111 | def polynom(v_atm_n, f, t, alpha, beta, rho, volvol): 112 | """Debug function - to remove""" 113 | f_ = f ** (1 - beta) 114 | p = [ 115 | - beta * (2 - beta) / (24 * f_**2) * t * f**beta, 116 | t * f**beta * rho * beta * volvol / (4 * f_), 117 | (1 + t * volvol**2 * (2 - 3*rho**2) / 24) * f**beta, 118 | -v_atm_n 119 | ] 120 | return p[0] * alpha**3 + p[1] * alpha**2 + p[2] * alpha + p[3] 121 | 122 | 123 | def v_atm_n(f, t, alpha, beta, rho, volvol): 124 | """Debug function - to remove""" 125 | f_av = f 126 | A = - beta * (2 - beta) * alpha**2 / (24 * f_av**(2 - 2 * beta)) 127 | B = rho * alpha * volvol * beta / (4 * f_av**(1 - beta)) 128 | C = (2 - 3 * rho**2) * volvol**2 / 24 129 | v_atm_n = alpha * f**beta * (1 + (A + B + C) * t) 130 | return v_atm_n 131 | -------------------------------------------------------------------------------- /pysabr/models/hagan_2013_normal_sabr.py: -------------------------------------------------------------------------------- 1 | from base_sabr import BaseNormalSABR 2 | 3 | 4 | class Hagan2013NormalSABR(BaseNormalSABR): 5 | pass 6 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | addopts = --verbose -s 3 | log_cli = true 4 | log_cli_level = INFO 5 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | numpy==1.14.0 2 | scipy==1.0.0 3 | falcon==1.4.1 4 | gunicorn 5 | pytest 6 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | 3 | setup( 4 | name='pysabr', 5 | description='SABR volatility model for interest rates options', 6 | url='https://github.com/ynouri/pysabr', 7 | version='0.4.1', 8 | license='MIT', 9 | author='Yacine Nouri', 10 | packages=find_packages(), 11 | python_requires='>=3', 12 | install_requires=['numpy', 'scipy', 'falcon'] 13 | ) 14 | -------------------------------------------------------------------------------- /tests/black/test_conversion_generic.py: -------------------------------------------------------------------------------- 1 | from pysabr import black 2 | import pytest 3 | from pytest import approx 4 | 5 | 6 | MAX_ERROR_PREMIUM = 0.001 # 0.1% error is tolerated 7 | N = 1e5 # Default notional proxies annuity of $100M 10Y swap 8 | 9 | # Test case should be defined as follow: 10 | # 'Test case name': [ 11 | # [k, f, u, t, v, cp], # u=0% for LN vol, u='N'' for N vol 12 | # s # s=0% to convert to LN vol, s='N"' to convert to N vol 13 | # ], 14 | 15 | test_data_dict = { 16 | '1y6m 800bps otm put 10% 2%-SLN vol to LN': [ 17 | [0.02, 0.10, 0.02, 1.5, 0.10, 'put'], 0.00 18 | ], 19 | '10y 10bps otm put 20% LN vol to 3% SLN vol': [ 20 | [0.012, 0.013, 10., 0.20, 0.00, 'put'], 0.03 21 | ], 22 | '1y atm 30% 2%-SLN vol to 2%-SLN vol': [ 23 | [0.03, 0.03, 1., 0.30, 0.02, 'call'], 0.02 24 | ], 25 | '1y atm 30% 2%-SLN vol to LN vol': [ 26 | [0.03, 0.03, 1., 0.30, 0.02, 'call'], 0.00 27 | ], 28 | '1y atm 30% 2%-SLN vol to 4%-SLN vol': [ 29 | [0.03, 0.03, 1., 0.30, 0.02, 'call'], 0.04 30 | ], 31 | '6m 20bps otm put 50% LN vol to 1% SLN vol': [ 32 | [0.030, 0.028, 0.5, 0.50, 0.00, 'put'], 0.01 33 | ] 34 | } 35 | 36 | 37 | @pytest.fixture(scope="module", 38 | params=test_data_dict.values(), 39 | ids=list(test_data_dict.keys())) 40 | def test_data(request): 41 | yield request.param 42 | 43 | 44 | def test_conversion(test_data): 45 | """Test the conversion from one vol type to another.""" 46 | [k, f, u, t, v, cp], s = test_data 47 | r = 0. # Assume a discount rate of 0% for the test. 48 | # SLN to SLN case 49 | if not u == 'N' and not u == 'N': 50 | pv_target = N * black.shifted_lognormal_call(k, f, u, t, v, r, cp) 51 | v_sln = black.lognormal_to_lognormal(k, f, s, t, v, u) 52 | pv_test = N * black.shifted_lognormal_call(k, f, s, t, v_sln, r, cp) 53 | # Other cases TODO 54 | else: 55 | assert False 56 | assert pv_target == approx(pv_test, MAX_ERROR_PREMIUM) 57 | -------------------------------------------------------------------------------- /tests/black/test_conversion_round_trip.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | from pysabr import black 3 | from pytest import approx 4 | 5 | 6 | MAX_ABS_ERROR_VOL = 0.01e-4 # Max error is 0.01bp 7 | 8 | 9 | def conversion_round_trip(fun_n_to_sln, fun_sln_n): 10 | """Generic function for conversion round trip test.""" 11 | f, s, t, v_atm_n = (0.02, 0.025, 1.0, 0.0040) 12 | n_strikes = 200 13 | strikes = np.linspace(-1.99, 6.00, n_strikes) / 100 14 | target_vols = np.ones(n_strikes) * v_atm_n 15 | # Normal to Lognormal conversion 16 | sln_vols = [fun_n_to_sln(k, f, s, t, v_n) 17 | for (k, v_n) in zip(strikes, target_vols)] 18 | # Lognormal to Normal conversion 19 | test_vols = [fun_sln_n(k, f, s, t, v_sln) 20 | for (k, v_sln) in zip(strikes, sln_vols)] 21 | assert test_vols == approx(target_vols, abs=MAX_ABS_ERROR_VOL) 22 | 23 | 24 | def test_hagan_conversion_round_trip(): 25 | """Convert N to SLN, then SLN to N, using Hagan's formula.""" 26 | fun_n_to_sln = black.hagan_normal_to_lognormal 27 | fun_sln_n = black.hagan_lognormal_to_normal 28 | conversion_round_trip(fun_n_to_sln, fun_sln_n) 29 | 30 | 31 | def test_premium_based_conversion_round_trip(): 32 | """Convert N to SLN, then SLN to N, using premium based conversion.""" 33 | fun_n_to_sln = black.normal_to_shifted_lognormal 34 | fun_sln_n = black.shifted_lognormal_to_normal 35 | conversion_round_trip(fun_n_to_sln, fun_sln_n) 36 | -------------------------------------------------------------------------------- /tests/black/test_lognormal.py: -------------------------------------------------------------------------------- 1 | import pysabr.black as black 2 | import numpy as np 3 | import logging 4 | import pytest 5 | from pytest import approx 6 | 7 | 8 | ERROR_TOLERANCE = 0.001 # 0.1% error is tolerated 9 | 10 | test_data = { 11 | '1y6m 800bps itm call 10% LN vol': [ 12 | 1e5, 13 | [2., 10., 1.5, 0.10, 0.03, 'call'], 14 | 1e5 * (10. - 2.) * np.exp(-0.03*1.5) 15 | ], 16 | '10y 10bps itm call 20% LN vol': [ 17 | 1e5, 18 | [0.012, 0.013, 10., 0.20, 0.02, 'call'], 19 | 296.88 20 | ], 21 | '6m 20bps otm put 50% LN vol': [ 22 | 1e5, 23 | [0.030, 0.028, 0.5, 0.50, 0.04, 'put'], 24 | 504.37 25 | ], 26 | '2y atm put 100% LN vol': [ 27 | 1e5, 28 | [0.005, 0.005, 2., 1., 0.10, 'put'], 29 | 213.07 30 | ] 31 | } 32 | 33 | 34 | @pytest.fixture(scope="module", 35 | params=test_data.values(), 36 | ids=list(test_data.keys())) 37 | def option_data(request): 38 | yield request.param 39 | 40 | 41 | def test_pv(option_data): 42 | """Tests the Black lognormal formula against an expected target PV.""" 43 | n, [k, f, t, v, r, cp], target_pv = option_data 44 | pv = n * black.lognormal_call(k, f, t, v, r, cp) 45 | logging.debug("PV = {}".format(pv)) 46 | logging.debug("Target PV = {}".format(target_pv)) 47 | assert pv == approx(target_pv, ERROR_TOLERANCE) 48 | 49 | 50 | def test_call_put_parity(option_data): 51 | """Tests the Black lognormal call put parity relationship.""" 52 | n, [k, f, t, v, r, _], _ = option_data 53 | call = n * black.lognormal_call(k, f, t, v, r, cp='call') 54 | put = n * black.lognormal_call(k, f, t, v, r, cp='put') 55 | target = n * np.exp(-r*t) * (f - k) 56 | logging.debug("Call - Put = {}".format(call - put)) 57 | logging.debug("DF * (F -K) = {}".format(target)) 58 | assert call - put == approx(target, ERROR_TOLERANCE) 59 | 60 | 61 | def test_shifted_lognormal_to_normal(option_data): 62 | """ Tests the conversion from shifted lognormal vol to normal.""" 63 | n, [k, f, t, v_sln, r, cp], _ = option_data 64 | # We assume a shift of 2% for the test 65 | s = 0.02 66 | v_n = black.shifted_lognormal_to_normal(k, f, s, t, v_sln) 67 | pv_lognormal = n * black.shifted_lognormal_call(k, f, s, t, v_sln, r, cp) 68 | pv_normal = n * black.normal_call(k, f, t, v_n, r, cp) 69 | assert pv_normal == approx(pv_lognormal, ERROR_TOLERANCE) 70 | -------------------------------------------------------------------------------- /tests/black/test_normal.py: -------------------------------------------------------------------------------- 1 | import pysabr.black as black 2 | import numpy as np 3 | import logging 4 | import pytest 5 | from pytest import approx 6 | 7 | 8 | MAX_ERROR_PRICE = 1e-4 # 1bp error is tolerated. TODO: refine this 9 | MAX_ERROR_CONVERSION = 1e-8 # ie 0.0001bp 10 | MAX_ERROR_HAGAN_CONVERSION = 1e-4 # ie 1bp 11 | 12 | test_data = { 13 | '1y6m 800bps itm call 50bps N vol': [ 14 | 1e5, 15 | [2., 10., 1.5, 0.10, 0.03, 'call'], 16 | 1e5 * (10. - 2.) * np.exp(-0.03*1.5) 17 | ], 18 | '10y 10bps itm call 30bps N vol': [ 19 | 1e5, 20 | [0.012, 0.013, 10., 0.0030, 0.02, 'call'], 21 | 352.52 22 | ], 23 | '6m 20bps otm call 100bps N vol': [ 24 | 1e5, 25 | [0.028, 0.030, 0.5, 0.01, 0.04, 'call'], 26 | 385.52 27 | ], 28 | '2y atm put 80bps N vol': [ 29 | 1e5, 30 | [0.005, 0.005, 2., 0.0080, 0.10, 'put'], 31 | 1e5 * np.exp(-0.10*2.) * 0.0080 * (2. / (2 * np.pi))**0.5 32 | ], 33 | '30y atm call 40bps N vol': [ 34 | 1e9, # ie $100M 10Y swap 35 | [0.025, 0.025, 30., 0.0040, 0., 'call'], 36 | 8740387.44 37 | ] 38 | } 39 | 40 | 41 | @pytest.fixture(scope="module", 42 | params=test_data.values(), 43 | ids=list(test_data.keys())) 44 | def option_data(request): 45 | """Return the test data.""" 46 | yield request.param 47 | 48 | 49 | def test_pv(option_data): 50 | """Test the Black normal formula against an expected target PV.""" 51 | n, [k, f, t, v, r, cp], target_pv = option_data 52 | pv = n * black.normal_call(k, f, t, v, r, cp) 53 | logging.debug("PV = {}".format(pv)) 54 | logging.debug("Target PV = {}".format(target_pv)) 55 | assert pv == approx(target_pv, MAX_ERROR_PRICE) 56 | 57 | 58 | def test_call_put_parity(option_data): 59 | """Test the Black normal call put parity relationship.""" 60 | n, [k, f, t, v, r, _], _ = option_data 61 | call = n * black.normal_call(k, f, t, v, r, cp='call') 62 | put = n * black.normal_call(k, f, t, v, r, cp='put') 63 | target = n * np.exp(-r*t) * (f - k) 64 | logging.debug("Call - Put = {}".format(call - put)) 65 | logging.debug("DF * (F -K) = {}".format(target)) 66 | assert call - put == approx(target, MAX_ERROR_PRICE) 67 | 68 | 69 | def test_hagan_normal_to_lognormal(option_data): 70 | """Test N to SLN conversion using Hagan formula root solving.""" 71 | n, [k, f, t, v_n, r, cp], _ = option_data 72 | # We assume a shift of 2% for the test 73 | s = 0.02 74 | v_sln = black.hagan_normal_to_lognormal(k, f, s, t, v_n) 75 | pv_normal = n * black.normal_call(k, f, t, v_n, r, cp) 76 | pv_lognormal = n * black.shifted_lognormal_call(k, f, s, t, v_sln, r, cp) 77 | assert pv_lognormal == approx(pv_normal, MAX_ERROR_HAGAN_CONVERSION) 78 | 79 | 80 | def test_normal_to_shifted_lognormal(option_data): 81 | """Test the conversion from normal vol to shifted lognormal.""" 82 | n, [k, f, t, v_n, r, cp], _ = option_data 83 | # We assume a shift of 2% for the test 84 | s = 0.02 85 | v_sln = black.normal_to_shifted_lognormal(k, f, s, t, v_n) 86 | pv_normal = n * black.normal_call(k, f, t, v_n, r, cp) 87 | pv_lognormal = n * black.shifted_lognormal_call(k, f, s, t, v_sln, r, cp) 88 | assert pv_lognormal == approx(pv_normal, MAX_ERROR_CONVERSION) 89 | -------------------------------------------------------------------------------- /tests/black/test_performance.py: -------------------------------------------------------------------------------- 1 | import timeit 2 | from pysabr import black 3 | import logging 4 | 5 | 6 | def test_normal_to_shifted_lognormal(): 7 | """Test N to SLN conversion performance.""" 8 | def slow_conversion(): 9 | [k, f, s, t, v_n] = [0.025, 0.025, 0.02, 30.0, 0.0040] 10 | v_sln = black.normal_to_shifted_lognormal(k, f, s, t, v_n) 11 | return v_sln 12 | nb_iterations = 100 13 | time = timeit.timeit(slow_conversion, number=nb_iterations) 14 | logging.debug( 15 | "Time for {} iterations = {:2f}s".format(nb_iterations, time)) 16 | # TODO: 100 conversions should take less than 0.1s 17 | assert time <= 2.0 # failures on CI with times around 0.7s 18 | -------------------------------------------------------------------------------- /tests/hagan_2002_lognormal_sabr/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import itertools 3 | import pandas as pd 4 | 5 | 6 | # Path to vols, premiums and discount factors data 7 | PATH = 'pysabr/examples/' 8 | 9 | # Load vols data 10 | df_vols = pd.read_csv(PATH + 'vols.csv') 11 | df_vols.set_index(['Type', 'Option_expiry'], inplace=True) 12 | df_vols.sort_index(inplace=True) 13 | idx = pd.IndexSlice 14 | 15 | # Load premium data 16 | df_premiums = pd.read_csv(PATH + 'premiums.csv') 17 | df_premiums.set_index(['Type', 'Option_expiry', 'Strike'], inplace=True) 18 | df_premiums.sort_index(inplace=True) 19 | 20 | # Load discount factors 21 | df_option_expiries = pd.read_csv(PATH + 'option_expiries.csv') 22 | 23 | # Cartesian product of all expiries and tenors 24 | expiries = df_vols.index.levels[1] 25 | tenors = df_vols.columns 26 | all_points = list(itertools.product(*[expiries, tenors])) 27 | # all_points = [('1Y', '10Y'), ('1Y', '30Y'), ('9M', '10Y')] # for debugging 28 | all_points_ids = ["{} into {}".format(e, t) for e, t in all_points] 29 | 30 | 31 | # Fixture serves for each point of the vol surface a tuple of: 32 | # * Vol input: Forward + Shift + SABR params 33 | # * Target vols for a range of strike 34 | @pytest.fixture(scope="module", params=all_points, ids=all_points_ids) 35 | def vol_cube(request): 36 | """Return vol cube parameters, vols, premiums.""" 37 | option_expiry, swap_tenor = request.param 38 | # Vol input 39 | p = dict( 40 | df_vols.loc[idx[:, option_expiry], swap_tenor]. 41 | reset_index(level=1, drop=True) 42 | ) 43 | # Option expiry year fraction 44 | i = df_option_expiries.Option_expiry == option_expiry 45 | expiry_year_frac = df_option_expiries.loc[i].Year_frac.values[0] 46 | # expiry_year_frac = year_frac_from_maturity_label(option_expiry) 47 | vol_input = (p['Forward'], p['Shift'], expiry_year_frac, 48 | p['Normal_ATM_vol'], p['Beta'], p['Rho'], p['Volvol']) 49 | 50 | # Target vols 51 | vols_target = df_premiums.loc[ 52 | idx['SLN_vol', option_expiry, :], swap_tenor 53 | ].reset_index(level=[0, 1], drop=True) 54 | # Target premiums 55 | premiums_target = df_premiums.loc[ 56 | idx['Call', option_expiry, :], swap_tenor 57 | ].reset_index(level=[0, 1], drop=True) 58 | # Yields the tuple 59 | yield (vol_input, vols_target, premiums_target) 60 | -------------------------------------------------------------------------------- /tests/hagan_2002_lognormal_sabr/test_alpha.py: -------------------------------------------------------------------------------- 1 | from pysabr import hagan_2002_lognormal_sabr as sabr 2 | import logging 3 | import pytest 4 | from pytest import approx 5 | 6 | 7 | ERROR_TOLERANCE = 0.001 # 0.1% error is tolerated 8 | 9 | test_data = { 10 | 'Beta=1 flat lognormal': [ 11 | [0.60, 0.02, 1.5, 1.0, 0.0, 0.0], 12 | 0.60 13 | ], 14 | 'Beta=0 flat normal': [ 15 | [0.60, 2.0, 1.5, 0.0, 0.0, 0.0], 16 | 1.1746 17 | ], 18 | 'Beta=0.5, 10y': [ 19 | [0.20, 0.015, 10., 0.5, -0.2, 0.3], 20 | 0.02310713 21 | ] 22 | } 23 | 24 | 25 | @pytest.fixture(scope="module", 26 | params=test_data.values(), 27 | ids=list(test_data.keys())) 28 | def sabr_data(request): 29 | yield request.param 30 | 31 | 32 | def test_calibration(sabr_data): 33 | [atm_vol, f, t, beta, rho, volvol], target_alpha = sabr_data 34 | test_alpha = sabr.alpha(atm_vol, f, t, beta, rho, volvol) 35 | logging.debug("Test alpha = {}".format(test_alpha)) 36 | logging.debug("Target alpha = {}".format(target_alpha)) 37 | assert test_alpha == approx(target_alpha, ERROR_TOLERANCE) 38 | 39 | 40 | def test_atm_vol_repricing(sabr_data): 41 | [target_atm_vol, f, t, beta, rho, volvol], _ = sabr_data 42 | alpha = sabr.alpha(target_atm_vol, f, t, beta, rho, volvol) 43 | test_atm_vol = sabr.lognormal_vol(f, f, t, alpha, beta, rho, volvol) 44 | assert test_atm_vol == approx(target_atm_vol, ERROR_TOLERANCE) 45 | -------------------------------------------------------------------------------- /tests/hagan_2002_lognormal_sabr/test_cube.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import pytest 3 | from pysabr import Hagan2002LognormalSABR 4 | 5 | 6 | N = 1e9 # We assume BPV = $100,000 (= 1e9 / 1e4) 7 | MAX_ABS_ERROR_PREMIUM = 10.0 # Max absolute error on premium is $10.0 8 | MAX_ERROR_VOL = 0.0005 # Max error is 0.05% 9 | 10 | 11 | def test_vols(vol_cube): 12 | """Test the full ATM SABR vol chain for Hagan's 2002 Lognormal model.""" 13 | logging.debug(vol_cube) 14 | (f, s, t, v_atm_n, beta, rho, volvol), vols_target, _ = vol_cube 15 | sabr = Hagan2002LognormalSABR(f/100, s/100, t, v_atm_n/1e4, 16 | beta, rho, volvol) 17 | strikes = vols_target.index 18 | vols_test = [sabr.lognormal_vol(k/100) * 100 for k in strikes] 19 | assert vols_test == pytest.approx(vols_target.values, MAX_ERROR_VOL) 20 | 21 | 22 | def test_premiums(vol_cube): 23 | """Test the premiums.""" 24 | (f, s, t, v_atm_n, beta, rho, volvol), _, premiums_target = vol_cube 25 | sabr = Hagan2002LognormalSABR(f/100, s/100, t, v_atm_n/1e4, 26 | beta, rho, volvol) 27 | strikes = premiums_target.index[premiums_target.index + s > 0.] 28 | # TODO: strikes = premiums_target.index 29 | premiums_test = [sabr.call(k/100) * N for k in strikes] 30 | premiums_target = premiums_target[strikes].values * N / 100 31 | assert premiums_test == pytest.approx(premiums_target, 32 | abs=MAX_ABS_ERROR_PREMIUM) 33 | -------------------------------------------------------------------------------- /tests/hagan_2002_lognormal_sabr/test_density.py: -------------------------------------------------------------------------------- 1 | from pytest import approx 2 | from pysabr import Hagan2002LognormalSABR 3 | import numpy as np 4 | from scipy.stats import norm 5 | 6 | MAX_ABS_ERROR = 0.003 7 | 8 | 9 | def test_standard_normal_density(): 10 | """ 11 | Test the density of a degenerated SABR model. 12 | 13 | Should converge towards the pdf of the standard normal distribution. 14 | """ 15 | sabr = Hagan2002LognormalSABR(f=0, shift=100, t=1, v_atm_n=1, 16 | beta=1, rho=0, volvol=0) 17 | std_dev = sabr.v_atm_n * np.sqrt(sabr.t) # = 1.0 18 | k = np.linspace(sabr.f - 5 * std_dev, sabr.f + 5 * std_dev, 100) 19 | sabr_density = [sabr.density(x) for x in k] 20 | std_normal_density = norm.pdf(k) 21 | assert sabr_density == approx(std_normal_density, abs=MAX_ABS_ERROR) 22 | -------------------------------------------------------------------------------- /tests/hagan_2002_lognormal_sabr/test_fit.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | from pysabr import Hagan2002LognormalSABR 3 | import logging 4 | 5 | 6 | def test_calibration_beta_05(): 7 | k = np.array([-0.4729, 0.5271, 1.0271, 1.5271, 1.7771, 2.0271, 2.2771, 8 | 2.4021, 2.5271, 2.6521, 2.7771, 3.0271, 3.2771, 3.5271, 9 | 4.0271, 4.5271, 5.5271]) 10 | v = np.array([19.641923, 15.785344, 14.305103, 13.073869, 11 | 12.550007, 12.088721, 11.691661, 11.517660, 12 | 11.360133, 11.219058, 11.094293, 10.892464, 13 | 10.750834, 10.663653, 10.623862, 10.714479, 14 | 11.103755]) 15 | [t, f, s, beta] = np.array([10.0000, 2.5271, 3.0000, 0.5000]) 16 | sabr = Hagan2002LognormalSABR(f/100, s/100, t, beta=beta) 17 | sabr_test = sabr.fit(k/100, v) 18 | [alpha, rho, volvol] = sabr_test 19 | logging.debug('\nalpha={:.6f}, rho={:.6f}, volvol={:.6f}' 20 | .format(alpha, rho, volvol)) 21 | sabr_target = np.array([0.0253, -0.2463, 0.2908]) 22 | error_max = max(abs(sabr_test - sabr_target)) 23 | assert (error_max < 1e-5) 24 | -------------------------------------------------------------------------------- /tests/hagan_2002_lognormal_sabr/test_interpolation.py: -------------------------------------------------------------------------------- 1 | from pysabr import hagan_2002_lognormal_sabr as sabr 2 | import pytest 3 | import numpy as np 4 | 5 | 6 | def test_lognormal_beta_05(): 7 | s = 3 / 100 8 | k = 3.02715567337258000 / 100 9 | f = 2.52715567337258000 / 100 10 | t = 10.00000000000000000 11 | alpha = 0.0252982247897366000 12 | beta = 0.5000000000000000000 13 | rho = -0.2463339754454810000 14 | volvol = 0.2908465632529730000 15 | v_test = sabr.lognormal_vol(k+s, f+s, t, alpha, 16 | beta, rho, volvol) * 100 17 | v_target = 10.8917434151064000 18 | assert v_test == pytest.approx(v_target, 1e-7) 19 | 20 | 21 | def test_lognormal_beta_0(): 22 | k = 0.01 23 | f = 0.03 24 | t = 10 25 | alpha = 0.02 26 | beta = 1.00 27 | rho = 0.00 28 | volvol = 0.00 29 | v_test = sabr.lognormal_vol(k, f, t, alpha, beta, rho, volvol) 30 | v_target = 0.02 31 | assert v_test == pytest.approx(v_target, 1e-7) 32 | 33 | 34 | def test_lognormal_beta_05_smile(): 35 | k = np.array([-0.4729, 0.5271, 1.0271, 1.5271, 1.7771, 2.0271, 2.2771, 36 | 2.4021, 2.5271, 2.6521, 2.7771, 3.0271, 3.2771, 3.5271, 37 | 4.0271, 4.5271, 5.5271]) 38 | [t, f, s, alpha, beta, rho, volvol] = np.array( 39 | [10.0000, 2.5271, 3.0000, 0.0253, 0.5000, -0.2463, 0.2908]) 40 | k = (k + s) / 100 41 | f = (f + s) / 100 42 | vols_test = [sabr.lognormal_vol(k_, f, t, alpha, beta, rho, volvol) * 100 43 | for k_ in k] 44 | vols_target = np.array([19.641923, 15.785344, 14.305103, 13.073869, 45 | 12.550007, 12.088721, 11.691661, 11.517660, 46 | 11.360133, 11.219058, 11.094293, 10.892464, 47 | 10.750834, 10.663653, 10.623862, 10.714479, 48 | 11.103755]) 49 | error_max = max(abs(vols_test - vols_target)) 50 | assert (error_max < 1e-5) 51 | -------------------------------------------------------------------------------- /tests/hagan_2002_normal_sabr/test_h2002n_alpha.py: -------------------------------------------------------------------------------- 1 | from pysabr import hagan_2002_normal_sabr as sabr 2 | import logging 3 | import pytest 4 | from pytest import approx 5 | 6 | 7 | ERROR_TOLERANCE = 0.00001 # 0.01bps error is tolerated 8 | 9 | # [f, t, v_atm_n, beta, rho, volvol] 10 | test_data = { 11 | 'Beta=0.5, No Rho No Volvol': [ 12 | [2., 1.0, 1.0, 0.5, 0.0, 0.0], 13 | 0.712764724868 14 | ], 15 | 'Flat normal smile 20bps (beta=0)': [ 16 | [0.030, 5.0, 0.0020, 0., 0., 0.], 17 | 0.002 18 | ], 19 | 'Flat lognormal smile 30% (beta=1, fwd 2%, vol 60bps)': [ 20 | [0.020, 0.5, 0.0060, 1., 0., 0.], 21 | 0.300565687999 22 | ], 23 | 'Beta=0, 40bps normal vol': [ 24 | [0.025, 10.0, 0.0040, 0., -0.2, 0.3], 25 | 0.003736571695 26 | ], 27 | 'Low rates (0.1%) high vol (100bps) long dated (30y)': [ 28 | [0.001, 30.0, 0.010, 0.7, 0.8, 0.5], 29 | -0.214685033491 # TODO: ALPHA NEGATIVE! What does that mean? 30 | ], 31 | 'Regular case': [ 32 | [0.025, 0.75, 0.0040, 0.5, -0.2, 0.3], 33 | 0.025202566661 34 | ] 35 | } 36 | 37 | 38 | @pytest.fixture(scope="module", 39 | params=test_data.values(), 40 | ids=list(test_data.keys())) 41 | def sabr_data(request): 42 | yield request.param 43 | 44 | 45 | def test_calibration(sabr_data): 46 | [f, t, v_atm_n, beta, rho, volvol], target_alpha = sabr_data 47 | test_alpha = sabr.alpha(f, t, v_atm_n, beta, rho, volvol) 48 | logging.debug("Test alpha = {}".format(test_alpha)) 49 | logging.debug("Target alpha = {}".format(target_alpha)) 50 | assert test_alpha == approx(target_alpha, ERROR_TOLERANCE) 51 | 52 | 53 | def test_atm_vol_repricing(sabr_data): 54 | [f, t, v_atm_n_target, beta, rho, volvol], _ = sabr_data 55 | alpha = sabr.alpha(f, t, v_atm_n_target, beta, rho, volvol) 56 | v_atm_n_test = sabr.normal_vol(f, f, t, alpha, beta, rho, volvol) 57 | assert v_atm_n_test == approx(v_atm_n_target, ERROR_TOLERANCE) 58 | -------------------------------------------------------------------------------- /tests/hagan_2002_normal_sabr/test_h2002n_interpolation.py: -------------------------------------------------------------------------------- 1 | from pysabr import hagan_2002_normal_sabr as sabr 2 | import pytest 3 | from pytest import approx 4 | 5 | 6 | ERROR_TOLERANCE = 0.00001 # 0.01bps error is tolerated 7 | 8 | # [k, f, t, alpha, beta, rho, volvol] 9 | test_data = { 10 | 'Beta=1.0 OTMF (float division by zero fix)': [ 11 | [0.010, 0.025, 2., 0.118054435871, 1.0, 0.2, 0.3], 12 | 0.0026977617618464157 13 | ] 14 | } 15 | 16 | 17 | @pytest.fixture(scope="module", 18 | params=test_data.values(), 19 | ids=list(test_data.keys())) 20 | def sabr_data(request): 21 | yield request.param 22 | 23 | 24 | def test_interpolation(sabr_data): 25 | [k, f, t, alpha, beta, rho, volvol], v_n_target = sabr_data 26 | v_n_test = sabr.normal_vol(k, f, t, alpha, beta, rho, volvol) 27 | assert v_n_test == approx(v_n_target, ERROR_TOLERANCE) 28 | -------------------------------------------------------------------------------- /tests/hagan_2002_normal_sabr/test_normal_fit.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | from pysabr import Hagan2002NormalSABR 3 | import logging 4 | 5 | 6 | def test_normal_calibration_beta_05(): 7 | k = np.array([0.05, 0.055, 0.06, 0.065, 0.07, 0.075, 0.08, 0.085, 0.09, 0.095, 0.10]) 8 | v = np.array([116.63, 111.66, 109.96, 113.97, 124.7, 140.04, 157.3, 175.07, 192.78, 210.26, 227.42]) 9 | [t, f, s, beta] = np.array([0.5, 0.07520, 0.0000, 0.5000]) 10 | sabr = Hagan2002NormalSABR(f, s, t, beta=beta) 11 | sabr_test = sabr.fit(k, v) 12 | [alpha, rho, volvol] = sabr_test 13 | logging.debug('\nalpha={:.6f}, rho={:.6f}, volvol={:.6f}' 14 | .format(alpha, rho, volvol)) 15 | sabr_target = np.array([0.050394, 0.64125, 0.875235]) 16 | error_max = max(abs(sabr_test - sabr_target)) 17 | assert (error_max < 1e-5) 18 | -------------------------------------------------------------------------------- /tests/limit_cases/test_flat_smile.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | from pysabr import Hagan2002LognormalSABR 3 | from pysabr import Hagan2002NormalSABR 4 | from pytest import approx 5 | 6 | 7 | MAX_ABS_ERROR_VOL = 0.05e-4 # Max error is 0.05bps 8 | 9 | 10 | def test_flat_normal_smile(): 11 | f, s, t, v_atm_n, beta, rho, volvol = (0.02, 0.025, 1.0, 0.0040, 12 | 0.0, 0.0, 0.0) 13 | sabr_ln = Hagan2002LognormalSABR(f, s, t, v_atm_n, beta, rho, volvol) 14 | sabr_n = Hagan2002NormalSABR(f, s, t, v_atm_n, beta, rho, volvol) 15 | n_strikes = 20 16 | strikes = np.linspace(-1.99, 6.00, n_strikes) / 100 17 | sabr_ln_vols = [sabr_ln.normal_vol(k) for k in strikes] 18 | sabr_n_vols = [sabr_n.normal_vol(k) for k in strikes] 19 | target_vols = np.ones(n_strikes) * v_atm_n 20 | target_vols_approx = approx(target_vols, abs=MAX_ABS_ERROR_VOL) 21 | assert ( 22 | sabr_n_vols == target_vols_approx 23 | and sabr_ln_vols == target_vols_approx 24 | ) 25 | -------------------------------------------------------------------------------- /web/app.py: -------------------------------------------------------------------------------- 1 | import falcon 2 | import pysabr.models.hagan_2002_lognormal_sabr as sabr 3 | import logging 4 | 5 | 6 | logging.basicConfig(level=logging.INFO) 7 | 8 | 9 | # Helper to get required parameters 10 | def get_param_as_float(req, param): 11 | p = req.get_param(param, required=True) 12 | return float(p) 13 | 14 | 15 | # /sabr 16 | # Test URL: 17 | # http://127.0.0.1:5000/sabr?k=1.0&f=1.0&t=1.0&a=0.20&b=1.0&r=0.0&n=0.2 18 | class SabrLognormalVolResource(object): 19 | def on_get(self, req, resp): 20 | # Default status: success 21 | resp.status = falcon.HTTP_200 22 | # GET parameters 23 | params = ['k', 'f', 't', 'a', 'b', 'r', 'n'] 24 | values = list(map(lambda x: get_param_as_float(req, x), params)) 25 | logging.info("SABR Lognormal vol: " + str(values)) 26 | # Compute sabr.lognormal(k, f, t, alpha, beta, rho, volvol) 27 | result = sabr.lognormal_vol(*values) 28 | resp.body = ('{}'.format(result)) 29 | 30 | 31 | # /alpha 32 | # Test URL: 33 | # http://127.0.0.1:5000/alpha?v=0.6&f=1.0&t=1.0&b=1.0&r=0.0&n=0.2 34 | class SabrAlphaResource(object): 35 | def on_get(self, req, resp): 36 | # Default status: success 37 | resp.status = falcon.HTTP_200 38 | # GET parameters 39 | params = ['v', 'f', 't', 'b', 'r', 'n'] 40 | values = list(map(lambda x: get_param_as_float(req, x), params)) 41 | logging.info("Alpha: " + str(values)) 42 | # Compute sabr.lognormal(v, f, t, beta, rho, volvol) 43 | result = sabr.alpha(*values) 44 | resp.body = ('{}'.format(result)) 45 | 46 | 47 | # Callable WSGI app 48 | app = falcon.API() 49 | 50 | # Resource instance 51 | sabr_ln_vol_resource = SabrLognormalVolResource() 52 | sabr_alpha = SabrAlphaResource() 53 | 54 | # Routes 55 | app.add_route('/sabr', sabr_ln_vol_resource) 56 | app.add_route('/alpha', sabr_alpha) 57 | -------------------------------------------------------------------------------- /web/pySABR_web.bas: -------------------------------------------------------------------------------- 1 | Attribute VB_Name = "pySABR_web" 2 | Option Explicit 3 | Option Base 1 4 | 5 | Public Const pySABR_host As String = "us051vm:5000" 6 | ' Public Const pySABR_host = "127.0.0.1:5000" --> localhost for dev 7 | 8 | Public Function pySABR_alpha(ATM_vol As Double, f As Double, t As Double, Beta As Double, Rho As Double, Vovol As Double) 9 | 10 | Dim pySABR_service As String 11 | Dim result As Double 12 | pySABR_service = "http://" & pySABR_host & "/alpha?v=" & ATM_vol & "&f=" & f & "&t=" & t & "&b=" & Beta & "&r=" & Rho & "&n=" & Vovol 13 | result = WorksheetFunction.WebService(pySABR_service) 14 | pySABR_alpha = result 15 | 16 | End Function 17 | 18 | Public Function pySABR_lognormal_vol(k As Double, f As Double, t As Double, Alpha As Double, Beta As Double, Rho As Double, Vovol As Double) 19 | 20 | Dim pySABR_service As String 21 | Dim result As Double 22 | pySABR_service = "http://" & pySABR_host & "/sabr?k=" & k & "&f=" & f & "&t=" & t & "&a=" & Alpha & "&b=" & Beta & "&r=" & Rho & "&n=" & Vovol 23 | result = WorksheetFunction.WebService(pySABR_service) 24 | pySABR_lognormal_vol = result 25 | 26 | End Function 27 | -------------------------------------------------------------------------------- /web/test_web.py: -------------------------------------------------------------------------------- 1 | from falcon import testing 2 | import pytest 3 | 4 | 5 | @pytest.fixture() 6 | def client(scope='module'): 7 | from web.app import app 8 | return testing.TestClient(app) 9 | 10 | 11 | def test_sabr(client): 12 | params = {'k': 1.0, 'f': 0.02, 't': 1.0, 'a': 0.20, 13 | 'b': 1.0, 'r': 0.0, 'n': 0.0} 14 | result = client.simulate_get(path='/sabr', params=params) 15 | assert float(result.text) == 0.2 16 | 17 | 18 | def test_alpha(client): 19 | params = {'v': 0.6, 'f': 0.02, 't': 1.0, 20 | 'b': 1.0, 'r': 0.0, 'n': 0.0} 21 | result = client.simulate_get(path='/alpha', params=params) 22 | assert float(result.text) == 0.60 23 | --------------------------------------------------------------------------------