├── 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 |
--------------------------------------------------------------------------------