├── smithwilson ├── tests │ ├── __init__.py │ └── test_core.py ├── __init__.py └── core.py ├── requirements.txt ├── .gitattributes ├── .vscode └── launch.json ├── setup.py ├── LICENSE ├── main.py ├── .gitignore ├── .github └── workflows │ └── python-package.yml └── README.md /smithwilson/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/simicd/smith-wilson-py/HEAD/requirements.txt -------------------------------------------------------------------------------- /smithwilson/__init__.py: -------------------------------------------------------------------------------- 1 | from smithwilson.core import calculate_prices, fit_convergence_parameter, fit_smithwilson_rates, ufr_discount_factor, fit_parameters, wilson_function 2 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Python 2 | # Source files 3 | *.pxd text diff=python 4 | *.py text diff=python 5 | *.py3 text diff=python 6 | *.pyw text diff=python 7 | *.ipynb text 8 | *.pyx text diff=python 9 | 10 | # Binary files 11 | *.db binary 12 | *.p binary 13 | *.pkl binary 14 | *.pickle binary 15 | *.pyc binary 16 | *.pyd binary 17 | *.pyo binary 18 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "Python (Integrated): Start main.py", 9 | "type": "python", 10 | "request": "launch", 11 | "program": "${workspaceRoot}/main.py", 12 | "console": "integratedTerminal" 13 | }, 14 | ] 15 | } -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import setuptools 2 | 3 | with open("README.md", "r") as fh: 4 | long_description = fh.read() 5 | 6 | setuptools.setup( 7 | name="smithwilson", 8 | version="0.2.0", 9 | author="Dejan Simic", 10 | description= 11 | "Implementation of the Smith-Wilson yield curve fitting algorithm in Python for interpolations and extrapolations of zero-coupon bond rates", 12 | long_description=long_description, 13 | long_description_content_type="text/markdown", 14 | url="https://github.com/simicd/smith-wilson-py.git", 15 | packages=setuptools.find_packages(), 16 | classifiers=[ 17 | "Programming Language :: Python :: 3.7", 18 | "Programming Language :: Python :: 3.8", 19 | "Programming Language :: Python :: 3.9", 20 | "Programming Language :: Python :: 3.10", 21 | "License :: OSI Approved :: MIT License", 22 | "Operating System :: OS Independent", 23 | ], 24 | python_requires='>=3.7', 25 | install_requires=["numpy>=1.21.5", "scipy>=1.7.0"], 26 | ) 27 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Dejan Simic 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 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | import smithwilson as sw 2 | import pandas as pd 3 | 4 | # Input - Switzerland EIOPA spot rates with LLP 25 years and extrapolation period of 150 years 5 | # Source: https://eiopa.europa.eu/Publications/Standards/EIOPA_RFR_20190531.zip 6 | # EIOPA_RFR_20190531_Term_Structures.xlsx; Tab: RFR_spot_no_VA 7 | rates = [ 8 | -0.00803, -0.00814, -0.00778, -0.00725, -0.00652, -0.00565, -0.0048, -0.00391, -0.00313, -0.00214, -0.0014, -0.00067, 9 | -0.00008, 0.00051, 0.00108, 0.00157, 0.00197, 0.00228, 0.0025, 0.00264, 0.00271, 0.00274, 0.0028, 0.00291, 0.00309 10 | ] 11 | terms = [float(y + 1) for y in range(len(rates))] # 1.0, 2.0, ..., 25.0 12 | ufr = 0.029 13 | alpha = 0.128562 14 | 15 | # Target - Extrapolate to 150 years 16 | terms_target = [float(y + 1) for y in range(150)] # 1.0, 2.0, ..., 150.0 17 | 18 | # Calculate fitted rates based on actual observations and two parametes alpha & UFR 19 | fitted_rates = sw.fit_smithwilson_rates(rates_obs=rates, t_obs=terms, t_target=terms_target, alpha=alpha, ufr=ufr) 20 | 21 | # Display Outputs 22 | # Create dictionary with maturity as key and rate as value 23 | extrapolated = dict(zip(terms_target, fitted_rates.flatten())) 24 | print(extrapolated) 25 | 26 | # Create dataframe 27 | observed_df = pd.DataFrame(data=rates, index=terms, columns=["observed"]) 28 | extrapolated_df = pd.DataFrame(data=fitted_rates, index=terms_target, columns=["extrapolated"]) 29 | 30 | # Combie and print dataframe 31 | print(observed_df.join(extrapolated_df, how="outer")) 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Python 2 | # Byte-compiled / optimized / DLL files 3 | __pycache__/ 4 | *.py[cod] 5 | *$py.class 6 | 7 | # C extensions 8 | *.so 9 | 10 | # Distribution / packaging 11 | .Python 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 | pip-wheel-metadata/ 25 | share/python-wheels/ 26 | *.egg-info/ 27 | .installed.cfg 28 | *.egg 29 | MANIFEST 30 | 31 | # PyInstaller 32 | # Usually these files are written by a python script from a template 33 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 34 | *.manifest 35 | *.spec 36 | 37 | # Installer logs 38 | pip-log.txt 39 | pip-delete-this-directory.txt 40 | 41 | # Unit test / coverage reports 42 | htmlcov/ 43 | .tox/ 44 | .nox/ 45 | .coverage 46 | .coverage.* 47 | .cache 48 | nosetests.xml 49 | coverage.xml 50 | *.cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # celery beat schedule file 95 | celerybeat-schedule 96 | 97 | # SageMath parsed files 98 | *.sage.py 99 | 100 | # Environments 101 | .env 102 | .venv 103 | env/ 104 | venv/ 105 | ENV/ 106 | env.bak/ 107 | venv.bak/ 108 | 109 | # Spyder project settings 110 | .spyderproject 111 | .spyproject 112 | 113 | # Rope project settings 114 | .ropeproject 115 | 116 | # mkdocs documentation 117 | /site 118 | 119 | # mypy 120 | .mypy_cache/ 121 | .dmypy.json 122 | dmypy.json 123 | 124 | # Pyre type checker 125 | .pyre/ -------------------------------------------------------------------------------- /.github/workflows/python-package.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies, run tests and lint with a variety of Python versions 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions 3 | 4 | name: Python package 5 | 6 | on: 7 | push: 8 | branches: [ main ] 9 | pull_request: 10 | branches: [ main ] 11 | release: 12 | types: [published] 13 | 14 | jobs: 15 | build: 16 | runs-on: ubuntu-latest 17 | strategy: 18 | fail-fast: false 19 | matrix: 20 | python-version: ["3.8", "3.9", "3.10"] 21 | 22 | steps: 23 | - uses: actions/checkout@v2 24 | - name: Set up Python ${{ matrix.python-version }} 25 | uses: actions/setup-python@v2 26 | with: 27 | python-version: ${{ matrix.python-version }} 28 | - name: Install package and dependencies 29 | run: | 30 | python -m pip install --upgrade pip 31 | python -m pip install build flake8 pytest 32 | pip install -e . 33 | - name: Lint with flake8 34 | run: | 35 | # stop the build if there are Python syntax errors or undefined names 36 | flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics 37 | # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide 38 | flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics 39 | - name: Test with pytest 40 | run: | 41 | pytest 42 | - name: Build package 43 | run: python -m build 44 | - name: Archive production artifacts 45 | uses: actions/upload-artifact@v2 46 | with: 47 | name: dist 48 | path: | 49 | dist/**/*.tar.gz 50 | dist/**/*.whl 51 | 52 | deploy: 53 | needs: build 54 | if: github.event_name == 'release' && github.event.action == 'published' 55 | runs-on: ubuntu-latest 56 | 57 | steps: 58 | - uses: actions/checkout@v2 59 | - name: Set up Python 60 | uses: actions/setup-python@v2 61 | with: 62 | python-version: '3.10' 63 | - name: Install dependencies 64 | run: | 65 | python -m pip install --upgrade pip 66 | pip install build twine 67 | 68 | - name: Download Python package from build job 69 | uses: actions/download-artifact@v2 70 | with: 71 | name: dist 72 | path: ./dist 73 | 74 | - name: Test publishing 75 | env: 76 | TWINE_USERNAME: __token__ 77 | TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }} 78 | run: | 79 | twine upload dist/* 80 | # For testing only: twine upload -r testpypi dist/* 81 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # smithwilson 2 | ## Overview 3 | This Python package implements the Smith-Wilson yield curve fitting algorithm. It allows for interpolations and extrapolations of zero-coupon bond rates. This algorithm is used for the extrapolation of [EIOPA risk-free term structures](https://eiopa.europa.eu/Publications/Standards/Technical%20Documentation%20(31%20Jan%202018).pdf) in the Solvency II framework. Details are available in the Technical Paper [QIS 5 Risk-free interest rates](https://eiopa.europa.eu/Publications/QIS/ceiops-paper-extrapolation-risk-free-rates_en-20100802.pdf). Examples of extrapolated yield curves including the parameters applied can be found [here](https://eiopa.europa.eu/Publications/Standards/EIOPA_RFR_20190531.zip). 4 |

5 | 6 | ## How to use the package 7 | 1. Install the package with `pip install smithwilson` 8 | 2. To use the Smith-Wilson fitting algorithm, first import the Python package and specify the inputs. In the example below the inputs are zero-coupon rates with annual frequency up until year 25. The UFR is 2.9% and the convergence parameter alpha is 0.128562. The `terms` list defines the list of maturities, in this case `[1.0, 2.0, 3.0, ..., 25.0]` 9 | ```py 10 | import smithwilson as sw 11 | 12 | # Input - Switzerland EIOPA spot rates with LLP of 25 years 13 | # Source: https://eiopa.europa.eu/Publications/Standards/EIOPA_RFR_20190531.zip 14 | # EIOPA_RFR_20190531_Term_Structures.xlsx; Tab: RFR_spot_no_VA 15 | rates = [-0.00803, -0.00814, -0.00778, -0.00725, -0.00652, 16 | -0.00565, -0.0048, -0.00391, -0.00313, -0.00214, 17 | -0.0014, -0.00067, -0.00008, 0.00051, 0.00108, 18 | 0.00157, 0.00197, 0.00228, 0.0025, 0.00264, 19 | 0.00271, 0.00274, 0.0028, 0.00291, 0.00309] 20 | terms = [float(y + 1) for y in range(len(rates))] # [1.0, 2.0, ..., 25.0] 21 | ufr = 0.029 22 | alpha = 0.128562 23 | 24 | ``` 25 | 26 | 3. Specify the targeted output maturities. This is the set of terms you want to get rates fitted by Smith-Wilson. 27 | Expand the set of rates beyond the Last Liquid Point (e.g. extrapolate to 150 years with annual frequency): 28 | ```py 29 | # Extrapolate to 150 years 30 | terms_target = [float(y + 1) for y in range(150)] # [1.0, 2.0, ..., 150.0] 31 | ``` 32 | 33 | Alternatively, you can retrieve a different frequency (e.g. interpolate quarterly instead of annual): 34 | ```py 35 | # Interpolate to quarterly granularity 36 | terms_target = [float(y + 1) / 4 for y in range(25*4)] # [0.25, 0.5, ..., 25.0] 37 | ``` 38 | 39 | A combination of interpolation & extrapolation is possible, too. Same for sets of arbitrary maturities: 40 | ```py 41 | # Get rates for a well-specified set of maturities only 42 | terms_target = [0.25, 0.5, 1.0, 2.0, 5.0, 10.0, 20.0, 50.0, 100.0] 43 | ``` 44 | 45 | 4. Call the Smiwth-Wilson fitting algorithm. This returns the rates as numpy vector with each element corresponding to the maturity in `terms_target` 46 | ```py 47 | # Calculate fitted rates based on actual observations and two parametes alpha & UFR 48 | fitted_rates = sw.fit_smithwilson_rates(rates_obs=rates, t_obs=terms, 49 | t_target=terms_target, ufr=ufr, 50 | alpha=alpha) # Optional 51 | ``` 52 | 53 | The convergence parameter alpha is optional and will be estimated if not provided. The parameter determines the convergence speed of the yield curve towards the Ultimate Forward Rate (UFR). The parameter is estimated by finding the smallest value such that the difference between forward rate at convergence maturity and UFR is smaller than 1bps. 54 | 55 | 5. To display the results and/or processing them it can be useful to turn them into a table, here using the pandas library: 56 | ```py 57 | # Ensure pandas package is imported 58 | import pandas as pd 59 | 60 | # ... 61 | 62 | # Turn inputs & outputs into dataframe 63 | observed_df = pd.DataFrame(data=rates, index=terms, columns=["observed"]) 64 | extrapolated_df = pd.DataFrame(data=fitted_rates, index=terms_target, columns=["extrapolated"]) 65 | 66 | # Combine and print dataframe 67 | print(observed_df.join(extrapolated_df, how="outer")) 68 | ``` 69 | 70 | A complete example can be found in [main.py](https://github.com/simicd/smith-wilson-py/blob/master/main.py) 71 |

72 | 73 | ## Algorithm 74 | The algorithm is fully vectorized and uses numpy, making it very performant. The code is in [core.py](https://github.com/simicd/smith-wilson-py/blob/master/smithwilson/core.py). 75 | 76 | The function `fit_smithwilson_rates()` expects following parameters: 77 | - Observed rates 78 | - Observed maturities 79 | - Target maturities 80 | - Convergence parameter alpha 81 | - Ultimate forward rate (UFR) 82 | 83 | The observed rates and maturities are assumed to be before the Last Liquid Point (LLP). The targeted maturity vector can 84 | contain any set of maturities (e.g. more granular maturity structure (interpolation) or terms after the LLP (extrapolation)). 85 |

86 | 87 | 88 | The Smith-Wilson fitting algorithm calculates first the Wilson-matrix (EIOPA, 2010, p. 16): 89 | 90 | W = e^(-UFR * (t1 + t2)) * (α * min(t1, t2) - 0.5 * e^(-α * max(t1, t2)) 91 | * (e^(α * min(t1, t2)) - e^(-α * min(t1, t2)))) 92 | 93 | Given the Wilson-matrix `W`, vector of UFR discount factors `μ` and prices `P`, the parameter vector `ζ` can be calculated as follows (EIOPA, 2010, p.17): 94 | 95 | ζ = W^-1 * (μ - P) 96 | 97 | With the Smith-Wilson parameter `ζ` and Wilson-matrix `W`, the zero-coupon bond prices can be represented as (EIOPA, 2010, p. 18) in matrix notation: 98 | 99 | P = e^(-t * UFR) - W * ζ 100 | 101 | In the last case, `t` can be any maturity vector, i.e. with additional maturities to extrapolate rates. 102 |

103 | 104 | ## Sources 105 | [EIOPA (2010). QIS 5 Technical Paper; Risk-free interest rates – Extrapolation method](https://eiopa.europa.eu/Publications/QIS/ceiops-paper-extrapolation-risk-free-rates_en-20100802.pdf); p.11ff 106 | 107 | [EIOPA (2018). Technical documentation of the methodology to derive EIOPA’srisk-free interest rate term structures](https://eiopa.europa.eu/Publications/Standards/Technical%20Documentation%20(31%20Jan%202018).pdf); p.37-46 108 |

109 | -------------------------------------------------------------------------------- /smithwilson/core.py: -------------------------------------------------------------------------------- 1 | from math import log 2 | import numpy as np 3 | from scipy import optimize 4 | from typing import Union, List, Optional 5 | 6 | 7 | def calculate_prices(rates: Union[np.ndarray, List[float]], t: Union[np.ndarray, List[float]]) -> np.ndarray: 8 | """Calculate prices from zero-coupon rates 9 | 10 | Args: 11 | rates: zero-coupon rates vector of length n 12 | t: time to maturity vector (in years) of length n 13 | 14 | Returns: 15 | Prices as vector of length n 16 | """ 17 | 18 | # Convert list into numpy array 19 | rates = np.array(rates) 20 | t = np.array(t) 21 | 22 | return np.power(1 + rates, -t) 23 | 24 | 25 | def ufr_discount_factor(ufr: float, t: Union[np.ndarray, List[float]]) -> np.ndarray: 26 | """Calculate Ultimate Forward Rate (UFR) discount factors. 27 | 28 | Takes the UFR with a vector of maturities and returns for each of the 29 | maturities the discount factor 30 | d_UFR = e^(-UFR * t) 31 | 32 | Note that UFR is expected to be annualy compounded and that 33 | this function performs the calculation of the log return prior 34 | to applying the formula above. 35 | 36 | Args: 37 | ufr: Ultimate Forward Rate (annualized/annual compounding) 38 | t: time to maturity vector (in years) of length n 39 | 40 | Returns: 41 | UFR discount factors as vector of length n 42 | """ 43 | 44 | # Convert annualized ultimate forward rate to log-return 45 | ufr = log(1 + ufr) 46 | 47 | # Convert list into numpy array 48 | t = np.array(t) 49 | 50 | return np.exp(-ufr * t) 51 | 52 | 53 | def wilson_function(t1: Union[np.ndarray, List[float]], 54 | t2: Union[np.ndarray, List[float]], 55 | alpha: float, ufr: float) -> np.ndarray: 56 | """Calculate matrix of Wilson functions 57 | 58 | The Smith-Wilson method requires the calculation of a series of Wilson 59 | functions. The Wilson function is calculated for each maturity combination 60 | t1 and t2. If t1 and t2 are scalars, the result is a scalar. If t1 and t2 61 | are vectors of shape (m, 1) and (n, 1), then the result is a matrix of 62 | Wilson functions with shape (m, n) as defined on p. 16: 63 | W = e^(-UFR * (t1 + t2)) * (α * min(t1, t2) - 0.5 * e^(-α * max(t1, t2)) 64 | * (e^(α * min(t1, t2)) - e^(-α * min(t1, t2)))) 65 | 66 | Source: EIOPA QIS 5 Technical Paper; Risk-free interest rates – Extrapolation method; p.11ff 67 | https://eiopa.europa.eu/Publications/QIS/ceiops-paper-extrapolation-risk-free-rates_en-20100802.pdf 68 | 69 | Args: 70 | t1: time to maturity vector of length m 71 | t2: time to maturity vector of length n 72 | alpha: Convergence speed parameter 73 | ufr: Ultimate Forward Rate (annualized/annual compounding) 74 | 75 | Returns: 76 | Wilson-matrix of shape (m, n) as numpy matrix 77 | """ 78 | 79 | # Take time vectors of shape (nx1) and (mx1) and turn them into matrices of shape (mxn). 80 | # This is achieved by repeating the vectors along the axis 1. The operation is required 81 | # because the Wilson function needs all possible combinations of maturities to construct 82 | # the Wilson matrix 83 | m = len(t1) 84 | n = len(t2) 85 | t1_Mat = np.repeat(t1, n, axis=1) 86 | t2_Mat = np.repeat(t2, m, axis=1).T 87 | 88 | # Calculate the minimum and maximum of the two matrices 89 | min_t = np.minimum(t1_Mat, t2_Mat) 90 | max_t = np.maximum(t1_Mat, t2_Mat) 91 | 92 | # Calculate the UFR discount factor - p.16 93 | ufr_disc = ufr_discount_factor(ufr=ufr, t=(t1_Mat + t2_Mat)) 94 | W = ufr_disc * (alpha * min_t - 0.5 * np.exp(-alpha * max_t) * \ 95 | (np.exp(alpha * min_t) - np.exp(-alpha * min_t))) 96 | 97 | return W 98 | 99 | 100 | def fit_parameters(rates: Union[np.ndarray, List[float]], 101 | t: Union[np.ndarray, List[float]], 102 | alpha: float, ufr: float) -> np.ndarray: 103 | """Calculate Smith-Wilson parameter vector ζ 104 | 105 | Given the Wilson-matrix, vector of discount factors and prices, 106 | the parameter vector can be calculated as follows (p.17): 107 | ζ = W^-1 * (μ - P) 108 | 109 | Source: EIOPA QIS 5 Technical Paper; Risk-free interest rates – Extrapolation method; p.11ff 110 | https://eiopa.europa.eu/Publications/QIS/ceiops-paper-extrapolation-risk-free-rates_en-20100802.pdf 111 | 112 | Args: 113 | rates: Observed zero-coupon rates vector of length n 114 | t1: Observed time to maturity vector (in years) of length n 115 | alpha: Convergence speed parameter 116 | ufr: Ultimate Forward Rate (annualized/annual compounding) 117 | 118 | Returns: 119 | Wilson-matrix of shape (m, n) as numpy matrix 120 | """ 121 | 122 | # Calcualte square matrix of Wilson functions, UFR discount vector and price vector 123 | # The price vector is calculated with zero-coupon rates and assumed face value of 1 124 | # For the estimation of zeta, t1 and t2 correspond both to the observed maturities 125 | W = wilson_function(t1=t, t2=t, alpha=alpha, ufr=ufr) 126 | mu = ufr_discount_factor(ufr=ufr, t=t) 127 | P = calculate_prices(rates=rates, t=t) 128 | 129 | # Calculate vector of parameters (p. 17) 130 | # To invert the Wilson-matrix, conversion to type matrix is required 131 | zeta = np.matrix(W).I * (mu - P) 132 | zeta = np.array(zeta) # Convert back to more general array type 133 | 134 | return zeta 135 | 136 | 137 | def fit_smithwilson_rates(rates_obs: Union[np.ndarray, List[float]], 138 | t_obs: Union[np.ndarray, List[float]], 139 | t_target: Union[np.ndarray, List[float]], 140 | ufr: float, alpha: Optional[float] = None) -> np.ndarray: 141 | """Calculate zero-coupon yields with Smith-Wilson method based on observed rates. 142 | 143 | This function expects the rates and initial maturity vector to be 144 | before the Last Liquid Point (LLP). The targeted maturity vector can 145 | contain both, more granular maturity structure (interpolation) or terms after 146 | the LLP (extrapolation). 147 | 148 | The Smith-Wilson method calculated first the Wilson-matrix (p. 16): 149 | W = e^(-UFR * (t1 + t2)) * (α * min(t1, t2) - 0.5 * e^(-α * max(t1, t2)) 150 | * (e^(α * min(t1, t2)) - e^(-α * min(t1, t2)))) 151 | 152 | Given the Wilson-matrix, vector of discount factors and prices, 153 | the parameter vector can be calculated as follows (p.17): 154 | ζ = W^-1 * (μ - P) 155 | 156 | With the Smith-Wilson parameter and Wilson-matrix, the zero-coupon bond 157 | prices can be represented as (p. 18) in matrix notation: 158 | P = e^(-t * UFR) - W * zeta 159 | 160 | In the last case, t can be any maturity vector 161 | 162 | Source: EIOPA QIS 5 Technical Paper; Risk-free interest rates – Extrapolation method; p.11ff 163 | https://eiopa.europa.eu/Publications/QIS/ceiops-paper-extrapolation-risk-free-rates_en-20100802.pdf 164 | 165 | Args: 166 | rates_obs: Initially observed zero-coupon rates vector before LLP of length n 167 | t_obs: Initially observed time to maturity vector (in years) of length n 168 | t_target: New targeted maturity vector (in years) with interpolated/extrapolated terms 169 | ufr: Ultimate Forward Rate (annualized/annual compounding) 170 | alpha: (optional) Convergence speed parameter. If not provided estimated using 171 | the `fit_convergence_parameter()` function 172 | 173 | Returns: 174 | Vector of zero-coupon rates with Smith-Wilson interpolated or extrapolated rates 175 | """ 176 | 177 | # Convert list to numpy array and use reshape to convert from 1-d to 2-d array 178 | # E.g. reshape((-1, 1)) converts an input of shape (10,) with second dimension 179 | # being empty (1-d vector) to shape (10, 1) where second dimension is 1 (2-d vector) 180 | rates_obs = np.array(rates_obs).reshape((-1, 1)) 181 | t_obs = np.array(t_obs).reshape((-1, 1)) 182 | t_target = np.array(t_target).reshape((-1, 1)) 183 | 184 | if alpha is None: 185 | alpha = fit_convergence_parameter(rates_obs=rates_obs, t_obs=t_obs, ufr=ufr) 186 | 187 | zeta = fit_parameters(rates=rates_obs, t=t_obs, alpha=alpha, ufr=ufr) 188 | ufr_disc = ufr_discount_factor(ufr=ufr, t=t_target) 189 | W = wilson_function(t1=t_target, t2=t_obs, alpha=alpha, ufr=ufr) 190 | 191 | # Price vector - equivalent to discounting with zero-coupon yields 1/(1 + r)^t 192 | # for prices where t_obs = t_target. All other matuirites are interpolated or extrapolated 193 | P = ufr_disc - W @ zeta # '@' in numpy is the dot product of two matrices 194 | 195 | # Transform price vector to zero-coupon rate vector (1/P)^(1/t) - 1 196 | return np.power(1 / P, 1 / t_target) - 1 197 | 198 | 199 | def fit_convergence_parameter(rates_obs: Union[np.ndarray, List[float]], 200 | t_obs: Union[np.ndarray, List[float]], 201 | ufr: float) -> float: 202 | """Fit Smith-Wilson convergence factor (alpha). 203 | 204 | Args: 205 | rates_obs: Initially observed zero-coupon rates vector before LLP of length n 206 | t_obs: Initially observed time to maturity vector (in years) of length n 207 | ufr: Ultimate Forward Rate (annualized/annual compounding) 208 | 209 | Returns: 210 | Convergence parameter alpha 211 | """ 212 | 213 | # Last liquid point (LLP) 214 | llp = np.max(t_obs) 215 | 216 | # Maturity at which forward curve is supposed to converge to ultimate forward rate (UFR) 217 | # See: https://www.eiopa.europa.eu/sites/default/files/risk_free_interest_rate/12092019-technical_documentation.pdf (chapter 7.D., p. 39) 218 | convergence_t = max(llp + 40, 60) 219 | 220 | # Optimization function calculating the difference between UFR and forward rate at convergence point 221 | def forward_difference(alpha: float): 222 | # Fit yield curve 223 | rates = fit_smithwilson_rates(rates_obs=rates_obs, # Input rates to be fitted 224 | t_obs=t_obs, # Maturities of these rates 225 | t_target=[convergence_t, convergence_t + 1], # Maturity at which curve is supposed to converge to UFR 226 | alpha=alpha, # Optimization parameter 227 | ufr=ufr) # Ultimate forward rate 228 | 229 | # Calculate the forward rate at convergence maturity - this is an approximation since 230 | # according to the documentation the minimization should be based on the forward intensity, not forward rate 231 | forward_rate = (1 + rates[1])**(convergence_t + 1) / (1 + rates[0])**(convergence_t) - 1 232 | 233 | # Absolute difference needs to be smaller than 1 bps 234 | return -abs(forward_rate - ufr) + 1 / 10_000 235 | 236 | # Minimize alpha w.r.t. forward difference criterion 237 | root = optimize.minimize(lambda alpha: alpha, x0=0.15, method='SLSQP', bounds=[[0.05, 1.0]], 238 | constraints=[{ 239 | 'type': 'ineq', 240 | 'fun': forward_difference 241 | }], 242 | options={ 243 | 'ftol': 1e-6, 244 | 'disp': True 245 | }) 246 | 247 | return float(root.x) 248 | -------------------------------------------------------------------------------- /smithwilson/tests/test_core.py: -------------------------------------------------------------------------------- 1 | 2 | import unittest 3 | import smithwilson as sw 4 | import numpy as np 5 | 6 | class TestSmithWilson(unittest.TestCase): 7 | 8 | def test_ufr_discount_factor(self): 9 | """Test creation of UFR discount factor vector""" 10 | 11 | # Input 12 | ufr = 0.029 13 | t = np.array([0.25, 1.0, 5.0, 49.5, 125.0]) 14 | 15 | # Expected Output 16 | expected = np.array([0.992878614, 0.971817298, 0.866808430, 0.242906395, 0.028059385]) 17 | 18 | # Actual Output 19 | actual = sw.ufr_discount_factor(ufr=ufr, t=t) 20 | 21 | # Assert 22 | self.assertEqual(type(actual), type(expected), "Returned types not matching") 23 | self.assertTupleEqual(actual.shape, expected.shape, "Shapes not matching") 24 | np.testing.assert_almost_equal(actual, expected, decimal=8, err_msg="UFR discount factors not matching") 25 | 26 | 27 | def test_calculate_prices(self): 28 | """Test calculation of zero-coupon bond price vector""" 29 | 30 | # Input 31 | r = np.array([0.02, 0.025, -0.033, 0.01, 0.0008]) 32 | t = np.array([0.25, 1.0, 5.0, 49.5, 125.0]) 33 | 34 | # Expected Output 35 | expected = np.array([0.995061577, 0.975609756, 1.182681027, 0.611071456, 0.904873593]) 36 | 37 | # Actual Output 38 | actual = sw.calculate_prices(rates=r, t=t) 39 | 40 | # Assert 41 | self.assertEqual(type(actual), type(expected), "Returned types not matching") 42 | self.assertTupleEqual(actual.shape, expected.shape, "Shapes not matching") 43 | np.testing.assert_almost_equal(actual, expected, decimal=8, err_msg="Prices not matching") 44 | 45 | 46 | def test_wilson_function_symmetric(self): 47 | """Test creation of a symmetric Wilson-function matrix (t1 = t2)""" 48 | 49 | # Input 50 | t = np.array([0.25, 1.0, 5.0, 49.5, 125.0]).reshape((-1, 1)) 51 | ufr = 0.029 52 | alpha = 0.2 53 | 54 | # Expected Output 55 | expected = np.array([[0.00238438, 0.00872884, 0.02719467, 0.01205822, 0.00139298], 56 | [0.00872884, 0.03320614, 0.10608305, 0.04720974, 0.00545372], 57 | [0.02719467, 0.10608305, 0.42652097, 0.2105409 , 0.02432211], 58 | [0.01205822, 0.04720974, 0.2105409 , 0.55463306, 0.06747646], 59 | [0.00139298, 0.00545372, 0.02432211, 0.06747646, 0.01928956]]) 60 | 61 | # Actual Output 62 | actual = sw.wilson_function(t1=t, t2=t, ufr=ufr, alpha=alpha) 63 | 64 | # Assert 65 | self.assertEqual(type(actual), type(expected), "Returned types not matching") 66 | self.assertTupleEqual(actual.shape, expected.shape, "Shapes not matching") 67 | np.testing.assert_almost_equal(actual, expected, decimal=8, err_msg="Wilson functions not matching") 68 | 69 | 70 | def test_wilson_function_asymmetric_t1_lt_t2(self): 71 | """Test creation of a symmetric Wilson-function matrix (t1 != t2) with length of t1 > length of t2""" 72 | 73 | # Input 74 | t_obs = np.array([0.25, 1.0, 5.0, 49.5, 125.0]).reshape((-1, 1)) 75 | t_target = np.array([0.25, 0.5, 1.0, 2.0, 2.5, 3.5, 5.0, 10.0, 20.0, 49.5, 125.0]).reshape((-1, 1)) 76 | ufr = 0.029 77 | alpha = 0.2 78 | 79 | # Expected Output 80 | expected = np.array([[0.00238438, 0.00872884, 0.02719467, 0.01205822, 0.00139298], 81 | [0.00463874, 0.01723526, 0.0539627 , 0.0239447 , 0.00276612], 82 | [0.00872884, 0.03320614, 0.10608305, 0.04720974, 0.00545372], 83 | [0.015444 , 0.05969492, 0.20375322, 0.0917584 , 0.01060004], 84 | [0.01817438, 0.07046799, 0.24880429, 0.11307011, 0.013062 ], 85 | [0.02260267, 0.08794588, 0.33012767, 0.15383656, 0.01777143], 86 | [0.02719467, 0.10608305, 0.42652097, 0.2105409 , 0.02432211], 87 | [0.03225016, 0.12614043, 0.54769846, 0.36498556, 0.04216522], 88 | [0.02751232, 0.10770227, 0.47881259, 0.54833094, 0.06336226], 89 | [0.01205822, 0.04720974, 0.2105409 , 0.55463306, 0.06747646], 90 | [0.00139298, 0.00545372, 0.02432211, 0.06747646, 0.01928956]]) 91 | 92 | # Actual Output 93 | actual = sw.wilson_function(t1=t_target, t2=t_obs, ufr=ufr, alpha=alpha) 94 | 95 | # Assert 96 | self.assertEqual(type(actual), type(expected), "Returned types not matching") 97 | self.assertTupleEqual(actual.shape, expected.shape, "Shapes not matching") 98 | np.testing.assert_almost_equal(actual, expected, decimal=8, err_msg="Wilson functions not matching") 99 | 100 | 101 | def test_wilson_function_asymmetric_t2_lt_t1(self): 102 | """Test creation of a symmetric Wilson-function matrix (t1 != t2) with length of t2 > length of t1""" 103 | 104 | # Input 105 | t_target = np.array([0.50, 1.5, 7.0, 22.5]).reshape((-1, 1)) 106 | t_obs = np.array([0.25, 1.0, 2.0, 2.5, 5.0, 10.0, 20.0]).reshape((-1, 1)) 107 | ufr = 0.032 108 | alpha = 0.15 109 | 110 | # Expected Output 111 | expected = np.array([[0.00263839, 0.00990704, 0.01791847, 0.02129457, 0.03324991, 0.04184617, 0.03736174], 112 | [0.00714378, 0.02751832, 0.05096578, 0.06087744, 0.09600535, 0.12138299, 0.1085669 ], 113 | [0.01939785, 0.07563626, 0.14568738, 0.17843321, 0.31674624, 0.45088288, 0.42190812], 114 | [0.01768861, 0.06909389, 0.13384921, 0.16464728, 0.3035725 , 0.51271549, 0.69668792]]) 115 | 116 | # Actual Output 117 | actual = sw.wilson_function(t1=t_target, t2=t_obs, ufr=ufr, alpha=alpha) 118 | 119 | # Assert 120 | self.assertEqual(type(actual), type(expected), "Returned types not matching") 121 | self.assertTupleEqual(actual.shape, expected.shape, "Shapes not matching") 122 | np.testing.assert_almost_equal(actual, expected, decimal=8, err_msg="Wilson functions not matching") 123 | 124 | 125 | def test_fit_parameters(self): 126 | """Test estimation of Smith-Wilson parameter vector ζ""" 127 | 128 | # Input 129 | r = np.array([0.02, 0.025, -0.033, 0.01, 0.0008]).reshape((-1, 1)) 130 | t = np.array([0.25, 1.0, 5.0, 49.5, 125.0]).reshape((-1, 1)) 131 | ufr = 0.029 132 | alpha = 0.2 133 | 134 | # Expected Output 135 | expected = np.array([-42.78076209, 23.4627511, -3.96498616, 8.92604195, -75.22418515]).reshape((-1, 1)) 136 | 137 | # Actual Output 138 | actual = sw.fit_parameters(rates=r, t=t, ufr=ufr, alpha=alpha) 139 | 140 | # Assert 141 | self.assertEqual(type(actual), type(expected), "Returned types not matching") 142 | self.assertTupleEqual(actual.shape, expected.shape, "Shapes not matching") 143 | np.testing.assert_almost_equal(actual, expected, decimal=8, err_msg="Parameter not matching") 144 | 145 | 146 | def test_fit_smithwilson_rates_actual(self): 147 | """Test estimation of yield curve fitted with the Smith-Wilson algorithm. 148 | 149 | This example uses an actual example from EIOPA. Deviations must be less than 1bps (0.01%). 150 | Source: https://eiopa.europa.eu/Publications/Standards/EIOPA_RFR_20190531.zip 151 | EIOPA_RFR_20190531_Term_Structures.xlsx; Tab: RFR_spot_no_VA; Switzerland 152 | """ 153 | 154 | # Input 155 | r = np.array([-0.00803, -0.00814, -0.00778, -0.00725, -0.00652, 156 | -0.00565, -0.0048, -0.00391, -0.00313, -0.00214, 157 | -0.0014, -0.00067, -0.00008, 0.00051, 0.00108, 158 | 0.00157, 0.00197, 0.00228, 0.0025, 0.00264, 159 | 0.00271, 0.00274, 0.0028, 0.00291, 0.00309]).reshape((-1, 1)) 160 | t = np.array([float(y + 1) for y in range(len(r))]).reshape((-1, 1)) # 1.0, 2.0, ..., 25.0 161 | ufr = 0.029 162 | alpha = 0.128562 163 | 164 | t_target = np.array([float(y + 1) for y in range(65)]).reshape((-1, 1)) 165 | 166 | # Expected Output 167 | expected = np.array([-0.00803, -0.00814, -0.00778, -0.00725, -0.00652, 168 | -0.00565, -0.0048, -0.00391, -0.00313, -0.00214, 169 | -0.0014, -0.00067, -0.00008, 0.00051, 0.00108, 170 | 0.00157, 0.00197, 0.00228, 0.0025, 0.00264, 171 | 0.00271, 0.00274, 0.0028, 0.00291, 0.00309, 172 | 0.00337, 0.00372, 0.00412, 0.00455, 0.00501, 173 | 0.00548, 0.00596, 0.00644, 0.00692, 0.00739, 174 | 0.00786, 0.00831, 0.00876, 0.00919, 0.00961, 175 | 0.01002, 0.01042, 0.01081, 0.01118, 0.01154, 176 | 0.01189, 0.01223, 0.01255, 0.01287, 0.01318, 177 | 0.01347, 0.01376, 0.01403, 0.0143, 0.01456, 178 | 0.01481, 0.01505, 0.01528, 0.01551, 0.01573, 179 | 0.01594, 0.01615, 0.01635, 0.01655, 0.01673]).reshape((-1, 1)) 180 | 181 | # Actual Output 182 | actual = sw.fit_smithwilson_rates(rates_obs=r, t_obs=t, t_target=t_target, ufr=ufr, alpha=alpha) 183 | 184 | # Assert - Precision of 4 decimal points equals deviatino of less than 1bps 185 | self.assertEqual(type(actual), type(expected), "Returned types not matching") 186 | self.assertTupleEqual(actual.shape, expected.shape, "Shapes not matching") 187 | np.testing.assert_almost_equal(actual, expected, decimal=4, err_msg="Fitted rates not matching") 188 | 189 | 190 | def test_fit_smithwilson_rates_incl_convergence(self): 191 | """Test estimation of yield curve without known convergence factor alpha. 192 | 193 | This example uses an actual example from EIOPA. Deviations must be less than 1bps (0.01%). 194 | Source: https://eiopa.europa.eu/Publications/Standards/EIOPA_RFR_20190531.zip 195 | EIOPA_RFR_20190531_Term_Structures.xlsx; Tab: RFR_spot_no_VA; Switzerland 196 | """ 197 | 198 | # Input 199 | r = np.array([-0.00803, -0.00814, -0.00778, -0.00725, -0.00652, 200 | -0.00565, -0.0048, -0.00391, -0.00313, -0.00214, 201 | -0.0014, -0.00067, -0.00008, 0.00051, 0.00108, 202 | 0.00157, 0.00197, 0.00228, 0.0025, 0.00264, 203 | 0.00271, 0.00274, 0.0028, 0.00291, 0.00309]).reshape((-1, 1)) 204 | t = np.array([float(y + 1) for y in range(len(r))]).reshape((-1, 1)) # 1.0, 2.0, ..., 25.0 205 | ufr = 0.029 206 | alpha = 0.128562 207 | 208 | t_target = np.array([float(y + 1) for y in range(65)]).reshape((-1, 1)) 209 | 210 | # Expected Output 211 | expected = np.array([-0.00803, -0.00814, -0.00778, -0.00725, -0.00652, 212 | -0.00565, -0.0048, -0.00391, -0.00313, -0.00214, 213 | -0.0014, -0.00067, -0.00008, 0.00051, 0.00108, 214 | 0.00157, 0.00197, 0.00228, 0.0025, 0.00264, 215 | 0.00271, 0.00274, 0.0028, 0.00291, 0.00309, 216 | 0.00337, 0.00372, 0.00412, 0.00455, 0.00501, 217 | 0.00548, 0.00596, 0.00644, 0.00692, 0.00739, 218 | 0.00786, 0.00831, 0.00876, 0.00919, 0.00961, 219 | 0.01002, 0.01042, 0.01081, 0.01118, 0.01154, 220 | 0.01189, 0.01223, 0.01255, 0.01287, 0.01318, 221 | 0.01347, 0.01376, 0.01403, 0.0143, 0.01456, 222 | 0.01481, 0.01505, 0.01528, 0.01551, 0.01573, 223 | 0.01594, 0.01615, 0.01635, 0.01655, 0.01673]).reshape((-1, 1)) 224 | 225 | # Actual Output 226 | actual = sw.fit_smithwilson_rates(rates_obs=r, t_obs=t, t_target=t_target, ufr=ufr) 227 | 228 | # Assert - Precision of 4 decimal points equals deviatino of less than 1bps 229 | self.assertEqual(type(actual), type(expected), "Returned types not matching") 230 | self.assertTupleEqual(actual.shape, expected.shape, "Shapes not matching") 231 | np.testing.assert_almost_equal(actual, expected, decimal=4, err_msg="Fitted rates not matching") 232 | 233 | 234 | def test_fit_smithwilson_rates_random(self): 235 | """Test estimation of yield curve fitted with the Smith-Wilson algorithm using random data points.""" 236 | 237 | # Input 238 | r = np.array([0.02, 0.025, -0.033, 0.01, 0.0008]).reshape((-1, 1)) 239 | t = np.array([0.25, 1.0, 5.0, 20.0, 25.0]).reshape((-1, 1)) 240 | ufr = 0.029 241 | alpha = 0.12 242 | 243 | t_target = np.array([0.25, 0.5, 1.0, 2.0, 2.5, 3.5, 5.0, 10.0, 20.0, 49.5, 125.0]).reshape((-1, 1)) 244 | 245 | # Expected Output 246 | expected = np.array([0.02, 0.02417656, 0.025, 0.00361999, -0.00733027, 247 | -0.02345319, -0.033, -0.01256218, 0.01, 0.00715949, 0.02015626]).reshape((-1, 1)) 248 | 249 | # Actual Output 250 | actual = sw.fit_smithwilson_rates(rates_obs=r, t_obs=t, t_target=t_target, ufr=ufr, alpha=alpha) 251 | 252 | # Assert 253 | self.assertEqual(type(actual), type(expected), "Returned types not matching") 254 | self.assertTupleEqual(actual.shape, expected.shape, "Shapes not matching") 255 | np.testing.assert_almost_equal(actual, expected, decimal=8, err_msg="Fitted rates not matching") 256 | 257 | 258 | 259 | def test_fit_alpha(self): 260 | """Test estimation of convergence factor alpha. 261 | 262 | This example uses an actual example from EIOPA. Deviations must be less than 0.001. 263 | Source: https://eiopa.europa.eu/Publications/Standards/EIOPA_RFR_20190531.zip 264 | EIOPA_RFR_20190531_Term_Structures.xlsx; Tab: RFR_spot_no_VA; Switzerland 265 | """ 266 | 267 | # Input 268 | r = np.array([-0.00803, -0.00814, -0.00778, -0.00725, -0.00652, 269 | -0.00565, -0.0048, -0.00391, -0.00313, -0.00214, 270 | -0.0014, -0.00067, -0.00008, 0.00051, 0.00108, 271 | 0.00157, 0.00197, 0.00228, 0.0025, 0.00264, 272 | 0.00271, 0.00274, 0.0028, 0.00291, 0.00309]).reshape((-1, 1)) 273 | t = np.array([float(y + 1) for y in range(len(r))]).reshape((-1, 1)) # 1.0, 2.0, ..., 25.0 274 | ufr = 0.029 275 | 276 | # Expected Output 277 | alpha_expected = 0.128562 278 | 279 | # Actual Output 280 | alpha_actual = sw.fit_convergence_parameter(rates_obs=r, t_obs=t, ufr=ufr) 281 | 282 | # Assert - Precision of 4 decimal points equals deviatino of less than 1bps 283 | self.assertEqual(type(alpha_actual), type(alpha_expected), "Returned types not matching") 284 | self.assertAlmostEqual(alpha_actual, alpha_expected, msg="Alpha not matching", delta=0.001) 285 | --------------------------------------------------------------------------------