├── .gitignore ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── dh_european_option.py ├── dh_heatrate_option.py ├── dh_variable_annuity.py ├── setup.py └── trellis ├── __init__.py ├── __version__.py ├── models ├── __init__.py ├── base.py ├── european_option │ ├── __init__.py │ ├── analytics.py │ └── model.py ├── heatrate_option │ ├── analytics.py │ └── model.py ├── utils.py └── variable_annuity │ ├── __init__.py │ ├── analytics.py │ └── model.py ├── plotting.py ├── random_processes.py └── utils.py /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__/ 2 | 3 | .vscode/ 4 | *.code-workspace 5 | 6 | build/ 7 | dist/ 8 | *.egg-info/ 9 | 10 | checkpoints/ 11 | logs/ 12 | output/ 13 | plots/ 14 | results/ 15 | 16 | checkpoint 17 | *.data-* 18 | *.index 19 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing Guidelines 2 | 3 | ## Pull Request Checklist 4 | 5 | Before sending your pull requests, make sure you followed this list. 6 | 7 | - Read [contributing guidelines](CONTRIBUTING.md). 8 | - Read the [Contributor License Agreement (CLA)](CONTRIBUTING.md#Contributor-License-Agreement). 9 | - Check if my changes are consistent with the [guidelines](CONTRIBUTING.md#Contribution-guidelines-and-standards). 10 | - Changes are consistent with the [coding style](CONTRIBUTING.md#Python-coding-style). 11 | - Commits are consistent with the [commit conventions](CONTRIBUTING.md#Commit-conventions). 12 | 13 | ## How to become a contributor and submit your own code 14 | 15 | ### Contributor License Agreement 16 | 17 | By contributing to this codebase You understand and agree that this project and contributions to it are public and that a record of the contribution (including all personal information You submit with it, including Your full name and email address) is maintained indefinitely and may be redistributed consistent with this project, compliance with the open source license(s) involved, and maintenance of authorship attribution. 18 | 19 | You accept and agree to the following terms and conditions for Your present and future Contributions submitted to Beacon Platform Inc. Except for the license granted herein to Beacon Platform Inc and recipients of software distributed by Beacon Platform Inc, You reserve all right, title, and interest in and to Your Contributions. 20 | 21 | 1. Definitions. 22 | 23 | "You" (or "Your") shall mean the copyright owner or legal entity authorized by the copyright owner that is making this Agreement with Beacon Platform Inc. For legal entities, the entity making a Contribution and all other entities that control, are controlled by, or are under common control with that entity are considered to be a single Contributor. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. 24 | 25 | "Contribution" shall mean any original work of authorship, including any modifications or additions to an existing work, that is intentionally submitted by You to Beacon Platform Inc for inclusion in, or documentation of, any of the products owned or managed by Beacon Platform Inc (the "Work"). For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to Beacon Platform Inc or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, Beacon Platform Inc for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by You as "Not a Contribution." 26 | 27 | 2. Grant of Copyright License. Subject to the terms and conditions of this Agreement, You hereby grant to Beacon Platform Inc and to recipients of software distributed by Beacon Platform Inc a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare derivative works of, publicly display, publicly perform, sublicense, and distribute Your Contributions and such derivative works. 28 | 29 | 3. Grant of Patent License. Subject to the terms and conditions of this Agreement, You hereby grant to Beacon Platform Inc and to recipients of software distributed by Beacon Platform Inc a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by You that are necessarily infringed by Your Contribution(s) alone or by combination of Your Contribution(s) with the Work to which such Contribution(s) was submitted. If any entity institutes patent litigation against You or any other entity (including a cross-claim or counterclaim in a lawsuit) alleging that your Contribution, or the Work to which you have contributed, constitutes direct or contributory patent infringement, then any patent licenses granted to that entity under this Agreement for that Contribution or Work shall terminate as of the date such litigation is filed. 30 | 31 | 4. You represent that you are legally entitled to grant the above license. If your employer(s) has rights to intellectual property that you create that includes your Contributions, you represent that you have received permission to make Contributions on behalf of that employer, that your employer has waived such rights for your Contributions to Beacon Platform Inc, or that your employer has executed a separate Corporate CLA with Beacon Platform Inc. 32 | 33 | 5. You represent that each of Your Contributions is Your original creation (see section 7 for submissions on behalf of others). You represent that Your Contribution submissions include complete details of any third-party license or other restriction (including, but not limited to, related patents and trademarks) of which you are personally aware and which are associated with any part of Your Contributions. 34 | 35 | 6. You are not expected to provide support for Your Contributions, except to the extent You desire to provide support. You may provide support for free, for a fee, or not at all. Unless required by applicable law or agreed to in writing, You provide Your Contributions on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON- INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. 36 | 37 | 7. Should You wish to submit work that is not Your original creation, You may submit it to Beacon Platform Inc separately from any Contribution, identifying the complete details of its source and of any license or other restriction (including, but not limited to, related patents, trademarks, and license agreements) of which you are personally aware, and conspicuously marking the work as "Submitted on behalf of a third-party: [named here]". 38 | 39 | 8. You agree to notify Beacon Platform Inc of any facts or circumstances of which you become aware that would make these representations inaccurate in any respect. 40 | 41 | ### Contributing code 42 | 43 | If you have improvements, send us your pull requests! For those 44 | just getting started, Github has a 45 | [how to](https://help.github.com/articles/using-pull-requests/). 46 | 47 | Beacon Platform Inc team members will be assigned to review your pull requests. Once the 48 | pull requests are approved and pass continuous integration checks, a Beacon Platform Inc 49 | team member will apply `ready to pull` label to your change. This means we are 50 | working on getting your pull request submitted to the repository. 51 | 52 | If you want to contribute, start working through the codebase, 53 | navigate to the Github [issues](https://github.com/Beacon-Platform/trellis/issues) tab and start 54 | looking through interesting issues. If you are not sure of where to start, then 55 | start by trying one of the smaller/easier issues here i.e. 56 | issues with the "good first issue" label 57 | and then take a look at the 58 | issues with the "contributions welcome" label. 59 | These are issues that we believe are particularly well suited for outside 60 | contributions, often because we probably won't get to them right now. If you 61 | decide to start on an issue, leave a comment so that other people know that 62 | you're working on it. If you want to help out, but not alone, use the issue 63 | comment thread to coordinate. If somebody is already assigned to an issue you 64 | would like to tackle, do not start work on it without confirming that you will 65 | not be duplicating efforts by doing so. 66 | 67 | ### Contribution guidelines and standards 68 | 69 | Before sending your pull request for review, make sure your changes are consistent 70 | with the guidelines and follow the coding style. 71 | 72 | #### General guidelines and philosophy for contribution 73 | 74 | * As Trellis is in its infancy, improvements around all aspects of 75 | the APIs and structure as well as the addition of models, tools, tests, 76 | examples, and documentation are greatly appreciated. 77 | * Include unit tests when you contribute new features, as they help to a) 78 | prove that your code works correctly, and b) guard against future breaking 79 | changes to lower the maintenance cost. 80 | * Bug fixes also generally require unit tests, because the presence of bugs 81 | usually indicates insufficient test coverage. 82 | * Keep API compatibility in mind when you change code. Reviewers of your pull 83 | request will comment on any API compatibility issues. 84 | * When you contribute a new feature, the maintenance burden is (by default) 85 | transferred to the Beacon Platform Inc team. This means that the benefit 86 | of the contribution must be compared against the cost of maintaining the 87 | feature. 88 | 89 | #### License 90 | 91 | Include a license at the top of new files. 92 | 93 | **Python license** 94 | 95 | # Copyright (C) 2020 Beacon Platform Inc. - All Rights Reserved. 96 | # License: MIT 97 | 98 | #### Python coding style 99 | 100 | Changes to the Python code should conform to the 101 | [Google Python Style Guide](https://github.com/google/styleguide/blob/gh-pages/pyguide.md). 102 | 103 | Use `pylint` to check your Python changes. To install `pylint` and check a file 104 | against the style definition: 105 | 106 | ```bash 107 | pip install pylint 108 | pylint myfile.py 109 | ``` 110 | 111 | Note `pylint` should run from the top level project directory. 112 | 113 | #### Commit conventions 114 | 115 | Commits to the repository must follow the Conventional Commits v1.0.0 standard. 116 | 117 | - All commits must use an appropriate type and description in the imperative tense. 118 | - Squash commits together that contain unfinished work or contribute to the same change. 119 | 120 | ## Attribution 121 | 122 | These Contributing Guidelines are adapted from the [TensorFlow Contributing Guidelines](https://github.com/tensorflow/tensorflow/blob/master/CONTRIBUTING.md) and associated CLAs. 123 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (C) 2020 Beacon Platform Inc. 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 | [![PyPI version](https://badge.fury.io/py/beacon-trellis.svg)](https://pypi.org/project/beacon-trellis/) 2 | [![Python versions](https://img.shields.io/pypi/pyversions/beacon-trellis.svg)](https://pypi.org/project/beacon-trellis/) 3 | 4 | # Trellis: Deep Hedging and Deep Pricing 5 | 6 | Trellis is a deep hedging and deep pricing framework with the primary purpose of furthering research into the use of neural networks as a replacement for classical analytical methods for pricing and hedging financial instruments. 7 | 8 | The project is built in Python on top of TensorFlow and Keras. 9 | 10 | Trellis was originally developed by engineers at [Beacon Platform](https://beacon.io) to conduct research into the deep hedging technique and foster collaboration within the finance industry and with academia. 11 | 12 | If you are using this in your own project or research, we would be interested to hear from you. 13 | 14 | ## Installation 15 | 16 | Trellis is available on PyPi, simply install with `pip`. 17 | 18 | pip install beacon-trellis 19 | 20 | Note that only TensorFlow version 2.1.0 is currently supported. 21 | 22 | To use, simply 23 | 24 | import trellis 25 | 26 | See `dh_european_option.py` and `dh_variable_annuity.py` for examples of how to use the models and visualisations provided. 27 | 28 | ## Coming Soon 29 | 30 | - Example Jupyter Notebooks 31 | - TensorFlow 2.2.0 support 32 | - ReadTheDocs documentation 33 | 34 | ## Contribution guidelines 35 | 36 | If you want to contribute to the project, be sure to review the [contribution guidelines](CONTRIBUTING.md). 37 | 38 | ## Contact Information 39 | 40 | - For technical questions or bugs please [log an issue](https://github.com/Beacon-Platform/trellis/issues). 41 | - For business enquiries, please contact [Beacon Platform](https://www.beacon.io/contact) directly. 42 | - [Beacon Platform Twitter](https://twitter.com/PlatformBeacon). 43 | - [Beacon Platform LinkedIn](https://www.linkedin.com/company/beacon-platform-inc/). 44 | - Maintained by [Benjamin Pryke](https://github.com/benpryke). 45 | 46 | ## License 47 | 48 | [MIT](LICENSE) 49 | -------------------------------------------------------------------------------- /dh_european_option.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2020 Beacon Platform Inc. - All Rights Reserved. 2 | # License: MIT 3 | # Authors: Benjamin Pryke, Mark Higgins 4 | 5 | """Deep hedging example entry-point for pricing a vanilla option under BS.""" 6 | 7 | from trellis.utils import disable_gpu 8 | 9 | disable_gpu() # Call first 10 | 11 | import logging 12 | 13 | import numpy as np 14 | import tensorflow as tf 15 | 16 | import trellis.models.european_option.analytics as analytics 17 | from trellis.models import EuropeanOption 18 | from trellis.models.utils import set_seed, estimate_expected_shortfalls 19 | from trellis.plotting import ResultTypes, plot_heatmap, plot_deltas, plot_loss, plot_pnls 20 | from trellis.utils import get_progressive_min 21 | 22 | log = logging.getLogger(__name__) 23 | log.setLevel(logging.INFO) 24 | 25 | 26 | def get_callbacks(model): 27 | return [ 28 | tf.keras.callbacks.EarlyStopping(monitor='val_loss', patience=50, restore_best_weights=True), 29 | tf.keras.callbacks.ModelCheckpoint(model.checkpoint_prefix, monitor='val_loss', save_best_only=True), 30 | ] 31 | 32 | 33 | def run_once(do_train=True, show_loss_plot=True, show_delta_plot=True, show_pnl_plot=True, **hparams): 34 | """Trains and tests a model, and displays some plots. 35 | 36 | Parameters 37 | ---------- 38 | do_train : bool 39 | Actually train the model 40 | show_loss_plot : bool 41 | Pop plot of training loss 42 | show_delta_plot : bool 43 | Pop up plot of delta vs spot 44 | show_pnl_plot : bool 45 | Run MC sim to compute PnL 46 | """ 47 | 48 | model = EuropeanOption(**hparams) 49 | 50 | if do_train: 51 | history = model.train(callbacks=get_callbacks(model)) 52 | 53 | if show_loss_plot: 54 | plot_loss(get_progressive_min(history.history['val_loss'])) 55 | 56 | model.restore() 57 | 58 | if show_delta_plot: 59 | 60 | def compute_nn_delta(model, t, spot): 61 | nn_input = np.transpose(np.array([spot, [t] * len(spot)], dtype=np.float32)) 62 | return model.compute_hedge_delta(nn_input)[:, 0].numpy() 63 | 64 | def compute_bs_delta(model, t, spot): 65 | # The hedge will have the opposite sign as the option we are hedging, 66 | # ie the hedge of a long call is a short call, so we flip psi. 67 | return -model.psi * analytics.calc_opt_delta(model.is_call, spot, model.K, model.texp - t, model.vol, 0, 0) 68 | 69 | plot_deltas(model, compute_nn_delta, compute_bs_delta) 70 | 71 | if show_pnl_plot: 72 | log.info('Testing on %d paths', model.n_test_paths) 73 | pnls = model.simulate(n_paths=model.n_test_paths) 74 | estimate_expected_shortfalls(*pnls, pctile=model.pctile) 75 | plot_pnls(pnls, types=(ResultTypes.UNHEDGED, ResultTypes.BLACK_SCHOLES, ResultTypes.DEEP_HEDGING)) 76 | 77 | 78 | if __name__ == '__main__': 79 | set_seed(2) 80 | run_once(n_epochs=100, learning_rate=5e-3, mu=0.1, vol=0.2) 81 | 82 | # plot_heatmap( 83 | # model=EuropeanOption, 84 | # title='Deep Hedging error vs Black-Scholes', 85 | # xparam='b_std', 86 | # xlabel='Initial bias std', 87 | # xvals=[0., 0.05, 0.1], 88 | # yparam='learning_rate', 89 | # ylabel='Learning rate', 90 | # yvals=[1e-3, 5e-3, 1e-4], 91 | # get_callbacks=get_callbacks, 92 | # ) 93 | -------------------------------------------------------------------------------- /dh_heatrate_option.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2020 Beacon Platform Inc. - All Rights Reserved. 2 | # License: MIT 3 | # Authors: Benjamin Pryke, Mark Higgins, Amine Benchrifa 4 | 5 | """Deep hedging example entry-point for pricing a heat rate option under BS.""" 6 | 7 | import logging 8 | from pathlib import Path 9 | 10 | import numpy as np 11 | import tensorflow as tf 12 | import matplotlib.pyplot as plt 13 | 14 | import trellis.models.heatrate_option.analytics as analytics 15 | from trellis.models.heatrate_option.model import HeatrateOption 16 | from trellis.models.utils import set_seed, estimate_expected_shortfalls 17 | from trellis.plotting import ResultTypes, plot_heatmap, plot_deltas_heatrate, plot_loss, plot_pnls 18 | from trellis.utils import get_progressive_min 19 | 20 | log = logging.getLogger(__name__) 21 | log.setLevel(logging.INFO) 22 | 23 | 24 | def get_callbacks(model): 25 | return [ 26 | tf.keras.callbacks.EarlyStopping(monitor='val_loss', patience=50, restore_best_weights=True), 27 | tf.keras.callbacks.ModelCheckpoint(model.checkpoint_prefix, monitor='val_loss', save_best_only=True), 28 | ] 29 | 30 | 31 | def run_once(plot_path='./plots/', do_train=True, show_loss_plot=True, show_delta_plot=True, show_pnl_plot=True, **hparams): 32 | """Trains and tests a model, and displays some plots. 33 | 34 | Parameters 35 | ---------- 36 | plot_path : str 37 | path to write the plots 38 | do_train : bool 39 | Actually train the model 40 | show_loss_plot : bool 41 | Pop plot of training loss 42 | show_delta_plot : bool 43 | Pop up plot of delta vs spot 44 | show_pnl_plot : bool 45 | Run MC sim to compute PnL 46 | """ 47 | 48 | model = HeatrateOption(**hparams) 49 | 50 | if do_train: 51 | history = model.train(callbacks=get_callbacks(model)) 52 | 53 | if show_loss_plot: 54 | plot_loss(get_progressive_min(history.history['val_loss'])) 55 | 56 | model.restore() 57 | 58 | if show_delta_plot: 59 | 60 | def compute_nn_delta(model, t, spot_power, spot_gas, delta_type): 61 | nn_input = np.transpose(np.array([spot_power, spot_gas, [t] * len(spot_power)], dtype=np.float32)) 62 | 63 | output_index = 0 if delta_type == 'power' else 1 64 | 65 | return model.compute_hedge_delta(nn_input)[:, output_index].numpy() 66 | 67 | def compute_bs_delta(model, t, spot_power, spot_gas, delta_type): 68 | # The hedge will have the opposite sign as the option we are hedging, 69 | # ie the hedge of a long call is a short call, so we flip psi. 70 | deltas = analytics.calc_opt_delta( 71 | model.is_call, spot_power, spot_gas, model.K, model.H, model.texp - t, model.vol_P, model.vol_G, model.mu_P, model.rho 72 | ) 73 | 74 | if delta_type == 'power': 75 | return -model.psi * deltas[0] 76 | else: 77 | return -model.psi * deltas[1] 78 | 79 | Path(plot_path).mkdir(parents=True, exist_ok=True) 80 | plot_path += model.model_id + '-' if model.model_id else '' 81 | plot_path += 'delta-plot-' 82 | 83 | plot_deltas_heatrate(plot_path + 'power-power', model, compute_nn_delta, compute_bs_delta, 'power', 'power') 84 | plot_deltas_heatrate(plot_path + 'power-gas', model, compute_nn_delta, compute_bs_delta, 'power', 'gas') 85 | plot_deltas_heatrate(plot_path + 'gas-power', model, compute_nn_delta, compute_bs_delta, 'gas', 'power') 86 | plot_deltas_heatrate(plot_path + 'gas-gas', model, compute_nn_delta, compute_bs_delta, 'gas', 'gas') 87 | 88 | if show_pnl_plot: 89 | log.info('Testing on %d paths', model.n_test_paths) 90 | pnls = model.simulate(n_paths=model.n_test_paths) 91 | estimate_expected_shortfalls(*pnls, pctile=model.pctile) 92 | plot_pnls(pnls, types=(ResultTypes.UNHEDGED, ResultTypes.BLACK_SCHOLES, ResultTypes.DEEP_HEDGING)) 93 | 94 | 95 | if __name__ == '__main__': 96 | set_seed(2) 97 | run_once() 98 | -------------------------------------------------------------------------------- /dh_variable_annuity.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2020 Beacon Platform Inc. - All Rights Reserved. 2 | # License: MIT 3 | # Authors: Benjamin Pryke, Mark Higgins 4 | 5 | """Deep hedging example entry-point for pricing a variable annuity under BS.""" 6 | 7 | from trellis.utils import disable_gpu 8 | 9 | disable_gpu() # Call first 10 | 11 | import logging 12 | import time 13 | 14 | from bayes_opt import BayesianOptimization 15 | import matplotlib.pyplot as plt 16 | import numpy as np 17 | import scipy 18 | import seaborn as sns 19 | import tensorflow as tf 20 | 21 | from trellis.models.utils import set_seed, estimate_expected_shortfalls 22 | import trellis.models.variable_annuity.analytics as analytics 23 | from trellis.models import VariableAnnuity 24 | from trellis.plotting import ResultTypes, plot_heatmap, plot_deltas, plot_loss, plot_pnls 25 | from trellis.utils import get_progressive_min 26 | 27 | log = logging.getLogger(__name__) 28 | log.setLevel(logging.INFO) 29 | 30 | 31 | def get_callbacks(model): 32 | return [ 33 | # tf.keras.callbacks.EarlyStopping(monitor='val_loss', patience=50, restore_best_weights=True), 34 | tf.keras.callbacks.ModelCheckpoint(model.checkpoint_prefix, monitor='val_loss', save_best_only=True), 35 | ] 36 | 37 | 38 | def search_vol_vs_mu(): 39 | vols = [0.05, 0.1, 0.15, 0.2, 0.25] 40 | mus = [0.0, 0.05, 0.1, 0.15] 41 | plot_heatmap( 42 | model=VariableAnnuity, 43 | title='Deep Hedging error vs Black-Scholes', 44 | xparam='vol', 45 | xlabel='Vol', 46 | xvals=vols, 47 | yparam='mu', 48 | ylabel='Expected annual drift', 49 | yvals=mus, 50 | ) 51 | 52 | 53 | def get_bayes_opt_loss_fn(): 54 | n_models = 0 55 | 56 | def evaluate_network(**kwargs): 57 | nonlocal n_models 58 | int_params = ('n_layers', 'n_hidden', 'batch_size', 'epoch_size', 'n_epochs', 'n_val_paths') 59 | 60 | for key in kwargs: 61 | if key in int_params: 62 | kwargs[key] = int(kwargs[key]) 63 | 64 | model_id = 'bayesian_optimisation{}'.format(n_models) 65 | model = VariableAnnuity(model_id=model_id, **kwargs) 66 | model.train(callbacks=get_callbacks(model), verbose=0) 67 | 68 | # Note that we do not restore the model before testing in order that our test 69 | # acts as a sample from the distribution of model states at termination, which 70 | # is the distribution we wish to stabilise through hyperparameter optimisation. 71 | loss = model.test(n_paths=model.n_test_paths) 72 | 73 | log.info('%s loss: % .5f', model_id, loss) 74 | log.info(kwargs) 75 | 76 | n_models += 1 77 | 78 | return -loss 79 | 80 | return evaluate_network 81 | 82 | 83 | def run_bayesian_opt(): 84 | pbounds = { 85 | 'n_layers': (1, 4), 86 | 'n_hidden': (25, 75), # Number of nodes per hidden layer 87 | 'w_std': (0.01, 0.2), # Initialisation std of the weights 88 | 'b_std': (0.01, 0.2), # Initialisation std of the biases 89 | 'learning_rate': (0.01, 0.0001), 90 | 'batch_size': (50, 250), # Number of MC paths per batch 91 | 'epoch_size': (50, 250), # Number of batches per epoch 92 | 'n_epochs': (50, 150), # Number of epochs to train for #100 default 93 | 'n_val_paths': (1_000, 150_000), # Number of paths to validate against 94 | } 95 | 96 | optimizer = BayesianOptimization(f=get_bayes_opt_loss_fn(), pbounds=pbounds, verbose=2, random_state=1,) 97 | 98 | optimizer.maximize(init_points=20, n_iter=20) 99 | log.info(optimizer.max) 100 | 101 | 102 | def run_once(do_train=True, show_loss_plot=True, show_delta_plot=True, show_pnl_plot=True, **hparams): 103 | """Trains and tests a model, and displays some plots. 104 | 105 | Parameters 106 | ---------- 107 | do_train : bool 108 | Actually train the model 109 | show_loss_plot : bool 110 | Pop plot of training loss 111 | show_delta_plot : bool 112 | Pop up plot of delta vs spot 113 | show_pnl_plot : bool 114 | Run MC sim to compute PnL 115 | """ 116 | 117 | model = VariableAnnuity(**hparams) 118 | 119 | if do_train: 120 | history = model.train(callbacks=get_callbacks(model)) 121 | 122 | if show_loss_plot: 123 | plot_loss(get_progressive_min(history.history['val_loss'])) 124 | 125 | model.restore() 126 | 127 | if show_delta_plot: 128 | 129 | def compute_nn_delta(model, t, spot): 130 | nn_input = np.transpose(np.array([spot, [t] * len(spot)], dtype=np.float32)) 131 | delta = model.compute_hedge_delta(nn_input)[:, 0].numpy() 132 | delta = np.minimum(delta, 0) # pylint: disable=assignment-from-no-return 133 | delta *= (1 - np.exp(-model.lam * (model.texp - t))) * model.principal 134 | return delta 135 | 136 | def compute_bs_delta(model, t, spot): 137 | account = model.principal * spot / model.S0 * np.exp(-model.fee * t) 138 | return analytics.calc_delta(model.texp, t, model.lam, model.vol, model.fee, model.gmdb, account, spot) 139 | 140 | plot_deltas(model, compute_nn_delta, compute_bs_delta) 141 | 142 | if show_pnl_plot: 143 | log.info('Testing on %d paths', model.n_test_paths) 144 | pnls = model.simulate(n_paths=model.n_test_paths) 145 | estimate_expected_shortfalls(*pnls, pctile=model.pctile) 146 | plot_pnls(pnls, types=(ResultTypes.UNHEDGED, ResultTypes.BLACK_SCHOLES, ResultTypes.DEEP_HEDGING)) 147 | 148 | 149 | if __name__ == '__main__': 150 | set_seed(2) 151 | run_once(n_epochs=20, learning_rate=5e-3, mu=0.0, vol=0.2) 152 | # run_bayesian_opt() 153 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2020 Beacon Platform Inc. - All Rights Reserved. 2 | # License: MIT 3 | # Authors: Benjamin Pryke, Mark Higgins 4 | 5 | """Package setup""" 6 | 7 | import os 8 | from shutil import rmtree 9 | import sys 10 | 11 | from setuptools import find_packages, setup, Command 12 | 13 | from trellis.__version__ import __version__ 14 | 15 | 16 | with open('README.md', 'r') as fh: 17 | long_description = fh.read() 18 | 19 | 20 | class UploadCommand(Command): 21 | """Support setup.py upload.""" 22 | 23 | description = 'Build and publish the package.' 24 | user_options = [] 25 | 26 | @staticmethod 27 | def status(s): 28 | """Prints things in bold.""" 29 | print('\033[1m{0}\033[0m'.format(s)) 30 | 31 | def initialize_options(self): 32 | pass 33 | 34 | def finalize_options(self): 35 | pass 36 | 37 | def run(self): 38 | try: 39 | self.status('Removing previous builds…') 40 | here = os.path.abspath(os.path.dirname(__file__)) 41 | rmtree(os.path.join(here, 'dist')) 42 | except OSError: 43 | pass 44 | 45 | self.status('Building Source and Wheel (universal) distribution…') 46 | os.system('"{0}" setup.py sdist bdist_wheel'.format(sys.executable)) 47 | 48 | self.status('Uploading the package to PyPI via Twine…') 49 | os.system('"{0}" -m twine upload dist/*'.format(sys.executable)) 50 | 51 | self.status('Pushing git tags…') 52 | os.system('git tag v{0}'.format(__version__)) 53 | os.system('git push --tags') 54 | 55 | sys.exit() 56 | 57 | 58 | setup( 59 | name='beacon-trellis', 60 | version=__version__, 61 | author='Benjamin Pryke', 62 | author_email='ben.pryke@beacon.io', 63 | maintainer='Beacon Platform', 64 | description='Trellis is a deep hedging and deep pricing framework for quantitative finance', 65 | long_description=long_description, 66 | long_description_content_type='text/markdown', 67 | url='https://github.com/Beacon-Platform/trellis', 68 | project_urls={'Maintainer Homepage': 'https://www.beacon.io'}, 69 | packages=find_packages(exclude=['tests', '*.tests', '*.tests.*', 'tests.*']), 70 | install_requires=[ 71 | 'matplotlib>=3.0.0', 72 | 'numpy>=1.16.0', 73 | 'scipy>=1.4.1', 74 | 'seaborn>=0.9.0', 75 | 'tensorflow>=2.4.1', 76 | ], 77 | license='MIT', 78 | classifiers=[ 79 | 'Development Status :: 2 - Pre-Alpha', 80 | 'Intended Audience :: Developers', 81 | 'Intended Audience :: Financial and Insurance Industry', 82 | 'Intended Audience :: Science/Research', 83 | 'License :: OSI Approved :: MIT License', 84 | 'Natural Language :: English', 85 | 'Operating System :: OS Independent', 86 | 'Programming Language :: Python :: 3 :: Only', 87 | 'Programming Language :: Python :: 3.5', 88 | 'Programming Language :: Python :: 3.6', 89 | 'Programming Language :: Python :: 3.7', 90 | 'Programming Language :: Python :: 3.8', 91 | 'Programming Language :: Python :: 3', 92 | 'Topic :: Scientific/Engineering :: Artificial Intelligence', 93 | 'Topic :: Scientific/Engineering :: Mathematics', 94 | 'Topic :: Scientific/Engineering', 95 | 'Topic :: Software Development :: Libraries :: Python Modules', 96 | 'Topic :: Software Development :: Libraries', 97 | 'Topic :: Software Development', 98 | ], 99 | python_requires='>=3.5', 100 | cmdclass={'upload': UploadCommand}, 101 | ) 102 | -------------------------------------------------------------------------------- /trellis/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2020 Beacon Platform Inc. - All Rights Reserved. 2 | # License: MIT 3 | # Authors: Benjamin Pryke, Mark Higgins 4 | 5 | """Trellis exports""" 6 | 7 | from trellis import __version__ 8 | from trellis import models 9 | from trellis import plotting 10 | from trellis import random_processes as random 11 | from trellis import utils 12 | -------------------------------------------------------------------------------- /trellis/__version__.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2020 Beacon Platform Inc. - All Rights Reserved. 2 | # License: MIT 3 | # Authors: Benjamin Pryke, Mark Higgins 4 | 5 | """Trellis version""" 6 | 7 | __version__ = '0.2.0' 8 | -------------------------------------------------------------------------------- /trellis/models/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2020 Beacon Platform Inc. - All Rights Reserved. 2 | # License: MIT 3 | # Authors: Benjamin Pryke, Mark Higgins 4 | 5 | """Deep hedging models.""" 6 | 7 | from trellis.models.european_option.model import EuropeanOption 8 | from trellis.models.variable_annuity.model import VariableAnnuity 9 | -------------------------------------------------------------------------------- /trellis/models/base.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2020 Beacon Platform Inc. - All Rights Reserved. 2 | # License: MIT 3 | # Authors: Benjamin Pryke, Mark Higgins 4 | 5 | """Base classes for models.""" 6 | 7 | from hashlib import md5 8 | import logging 9 | import os 10 | import time 11 | 12 | from tensorflow.keras.callbacks import CallbackList 13 | import tensorflow as tf 14 | 15 | from trellis.utils import calc_expected_shortfall, get_duration_desc 16 | 17 | log = logging.getLogger(__name__) 18 | log.setLevel(logging.INFO) 19 | 20 | ROOT_CHECKPOINT_DIR = './checkpoints/' 21 | 22 | 23 | class HyperparamsBase: 24 | root_checkpoint_dir = ROOT_CHECKPOINT_DIR 25 | model_id = None # Unique id that, if defined, forms part of the checkpoint directory name 26 | 27 | learning_rate = 5e-3 28 | batch_size = 100 # Number of MC paths per batch 29 | epoch_size = 100 # Number of batches per epoch 30 | n_epochs = 100 # Number of epochs to train for 31 | n_val_paths = 50_000 # Number of paths to validate against 32 | n_test_paths = 100_000 # Number of paths to test against 33 | 34 | def __init__(self, **kwargs): 35 | # Set hyperparameters 36 | for k, v in kwargs.items(): 37 | if hasattr(self, k): 38 | setattr(self, k, v) 39 | else: 40 | raise ValueError('Invalid hyperparameter "%s"', k) 41 | 42 | @property 43 | def critical_fields(self): 44 | """Tuple of parameters that uniquely define the model.""" 45 | raise NotImplementedError() 46 | 47 | @property 48 | def checkpoint_directory(self): 49 | """Directory in which to save checkpoint files.""" 50 | base = '{}{}_'.format(self.root_checkpoint_dir, self.__class__.__name__) 51 | 52 | if self.model_id is not None: 53 | return base + self.model_id 54 | 55 | return base + md5(str(hash(self.critical_fields)).encode('utf-8')).hexdigest() 56 | 57 | @property 58 | def checkpoint_prefix(self): 59 | """Filepath prefix to be used when saving/loading checkpoints. 60 | 61 | Includes everything except for the file extension. 62 | """ 63 | return os.path.join(self.checkpoint_directory, 'checkpoint') 64 | 65 | 66 | class Model(tf.keras.Sequential): 67 | def __init__(self): 68 | tf.keras.Sequential.__init__(self) 69 | 70 | def train(self, optimizer=None, callbacks=None, *, verbose=1): 71 | """Train the network by running MC simulations, validating every `self.epoch_size` epochs. 72 | 73 | Note: these epochs are not true epochs in the sense of full passes over the dataset, as 74 | our dataset is infinite and we continuously generate new data. However, we need to 75 | evaluate our progress as training continues, hence the introduction of `epoch_size`. 76 | """ 77 | 78 | n_paths = self.batch_size * self.epoch_size * self.n_epochs 79 | 80 | if verbose != 0: 81 | log.info('Training on %d paths over %d epochs', n_paths, self.n_epochs) 82 | 83 | if not isinstance(callbacks, CallbackList): 84 | callbacks = list(callbacks or []) 85 | callbacks = CallbackList( 86 | callbacks, 87 | add_history=True, 88 | add_progbar=verbose != 0, 89 | model=self, 90 | do_validation=True, 91 | verbose=verbose, 92 | epochs=self.n_epochs, 93 | steps=self.epoch_size, 94 | ) 95 | 96 | # Use the Adam optimizer y default, which is gradient descent which also evolves 97 | # the learning rate appropriately (the learning rate passed in is the initial 98 | # learning rate) 99 | if optimizer is not None: 100 | self.optimizer = optimizer 101 | elif getattr(self, 'optimizer', None) is None: 102 | self.optimizer = tf.keras.optimizers.Adam(self.learning_rate) 103 | 104 | # Remember the start time - we'll log out the total time for training later 105 | callbacks.on_train_begin() 106 | t0 = time.time() 107 | 108 | # Loop through `batch_size` sized subsets of the total paths and train on each 109 | for epoch in range(self.n_epochs): 110 | callbacks.on_epoch_begin(epoch) 111 | 112 | for step in range(self.epoch_size): 113 | callbacks.on_train_batch_begin(step) 114 | 115 | # Get a random initial spot so that the training sees a proper range even at t=0. 116 | # We use the same initial spot price across the batch so that all MC paths are sampled 117 | # from the same distribution, which is a requirement for our expected shortfall calculation. 118 | init_spot = self.generate_random_init_spot() 119 | compute_loss = lambda: self.compute_loss(init_spot) 120 | 121 | # Now we've got the inputs set up for the training - run the training step 122 | # TODO should we use a GradientTape and then call compute_loss and apply_gradients? 123 | self.optimizer.minimize(compute_loss, self.trainable_variables) 124 | 125 | logs = {'batch': step, 'size': self.batch_size, 'loss': compute_loss().numpy()} 126 | callbacks.on_train_batch_end(step, logs) 127 | 128 | logs.update(val_loss=self.test(n_paths=self.n_val_paths)) 129 | callbacks.on_epoch_end(epoch, logs) 130 | 131 | if self.stop_training: 132 | break 133 | 134 | if verbose != 0: 135 | duration = get_duration_desc(t0) 136 | log.info('Total training time: %s', duration) 137 | 138 | callbacks.on_train_end() 139 | return self.history 140 | 141 | def test(self, n_paths, *, verbose=0): 142 | """Test model performance by computing the Expected Shortfall of the PNLs from a 143 | Monte Carlo simulation of the trading strategy represented by the model. 144 | 145 | Parameters 146 | ---------- 147 | n_paths : int 148 | Number of paths to test against. 149 | verbose : int 150 | Verbosity, use 0 to turn off all logging. 151 | 152 | Returns 153 | ------- 154 | float 155 | Expected Shortfall of the PNLs of the test MC simulation. 156 | """ 157 | 158 | _, _, nn_pnls = self.simulate(n_paths, verbose=verbose) 159 | nn_es = calc_expected_shortfall(nn_pnls, self.pctile) 160 | 161 | return nn_es 162 | 163 | def simulate(self, n_paths, *, verbose=1, write_to_tensorboard=False): 164 | """Simulate the trading strategy and return the PNLs. 165 | 166 | Parameters 167 | ---------- 168 | n_paths : int 169 | Number of paths to test against. 170 | verbose : int 171 | Verbosity, use 0 to turn off all logging. 172 | write_to_tensorboard : bool 173 | Whether to write to tensorboard or not. 174 | 175 | Returns 176 | ------- 177 | tuple of :obj:`numpy.array` 178 | (unhedged pnl, Black-Scholes hedged pnl, neural network hedged pnl) 179 | """ 180 | raise NotImplementedError() 181 | 182 | def restore(self): 183 | """Restore model weights from most recent checkpoint.""" 184 | try: 185 | self.load_weights(self.checkpoint_prefix) 186 | except ValueError: 187 | # No checkpoint to restore from 188 | pass 189 | -------------------------------------------------------------------------------- /trellis/models/european_option/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2020 Beacon Platform Inc. - All Rights Reserved. 2 | # License: MIT 3 | # Authors: Benjamin Pryke, Mark Higgins 4 | 5 | """European option deep hedging and Black-Scholes models.""" 6 | 7 | from trellis.models.european_option.analytics import calc_opt_delta, calc_opt_price 8 | from trellis.models.european_option.model import EuropeanOption, Hyperparams 9 | -------------------------------------------------------------------------------- /trellis/models/european_option/analytics.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2020 Beacon Platform Inc. - All Rights Reserved. 2 | # License: MIT 3 | # Authors: Benjamin Pryke, Mark Higgins 4 | 5 | """Black-Scholes analytical solutions for european options. 6 | 7 | See https://en.wikipedia.org/wiki/Black–Scholes_model 8 | """ 9 | 10 | import numpy as np 11 | from scipy.stats import norm 12 | 13 | 14 | def calc_opt_price(is_call, spot, strike, texp, vol, rd, rf): 15 | """Calculates option price 16 | 17 | Parameters 18 | ---------- 19 | is_call : bool 20 | True if the option is a call option, else False if a put option 21 | spot : float or :obj:`numpy.array` 22 | Current spot price(s) (S_t) 23 | strike : float 24 | Strike price (K) 25 | texp : float 26 | Time to maturity (in years) (T - t) 27 | vol : float 28 | Volatility of returns of the underlying asset (σ) 29 | rd : float 30 | Risk free rate (r) 31 | rf : float 32 | Dividend yield (q) 33 | 34 | Returns 35 | ------- 36 | float 37 | Price 38 | """ 39 | 40 | if vol <= 0 or texp <= 0 or strike <= 0: 41 | # Return intrinsic value 42 | int_val = spot * np.exp(-rf * texp) - strike * np.exp(-rd * texp) 43 | sign = 1 if is_call else -1 44 | return sign * np.maximum(int_val, 0) 45 | 46 | # Otherwise calculate the standard value 47 | d1 = calc_d1(spot, strike, texp, vol, rd, rf) 48 | d2 = d1 - vol * np.sqrt(texp) 49 | 50 | if is_call: 51 | return spot * np.exp(-rf * texp) * norm.cdf(d1) - strike * np.exp(-rd * texp) * norm.cdf(d2) 52 | else: 53 | return strike * np.exp(-rd * texp) * norm.cdf(-d2) - spot * np.exp(-rf * texp) * norm.cdf(-d1) 54 | 55 | 56 | def calc_opt_delta(is_call, spot, strike, texp, vol, rd, rf): 57 | """Calculates option delta 58 | 59 | Delta is the partial derivative of option price with respect to the spot price of the underlying 60 | asset (∂C/∂S). 61 | 62 | Parameters 63 | ---------- 64 | is_call : bool 65 | True if the option is a call option, else False if a put option 66 | spot : float or :obj:`numpy.array` 67 | Current spot price(s) (S_t) 68 | strike : float 69 | Strike price (K) 70 | texp : float 71 | Time to maturity (in years) (T - t) 72 | vol : float 73 | Volatility of returns of the underlying asset (σ) 74 | rd : float 75 | Risk free rate (r) 76 | rf : float 77 | Dividend yield (q) 78 | 79 | Returns 80 | ------- 81 | float 82 | Delta 83 | """ 84 | 85 | if vol <= 0 or texp <= 0: 86 | # Return intrinsic delta 87 | int_val = spot * np.exp(-rf * texp) - strike * np.exp(-rd * texp) 88 | 89 | if not is_call: 90 | int_val *= -1 91 | 92 | sign = 1 if is_call else -1 93 | return np.where(int_val < 0, 0, sign * np.exp(-rf * texp)) 94 | 95 | # Otherwise calculate the standard value 96 | if is_call: 97 | return np.exp(-rf * texp) * norm.cdf(calc_d1(spot, strike, texp, vol, rd, rf)) 98 | else: 99 | return -np.exp(-rf * texp) * norm.cdf(-calc_d1(spot, strike, texp, vol, rd, rf)) 100 | 101 | 102 | def calc_d1(spot, strike, texp, vol, rd, rf): 103 | """Calculates the d_1 value in the Black-Scholes formula with continuous yield dividends 104 | 105 | Parameters 106 | ---------- 107 | spot : float or :obj:`numpy.array` 108 | Current spot price(s) (S_t) 109 | strike : float 110 | Strike price (K) 111 | texp : float 112 | Time to maturity (in years) (T - t) 113 | vol : float 114 | Volatility of returns of the underlying asset (σ) 115 | rd : float 116 | Risk free rate (r) 117 | rf : float 118 | Dividend yield (q) 119 | 120 | Returns 121 | ------- 122 | float 123 | The value of d_1 for the given argument values 124 | """ 125 | return (np.log(spot / strike) + (rd - rf + vol * vol / 2.0) * texp) / vol / np.sqrt(texp) 126 | -------------------------------------------------------------------------------- /trellis/models/european_option/model.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2020 Beacon Platform Inc. - All Rights Reserved. 2 | # License: MIT 3 | # Authors: Benjamin Pryke, Mark Higgins 4 | 5 | """Vanilla European Option model.""" 6 | 7 | import logging 8 | import time 9 | 10 | import numpy as np 11 | import tensorflow as tf 12 | 13 | from trellis.models.base import Model, HyperparamsBase 14 | from trellis.models.european_option import analytics 15 | from trellis.models.utils import depends_on 16 | from trellis.random_processes import gbm 17 | from trellis.utils import calc_expected_shortfall, get_duration_desc 18 | 19 | log = logging.getLogger(__name__) 20 | log.setLevel(logging.INFO) 21 | 22 | 23 | class Hyperparams(HyperparamsBase): 24 | n_layers = 2 25 | n_hidden = 50 # Number of nodes per hidden layer 26 | w_std = 0.1 # Initialisation std of the weights 27 | b_std = 0.05 # Initialisation std of the biases 28 | 29 | learning_rate = 5e-3 30 | batch_size = 100 # Number of MC paths per batch 31 | epoch_size = 100 # Number of batches per epoch 32 | n_epochs = 100 # Number of epochs to train for 33 | n_val_paths = 50_000 # Number of paths to validate against 34 | n_test_paths = 100_000 # Number of paths to test against 35 | 36 | S0 = 1.0 # initial spot price 37 | mu = 0.0 # Expected upward spot drift, in years 38 | vol = 0.2 # Volatility 39 | 40 | texp = 0.25 # Fixed tenor to expiration, in years 41 | K = 1.0 # option strike price 42 | is_call = True # True: call option; False: put option 43 | is_buy = False # True: buying a call/put; False: selling a call/put 44 | 45 | dt = 1 / 260 # Timesteps per year 46 | n_steps = int(texp / dt) # Number of time steps 47 | pctile = 70 # Percentile for expected shortfall 48 | 49 | @property 50 | @depends_on('is_call') 51 | def phi(self): 52 | """Call or put""" 53 | return 1 if self.is_call else -1 54 | 55 | @property 56 | @depends_on('is_buy') 57 | def psi(self): 58 | """Buy or sell""" 59 | return 1 if self.is_buy else -1 60 | 61 | @property 62 | def critical_fields(self): 63 | """Tuple of parameters that uniquely define the model.""" 64 | return ( 65 | self.n_layers, 66 | self.n_hidden, 67 | self.w_std, 68 | self.b_std, 69 | self.learning_rate, 70 | self.batch_size, 71 | self.S0, 72 | self.mu, 73 | self.vol, 74 | self.texp, 75 | self.K, 76 | self.is_call, 77 | self.is_buy, 78 | self.dt, 79 | self.pctile, 80 | ) 81 | 82 | 83 | class EuropeanOption(Model, Hyperparams): 84 | def __init__(self, **kwargs): 85 | """Define our NN structure; we use the same nodes in each timestep""" 86 | 87 | Hyperparams.__init__(self, **kwargs) 88 | Model.__init__(self) 89 | 90 | # Hidden layers 91 | for _ in range(self.n_layers): 92 | self.add( 93 | tf.keras.layers.Dense( 94 | units=self.n_hidden, 95 | activation='relu', 96 | kernel_initializer=tf.initializers.TruncatedNormal(stddev=self.w_std), 97 | bias_initializer=tf.initializers.TruncatedNormal(stddev=self.b_std), 98 | ) 99 | ) 100 | 101 | # Output 102 | # We have one output (notional of spot hedge) 103 | self.add( 104 | tf.keras.layers.Dense( 105 | units=1, 106 | activation='linear', 107 | kernel_initializer=tf.initializers.TruncatedNormal(stddev=self.w_std), 108 | bias_initializer=tf.initializers.TruncatedNormal(stddev=self.b_std), 109 | ) 110 | ) 111 | 112 | # Inputs 113 | # Our 2 inputs are spot price and time, which are mostly determined during the MC 114 | # simulation except for the initial spot at time 0 115 | self.build((None, 2)) 116 | 117 | @tf.function 118 | def compute_hedge_delta(self, x): 119 | """Returns the output of the neural network at any point in time. 120 | 121 | The delta size of the position required to hedge the option. 122 | """ 123 | return self.call(x) 124 | 125 | @tf.function 126 | def compute_pnl(self, init_spot): 127 | """On each run of the training, we'll run a MC simulatiion to calculate the PNL distribution 128 | across the `batch_size` paths. PNL integrated along a given path is the sum of the 129 | option payoff at the end and the realized PNL from the hedges; that is, for a given path j, 130 | 131 | PNL[j] = Sum[delta_s[i-1,j] * (S[i,j] - S[i-1,j]), {i, 1, N}] + Payoff(S[N,j]) 132 | 133 | where 134 | delta_s[i,j] = spot hedge notional at time index i for path j 135 | S[i,j] = spot at time index i for path j 136 | N = total # of time steps from start to option expiration 137 | Payoff(S) = at-expiration option payoff (ie max(S-K,0) for a call option and max(K-S,0) for a put) 138 | 139 | We can define the PNL incrementally along a path like 140 | 141 | pnl[i,j] = pnl[i-1,j] + delta_s[i-1,j] * (S[i,j] - S[i-1,j]) 142 | 143 | where pnl[0,j] == 0 for every path. Then the integrated path PNL defined above is 144 | 145 | PNL[j] = pnl[N,j] + Payoff(S[N],j) 146 | 147 | So we build a tensorflow graph to calculate that integrated PNL for a given 148 | path. Then we'll define a loss function (given a set of path PNLs) equal to the 149 | expected shortfall of the integrated PNLs for each path in a batch. Make sure 150 | we're short the option so that (absent hedging) there's a -ve PNL. 151 | """ 152 | 153 | pnl = tf.zeros(self.batch_size, dtype=tf.float32) 154 | spots = gbm(init_spot, self.mu, self.vol, self.dt, self.n_steps, self.batch_size) 155 | 156 | # Run through the MC sim, generating path values for spots along the way 157 | for time_index in tf.range(self.n_steps): 158 | # 1. Compute updates at start of interval 159 | # Retrieve the neural network output, treating it as the delta hedge notional 160 | # at the start of the timestep. In the risk neutral limit, Black-Scholes is equivallent 161 | # to the minimising expected shortfall. Therefore, by minimising expected shortfall as 162 | # our loss function, the output of the network is trained to approximate Black-Scholes delta. 163 | t = tf.cast(time_index, dtype=tf.float32) * self.dt 164 | spot = spots[time_index] 165 | input_time = tf.fill([self.batch_size], t) 166 | inputs = tf.stack([spot, input_time], 1) 167 | delta = self.compute_hedge_delta(inputs)[:, 0] 168 | 169 | # 2. Compute updates at end of interval 170 | # Delta hedge and record the incremental pnl changes over the interval 171 | spot_change = spots[time_index + 1] - spot 172 | pnl += delta * spot_change 173 | 174 | # Calculate the final payoff 175 | payoff = self.psi * tf.maximum(self.phi * (spots[-1] - self.K), 0) 176 | pnl += payoff # Note we sell the option here 177 | 178 | return pnl 179 | 180 | @tf.function 181 | def compute_loss(self, init_spot): 182 | """Use expected shortfall for the appropriate percentile as the loss function. 183 | 184 | Note that we do *not* expect this to minimize to zero. 185 | """ 186 | # TODO move to losses module as ExpectedShortfall? 187 | 188 | pnl = self.compute_pnl(init_spot) 189 | n_pct = int((100 - self.pctile) / 100 * self.batch_size) 190 | pnl_past_cutoff = tf.nn.top_k(-pnl, n_pct)[0] 191 | return tf.reduce_mean(pnl_past_cutoff) / init_spot 192 | 193 | @tf.function 194 | def compute_mean_pnl(self, init_spot): 195 | """Mean PNL for debugging purposes""" 196 | pnl = self.compute_pnl(init_spot) 197 | return tf.reduce_mean(pnl) 198 | 199 | @tf.function 200 | def generate_random_init_spot(self): 201 | # TODO does this belong here? 202 | r = tf.random.normal((1,), 0, 2.0 * self.vol * self.texp ** 0.5)[0] 203 | return self.S0 * tf.exp(-self.vol * self.vol * self.texp / 2.0 + r) 204 | 205 | def simulate(self, n_paths, *, verbose=1, write_to_tensorboard=False): 206 | """Simulate the trading strategy and return the PNLs. 207 | 208 | Parameters 209 | ---------- 210 | n_paths : int 211 | Number of paths to test against. 212 | verbose : int 213 | Verbosity, use 0 to turn off all logging. 214 | write_to_tensorboard : bool 215 | Whether to write to tensorboard or not. 216 | 217 | Returns 218 | ------- 219 | tuple of :obj:`numpy.array` 220 | (unhedged pnl, Black-Scholes hedged pnl, neural network hedged pnl) 221 | """ 222 | 223 | t0 = time.time() 224 | 225 | if write_to_tensorboard: 226 | writer = tf.summary.create_file_writer('logs/') 227 | 228 | spots = gbm(self.S0, self.mu, self.vol, self.dt, self.n_steps, n_paths).numpy() 229 | uh_pnls = np.zeros(n_paths, dtype=np.float32) 230 | nn_pnls = np.zeros(n_paths, dtype=np.float32) 231 | bs_pnls = np.zeros(n_paths, dtype=np.float32) 232 | 233 | # Run through the MC sim, generating path values for spots along the way. This is just like a regular MC 234 | # sim to price a derivative - except that the price is *not* the expected value - it's the loss function 235 | # value. That handles both the conversion from real world to "risk neutral" and unhedgeable risk due to 236 | # eg discrete hedging (which is the only unhedgeable risk in this example, but there could be anything generally). 237 | for time_index in range(self.n_steps): 238 | # 1. Compute updates at start of interval 239 | t = time_index * self.dt 240 | spot = spots[time_index] 241 | 242 | input_time = tf.constant([t] * n_paths) 243 | nn_input = tf.stack([spot, input_time], 1) 244 | nn_delta = self.compute_hedge_delta(nn_input)[:, 0].numpy() 245 | 246 | bs_delta = -self.psi * analytics.calc_opt_delta(self.is_call, spot, self.K, self.texp - t, self.vol, 0, 0) 247 | 248 | # 2. Compute updates at end of interval 249 | # Record incremental pnl changes over the interval 250 | spot_change = spots[time_index + 1] - spot 251 | nn_pnls += nn_delta * spot_change 252 | bs_pnls += bs_delta * spot_change 253 | 254 | if verbose != 0: 255 | log.info( 256 | '%.4f years - delta: mean % .5f, std % .5f; spot: mean % .5f, std % .5f', 257 | t, 258 | nn_delta.mean(), 259 | nn_delta.std(), 260 | spot.mean(), 261 | spot.std(), 262 | ) 263 | 264 | if write_to_tensorboard: 265 | with writer.as_default(): 266 | tf.summary.histogram('nn_delta', nn_delta, step=time_index) 267 | tf.summary.histogram('bs_delta', bs_delta, step=time_index) 268 | tf.summary.histogram('nn_pnls', nn_pnls, step=time_index) 269 | tf.summary.histogram('bs_pnls', bs_pnls, step=time_index) 270 | tf.summary.histogram('spot', spot, step=time_index) 271 | 272 | # Compute the payoff and some metrics 273 | payoff = self.psi * np.maximum(self.phi * (spots[-1] - self.K), 0) 274 | uh_pnls += payoff 275 | nn_pnls += payoff 276 | bs_pnls += payoff 277 | 278 | if write_to_tensorboard: 279 | writer.flush() 280 | 281 | if verbose != 0: 282 | duration = get_duration_desc(t0) 283 | log.info('Simulation time: %s', duration) 284 | 285 | return uh_pnls, bs_pnls, nn_pnls 286 | -------------------------------------------------------------------------------- /trellis/models/heatrate_option/analytics.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2020 Beacon Platform Inc. - All Rights Reserved. 2 | # License: MIT 3 | # Authors: Benjamin Pryke, Mark Higgins, Amine Benchrifa 4 | 5 | """Black-Scholes analytical solutions for heat rate options. 6 | 7 | """ 8 | 9 | import numpy as np 10 | from scipy.stats import norm 11 | 12 | 13 | def calc_opt_price(is_call, spot_power, spot_gas, strike, H, texp, sigma_P, sigma_G, rd, rho): 14 | """Calculates option price of a heat rate option 15 | 16 | Parameters 17 | ---------- 18 | is_call : bool 19 | True if the option is a call option, else False if a put option 20 | spot_power : float or :obj:`numpy.array` 21 | Current power price(s) 22 | spot_gas : float or :obj:`numpy.array` 23 | Current gas price(s) 24 | strike : float 25 | Strike price (K) 26 | H: float 27 | Heat rate (H) 28 | texp : float 29 | Time to maturity (in years) (T - t) 30 | sigma_P : float 31 | Volatility of returns of the power asset (σ_P) 32 | sigma_G : float 33 | Volatility of returns of the gas asset (σ_G) 34 | rd : float 35 | Risk free rate (r) 36 | rho: float 37 | Correlation between the power and gas prices 38 | 39 | 40 | Returns 41 | ------- 42 | float 43 | Price 44 | """ 45 | 46 | if sigma_P <= 0 or sigma_G <= 0 or texp <= 0 or strike <= 0: 47 | # Return intrinsic value 48 | int_val = (spot_power - (H * spot_gas * strike)) * np.exp(-rd * texp) 49 | sign = 1 if is_call else -1 50 | return sign * np.maximum(int_val, 0) 51 | 52 | vol = np.sqrt( 53 | sigma_P ** 2 54 | + (sigma_G * (spot_gas / (H * spot_gas + strike))) ** 2 55 | - 2 * rho * sigma_P * sigma_G * (spot_gas / (H * spot_gas + strike)) 56 | ) 57 | 58 | # Otherwise calculate the standard value 59 | d1 = calc_d1(spot_power, spot_gas, strike, H, texp, sigma_P, sigma_G, rd, rho) 60 | d2 = d1 - vol * np.sqrt(texp) 61 | 62 | if is_call: 63 | return (spot_power * norm.cdf(d1) - (H * spot_gas + strike) * norm.cdf(d2)) * np.exp(-rd * texp) 64 | else: 65 | return -(spot_power * norm.cdf(d1) - (H * spot_gas + strike) * norm.cdf(d2)) * np.exp(-rd * texp) 66 | 67 | 68 | def calc_opt_delta(is_call, spot_power, spot_gas, strike, H, texp, sigma_P, sigma_G, rd, rho): 69 | """Calculates two option deltas with respect to the power and gas prices 70 | 71 | Delta is the partial derivative of option price with respect to the spot price of the underlying 72 | asset (∂C/∂S). 73 | 74 | Parameters 75 | ---------- 76 | is_call : bool 77 | True if the option is a call option, else False if a put option 78 | spot_power : float or :obj:`numpy.array` 79 | Current power price(s) 80 | spot_gas : float or :obj:`numpy.array` 81 | Current gas price(s) 82 | strike : float 83 | Strike price (K) 84 | H: float 85 | Heat rate (H) 86 | texp : float 87 | Time to maturity (in years) (T - t) 88 | sigma_P : float 89 | Volatility of returns of the power asset (σ_P) 90 | sigma_G : float 91 | Volatility of returns of the gas asset (σ_G) 92 | rd : float 93 | Risk free rate (r) 94 | rho: float 95 | Correlation between the power and gas prices 96 | 97 | 98 | Returns 99 | ------- 100 | pair 101 | (delta_power, delta_gas) 102 | """ 103 | 104 | if sigma_P <= 0 or sigma_G <= 0 or texp <= 0: 105 | # Return intrinsic delta 106 | int_val = (spot_power - (strike + H * spot_gas)) * np.exp(-rd * texp) 107 | 108 | if not is_call: 109 | int_val *= -1 110 | 111 | sign = 1 if is_call else -1 112 | return np.where(int_val < 0, 0, sign) 113 | 114 | vol = np.sqrt( 115 | sigma_P ** 2 116 | + (sigma_G * (spot_gas / (H * spot_gas + strike))) ** 2 117 | - 2 * rho * sigma_P * sigma_G * (spot_gas / (H * spot_gas + strike)) 118 | ) 119 | 120 | d1 = calc_d1(spot_power, spot_gas, strike, H, texp, sigma_P, sigma_G, rd, rho) 121 | d2 = d1 - vol * np.sqrt(texp) 122 | 123 | # Otherwise calculate the standard value 124 | if is_call: 125 | 126 | delta_power = np.exp(-rd * texp) * norm.cdf(d1) 127 | delta_gas = -np.exp(-rd * texp) * H * norm.cdf(d2) 128 | 129 | else: 130 | delta_power = -np.exp(-rd * texp) * norm.cdf(d1) 131 | delta_gas = np.exp(-rd * texp) * H * norm.cdf(d2) 132 | 133 | return delta_power, delta_gas 134 | 135 | 136 | def calc_d1(spot_power, spot_gas, strike, H, texp, sigma_P, sigma_G, rd, rho): 137 | """Calculates the d_1 value in the Black-Scholes formula with continuous yield dividends 138 | 139 | Parameters 140 | ---------- 141 | spot_power : float or :obj:`numpy.array` 142 | Current power price(s) 143 | spot_gas : float or :obj:`numpy.array` 144 | Current gas price(s) 145 | strike : float 146 | Strike price (K) 147 | H: float 148 | Heat rate (H) 149 | texp : float 150 | Time to maturity (in years) (T - t) 151 | sigma_P : float 152 | Volatility of returns of the power asset (σ_P) 153 | sigma_G : float 154 | Volatility of returns of the gas asset (σ_G) 155 | rd : float 156 | Risk free rate (r) 157 | rho: float 158 | Correlation between the power and gas prices 159 | 160 | 161 | Returns 162 | ------- 163 | float 164 | The value of d_1 for the given argument values 165 | """ 166 | 167 | vol = np.sqrt( 168 | sigma_P ** 2 169 | + (sigma_G * (spot_gas / (H * spot_gas + strike))) ** 2 170 | - 2 * rho * sigma_P * sigma_G * (spot_gas / (H * spot_gas + strike)) 171 | ) 172 | 173 | return (np.log(spot_power / (H * spot_gas + strike)) + (vol * vol / 2.0) * texp) / (vol * np.sqrt(texp)) 174 | -------------------------------------------------------------------------------- /trellis/models/heatrate_option/model.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2020 Beacon Platform Inc. - All Rights Reserved. 2 | # License: MIT 3 | # Authors: Benjamin Pryke, Mark Higgins, Amine Benchrifa 4 | 5 | """Heat Rate Option model.""" 6 | 7 | import logging 8 | import time 9 | 10 | import numpy as np 11 | import tensorflow as tf 12 | 13 | from trellis.models.base import Model, HyperparamsBase 14 | import trellis.models.heatrate_option.analytics as analytics 15 | from trellis.random_processes import gbm2 16 | from trellis.utils import calc_expected_shortfall, get_duration_desc 17 | 18 | 19 | log = logging.getLogger(__name__) 20 | log.setLevel(logging.INFO) 21 | 22 | 23 | class Hyperparams(HyperparamsBase): 24 | n_layers = 2 25 | n_hidden = 50 # Number of nodes per hidden layer 26 | w_std = 0.1 # Initialisation std of the weights 27 | b_std = 0.05 # Initialisation std of the biases 28 | 29 | learning_rate = 1e-3 30 | batch_size = 100 # Number of MC paths per batch 31 | epoch_size = 100 # Number of batches per epoch 32 | n_epochs = 100 # Number of epochs to train for 33 | n_val_paths = 50_000 # Number of paths to validate against 34 | n_test_paths = 100_000 # Number of paths to test against 35 | 36 | SP0 = 45.0 # initial power price 37 | SG0 = 12.0 # initial gas price 38 | H = 2.35 # heat rate 39 | mu_P = 0.0025 # Expected upward spot(power) drift, in years 40 | vol_P = 0.35 # Volatility (power) 41 | mu_G = 0.0025 # Expected upward spot(gas) drift, in years 42 | vol_G = 0.6 # Volatility (gas) 43 | rho = 0.8 # Correlation between power and gas prices 44 | 45 | texp = 1.0 # Fixed tenor to expiration, in years 46 | K = 15.0 # option strike price 47 | is_call = True # True: call option; False: put option 48 | is_buy = False # True: buying a call/put; False: selling a call/put 49 | phi = 1 if is_call else -1 # Call or put 50 | psi = 1 if is_buy else -1 # Buy or sell 51 | 52 | dt = 1 / 260 # Timesteps per year 53 | n_steps = int(texp / dt) # Number of time steps 54 | pctile = 70 # Percentile for expected shortfall 55 | 56 | def __setattr__(self, name, value): 57 | """Ensure the fair fee is kept up to date""" 58 | # TODO Can we use non-trainable Variables to form a dependency tree so we don't need to update these without losing functionality? 59 | self.__dict__[name] = value 60 | 61 | if name == 'is_call': 62 | self.phi = 1 if self.is_call else -1 63 | 64 | if name == 'is_buy': 65 | self.psi = 1 if self.is_buy else -1 66 | 67 | @property 68 | def critical_fields(self): 69 | """Tuple of parameters that uniquely define the model.""" 70 | return ( 71 | self.n_layers, 72 | self.n_hidden, 73 | self.w_std, 74 | self.b_std, 75 | self.learning_rate, 76 | self.batch_size, 77 | self.epoch_size, 78 | self.n_epochs, 79 | self.SP0, 80 | self.SG0, 81 | self.H, 82 | self.mu_P, 83 | self.vol_P, 84 | self.mu_G, 85 | self.vol_G, 86 | self.rho, 87 | self.texp, 88 | self.K, 89 | self.is_call, 90 | self.is_buy, 91 | self.dt, 92 | self.pctile, 93 | ) 94 | 95 | 96 | class HeatrateOption(Model, Hyperparams): 97 | def __init__(self, **kwargs): 98 | """Define our NN structure; we use the same nodes in each timestep""" 99 | 100 | Hyperparams.__init__(self, **kwargs) 101 | Model.__init__(self) 102 | 103 | # Hidden layers 104 | for _ in range(self.n_layers): 105 | self.add( 106 | tf.keras.layers.Dense( 107 | units=self.n_hidden, 108 | activation='relu', 109 | kernel_initializer=tf.initializers.TruncatedNormal(stddev=self.w_std), 110 | bias_initializer=tf.initializers.TruncatedNormal(stddev=self.b_std), 111 | ) 112 | ) 113 | 114 | # Output 115 | # We have two outputs (notional of spot hedge) 116 | self.add( 117 | tf.keras.layers.Dense( 118 | units=2, 119 | activation='linear', 120 | kernel_initializer=tf.initializers.TruncatedNormal(stddev=self.w_std), 121 | bias_initializer=tf.initializers.TruncatedNormal(stddev=self.b_std), 122 | ) 123 | ) 124 | 125 | # Inputs 126 | # Our 3 inputs are power price, gas price and time, which are mostly determined during the MC 127 | # simulation except for the initial spot at time 0 128 | self.build((None, 3)) 129 | 130 | @tf.function 131 | def compute_hedge_delta(self, x): 132 | """Returns the output of the neural network at any point in time. 133 | 134 | The delta size of the position required to hedge the option. 135 | """ 136 | return self.call(x) 137 | 138 | @tf.function 139 | def compute_pnl(self, init_spot): 140 | """On each run of the training, we'll run a MC simulatiion to calculate the PNL distribution 141 | across the `batch_size` paths. PNL integrated along a given path is the sum of the 142 | option payoff at the end and the realized PNL from the hedges; that is, for a given path j, 143 | 144 | PNL[j] = Sum[delta_p[i-1,j] * (S[i,j] - S[i-1,j])-abs(delta_g[i-1,j])*(S[i,j] - S[i-1,j]), {i, 1, N}] + Payoff(S[N,j]) 145 | 146 | where 147 | delta_p[i,j] = power spot hedge notional at time index i for path j 148 | delta_g[i,j] = gas spot hedge notional at time index i for path j 149 | S[i,j] = spot at time index i for path j 150 | N = total # of time steps from start to option expiration 151 | Payoff(S) = at-expiration option payoff 152 | 153 | We can define the PNL incrementally along a path like 154 | 155 | pnl[i,j] = pnl[i-1,j] + delta_p[i-1,j] * (S[i,j] - S[i-1,j])-abs(delta_g[i-1,j])*(S[i,j] - S[i-1,j]) 156 | 157 | where pnl[0,j] == 0 for every path. Then the integrated path PNL defined above is 158 | 159 | PNL[j] = pnl[N,j] + Payoff(S[N],j) 160 | 161 | So we build a tensorflow graph to calculate that integrated PNL for a given 162 | path. Then we'll define a loss function (given a set of path PNLs) equal to the 163 | expected shortfall of the integrated PNLs for each path in a batch. Make sure 164 | we're short the option so that (absent hedging) there's a -ve PNL. 165 | """ 166 | 167 | pnl = tf.zeros(self.batch_size, dtype=tf.float32) 168 | 169 | mu = (self.mu_P, self.mu_G) 170 | sigma = (self.vol_P, self.vol_G) 171 | spots = gbm2(init_spot, mu, sigma, self.dt, self.rho, self.n_steps, self.batch_size) 172 | spot = tf.zeros((self.batch_size, 2), dtype=tf.float32) 173 | 174 | # Run through the MC sim, generating path values for spots along the way 175 | # for time_index in tf.range(self.n_steps, dtype=tf.float32): 176 | for time_index in tf.range(self.n_steps): 177 | # 1. Compute updates at start of interval 178 | # Retrieve the neural network output, treating it as the delta hedge notional 179 | # at the start of the timestep. In the risk neutral limit, Black-Scholes is equivallent 180 | # to the minimising expected shortfall. Therefore, by minimising expected shortfall as 181 | # our loss function, the output of the network is trained to approximate Black-Scholes delta. 182 | t = tf.cast(time_index, dtype=tf.float32) * self.dt 183 | spot = spots[time_index, :, :] 184 | input_time = tf.expand_dims(tf.fill([self.batch_size], t), 1) 185 | inputs = tf.concat([spot, input_time], 1) 186 | delta = self.compute_hedge_delta(inputs) 187 | 188 | # 2. Compute updates at end of interval 189 | # Delta hedge and record the incremental pnl changes over the interval 190 | # We calculate delta_power * spot_change_power + delta_gas * spot_change_gas 191 | spot_change = spots[time_index + 1, :, :] - spot 192 | pnl += delta[:, 0] * spot_change[:, 0] + delta[:, 1] * spot_change[:, 1] 193 | 194 | # Calculate the final payoff 195 | spot_power = spot[:, 0] 196 | spot_gas = spot[:, 1] 197 | payoff = self.psi * tf.maximum(self.phi * (spot_power - self.H * spot_gas - self.K), 0) 198 | pnl += payoff # Note we sell the option here 199 | 200 | return pnl 201 | 202 | @tf.function 203 | def compute_loss(self, init_spot): 204 | """Use expected shortfall for the appropriate percentile as the loss function. 205 | 206 | Note that we do *not* expect this to minimize to zero. 207 | """ 208 | # TODO move to losses module as ExpectedShortfall? 209 | pnl = self.compute_pnl(init_spot) 210 | n_pct = int((100 - self.pctile) / 100 * self.batch_size) 211 | pnl_past_cutoff = tf.nn.top_k(-pnl, n_pct)[0] 212 | 213 | return tf.reduce_mean(pnl_past_cutoff) 214 | 215 | @tf.function 216 | def compute_mean_pnl(self, init_spot): 217 | """Mean PNL for debugging purposes""" 218 | pnl = self.compute_pnl(init_spot) 219 | return tf.reduce_mean(pnl) 220 | 221 | @tf.function 222 | def generate_random_init_spot(self): 223 | # TODO does this belong here? 224 | r = tf.random.normal((2,), 0, 2.0 * self.vol_P * self.texp ** 0.5) 225 | 226 | S_P = self.SP0 * tf.exp(-self.vol_P * self.vol_P * self.texp / 2.0 + r[0]) 227 | S_G = self.SG0 * tf.exp(-self.vol_G * self.vol_G * self.texp / 2.0 + r[1]) 228 | 229 | return S_P, S_G 230 | 231 | def simulate(self, n_paths, *, verbose=1, write_to_tensorboard=False): 232 | """Simulate the trading strategy and return the PNLs. 233 | 234 | Parameters 235 | ---------- 236 | n_paths : int 237 | Number of paths to test against. 238 | verbose : int 239 | Verbosity, use 0 to turn off all logging. 240 | write_to_tensorboard : bool 241 | Whether to write to tensorboard or not. 242 | 243 | Returns 244 | ------- 245 | tuple of :obj:`numpy.array` 246 | (unhedged pnl, Black-Scholes hedged pnl, neural network hedged pnl) 247 | """ 248 | 249 | t0 = time.time() 250 | 251 | if write_to_tensorboard: 252 | writer = tf.summary.create_file_writer('logs/') 253 | 254 | uh_pnls = np.zeros(n_paths, dtype=np.float32) 255 | nn_pnls = np.zeros(n_paths, dtype=np.float32) 256 | bs_pnls = np.zeros(n_paths, dtype=np.float32) 257 | 258 | S0 = (self.SP0, self.SG0) 259 | mu = (self.mu_P, self.mu_G) 260 | sigma = (self.vol_P, self.vol_G) 261 | spots = gbm2(S0, mu, sigma, self.dt, self.rho, self.n_steps, n_paths) 262 | spot = tf.zeros((n_paths, 2), dtype=tf.float32) 263 | 264 | # Run through the MC sim, generating path values for spots along the way. This is just like a regular MC 265 | # sim to price a derivative - except that the price is *not* the expected value - it's the loss function 266 | # value. That handles both the conversion from real world to "risk neutral" and unhedgeable risk due to 267 | # eg discrete hedging (which is the only unhedgeable risk in this example, but there could be anything generally). 268 | for time_index in range(self.n_steps): 269 | # 1. Compute updates at start of interval 270 | t = time_index * self.dt 271 | spot = spots[time_index, :, :].numpy() 272 | spot_power = spot[:, 0] 273 | spot_gas = spot[:, 1] 274 | 275 | input_time = tf.expand_dims(tf.constant([t] * n_paths), 1) 276 | nn_input = tf.concat([spot, input_time], 1) 277 | nn_delta = self.compute_hedge_delta(nn_input).numpy() 278 | nn_delta_power = nn_delta[:, 0] 279 | nn_delta_gas = nn_delta[:, 1] 280 | 281 | bs_delta = -self.psi * analytics.calc_opt_delta( 282 | self.is_call, spot_power, spot_gas, self.K, self.H, self.texp - t, self.vol_P, self.vol_G, 0, self.rho 283 | ) 284 | bs_delta_power, bs_delta_gas = bs_delta 285 | 286 | # 2. Compute updates at end of interval 287 | # Record incremental pnl changes over the interval 288 | # We calculate delta_power * spot_change_power + delta_gas * spot_change_gas 289 | spot_change = spots[time_index + 1, :, :] - spot 290 | nn_pnls += nn_delta_power * spot_change[:, 0] + nn_delta_gas * spot_change[:, 1] 291 | bs_pnls += bs_delta_power * spot_change[:, 0] + bs_delta_gas * spot_change[:, 1] 292 | 293 | if verbose != 0: 294 | log.info( 295 | '%.4f years - delta_power: mean % .4f,std % .4f; delta_gas: mean % .4f,std % .4f; spot_power: mean % .4f, std % .4f; spot_gas: mean % .4f, std % .4f', 296 | t, 297 | nn_delta_power.mean(), 298 | nn_delta_power.std(), 299 | nn_delta_gas.mean(), 300 | nn_delta_gas.std(), 301 | spot_power.mean(), 302 | spot_power.std(), 303 | spot_gas.mean(), 304 | spot_gas.std(), 305 | ) 306 | 307 | if write_to_tensorboard: 308 | with writer.as_default(): 309 | tf.summary.histogram('nn_delta_power', nn_delta_power, step=time_index) 310 | tf.summary.histogram('bs_delta_power', bs_delta_power, step=time_index) 311 | tf.summary.histogram('nn_delta_gas', nn_delta_gas, step=time_index) 312 | tf.summary.histogram('bs_delta_gas', bs_delta_gas, step=time_index) 313 | tf.summary.histogram('nn_pnls', nn_pnls, step=time_index) 314 | tf.summary.histogram('bs_pnls', bs_pnls, step=time_index) 315 | tf.summary.histogram('spot_power', spot_power, step=time_index) 316 | tf.summary.histogram('spot_gas', spot_gas, step=time_index) 317 | 318 | # Compute the payoff and some metrics 319 | payoff = self.psi * np.maximum(self.phi * (spot_power - self.H * spot_gas - self.K), 0) 320 | uh_pnls += payoff 321 | nn_pnls += payoff 322 | bs_pnls += payoff 323 | 324 | if write_to_tensorboard: 325 | writer.flush() 326 | 327 | if verbose != 0: 328 | duration = get_duration_desc(t0) 329 | log.info('Simulation time: %s', duration) 330 | 331 | return uh_pnls, bs_pnls, nn_pnls 332 | -------------------------------------------------------------------------------- /trellis/models/utils.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2020 Beacon Platform Inc. - All Rights Reserved. 2 | # License: MIT 3 | # Authors: Benjamin Pryke, Mark Higgins 4 | 5 | """Utils for deep hedging models.""" 6 | 7 | import logging 8 | import random 9 | 10 | import numpy as np 11 | import tensorflow as tf 12 | 13 | from trellis.plotting import ResultTypes, plot_heatmap, plot_deltas, plot_loss, plot_pnls 14 | from trellis.utils import calc_expected_shortfall 15 | 16 | log = logging.getLogger(__name__) 17 | log.setLevel(logging.INFO) 18 | 19 | 20 | def set_seed(seed=1): 21 | """Seed the RNGs for consistent results from run to run. 22 | 23 | Parameters 24 | ---------- 25 | seed : int 26 | RNG seed 27 | """ 28 | 29 | log.info('Using seed %d', seed) 30 | random.seed(seed) 31 | np.random.seed(seed) 32 | tf.random.set_seed(seed) 33 | 34 | 35 | def depends_on(*args): 36 | """Caches a `Model` parameter based on its dependencies. 37 | 38 | Example 39 | ------- 40 | >>> @property 41 | >>> @depends_on('x', 'y') 42 | >>> def param(self): 43 | >>> return self.x * self.y 44 | 45 | Parameters 46 | ---------- 47 | args : list of str 48 | List of parameters this parameter depends on. 49 | """ 50 | 51 | cache = {} 52 | 53 | def _wrapper(fn): 54 | def _fn(self): 55 | key = tuple(getattr(self, arg) for arg in args) 56 | 57 | if key not in cache: 58 | cache[key] = fn(self) 59 | 60 | return cache[key] 61 | 62 | return _fn 63 | 64 | return _wrapper 65 | 66 | 67 | def estimate_expected_shortfalls(uh_pnls, bs_pnls, nn_pnls, pctile, *, verbose=1): 68 | """Estimate the unhedged, analytical, and model expected shortfalls from a simulation. 69 | 70 | These estimates are also estimates of the fair price of the instrument. 71 | 72 | Parameters 73 | ---------- 74 | un_pnls : list of float or :obj:`numpy.array` of float 75 | Unhedged PNLs for n paths 76 | bs_pnls : list of float or :obj:`numpy.array` of float 77 | Black-Scholes analytical PNLs for n paths 78 | nn_pnls : list of float or :obj:`numpy.array` of float 79 | Neural network output PNLs for n paths 80 | pctile : int, float 81 | Percentage Expected Shortfall to calculate 82 | 83 | Returns 84 | ------- 85 | tuple of float 86 | (unhedged ES, analytical ES, neural network ES) 87 | """ 88 | 89 | uh_es = calc_expected_shortfall(uh_pnls, pctile) 90 | bs_es = calc_expected_shortfall(bs_pnls, pctile) 91 | nn_es = calc_expected_shortfall(nn_pnls, pctile) 92 | 93 | if verbose != 0: 94 | log.info('Unhedged ES = % .5f (mean % .5f, std % .5f)', uh_es, np.mean(uh_pnls), np.std(uh_pnls)) 95 | log.info('Deep hedging ES = % .5f (mean % .5f, std % .5f)', nn_es, np.mean(nn_pnls), np.std(nn_pnls)) 96 | log.info('Black-Scholes ES = % .5f (mean % .5f, std % .5f)', bs_es, np.mean(bs_pnls), np.std(bs_pnls)) 97 | 98 | return uh_es, bs_es, nn_es 99 | -------------------------------------------------------------------------------- /trellis/models/variable_annuity/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2020 Beacon Platform Inc. - All Rights Reserved. 2 | # License: MIT 3 | # Authors: Benjamin Pryke, Mark Higgins 4 | 5 | """Variable annuity deep hedging and Black-Scholes models.""" 6 | 7 | from trellis.models.variable_annuity.analytics import calc_delta, calc_fair_fee 8 | from trellis.models.variable_annuity.model import Hyperparams, VariableAnnuity 9 | -------------------------------------------------------------------------------- /trellis/models/variable_annuity/analytics.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2020 Beacon Platform Inc. - All Rights Reserved. 2 | # License: MIT 3 | # Authors: Benjamin Pryke, Mark Higgins 4 | 5 | """Black-Scholes-based analytical Variable Annuity calculations.""" 6 | 7 | import numpy as np 8 | import scipy 9 | 10 | from trellis.models.european_option import analytics as option_analytics 11 | 12 | 13 | def calc_fair_fee(texp, gmdb, S0, vol, lam): 14 | """Fair fee that the insurer receives to make the whole structure zero cost""" 15 | 16 | def port_value(est_fee): 17 | def integ(t): 18 | fwd = S0 * np.exp(-est_fee * t) 19 | opt = option_analytics.calc_opt_price(False, fwd, gmdb, t, vol, 0, 0) 20 | 21 | return opt * np.exp(-lam * t) * lam 22 | 23 | drift_rn = -est_fee - lam 24 | 25 | val = -scipy.integrate.quad(integ, 0, texp)[0] 26 | if drift_rn == 0: 27 | val += est_fee * texp 28 | else: 29 | val += est_fee * (np.exp(drift_rn * texp) - 1) / drift_rn 30 | 31 | return val 32 | 33 | fair_fee = scipy.optimize.newton(port_value, 1e-3) 34 | return float(fair_fee) 35 | 36 | 37 | def calc_delta(texp, start_time, lam, vol, fee, gmdb, account, spot): 38 | """Delta to put on against the VA portfolio""" 39 | 40 | t_fwd = texp - start_time 41 | n_int = max(2, int(round(0.4 * t_fwd))) 42 | dt_int = t_fwd / n_int 43 | deltas = -fee * account * np.exp(-lam * start_time) / spot * (1 - np.exp(-(fee + lam) * t_fwd)) / (fee + lam) 44 | for j in range(n_int): 45 | t_int = (j + 0.5) * dt_int # time from current time to put expiration 46 | adj_strikes = gmdb * spot / account * np.exp(fee * t_int) 47 | d1s = (np.log(spot / adj_strikes) + vol * vol * t_int / 2) / vol / np.sqrt(t_int) 48 | put_deltas = scipy.stats.norm.cdf(d1s * -1) * -1 * account / spot * np.exp(-fee * t_int) 49 | t_s = j * dt_int 50 | t_e = t_s + dt_int 51 | deltas += (np.exp(-lam * t_s) - np.exp(-lam * t_e)) * put_deltas * np.exp(-lam * start_time) 52 | 53 | return deltas 54 | -------------------------------------------------------------------------------- /trellis/models/variable_annuity/model.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2020 Beacon Platform Inc. - All Rights Reserved. 2 | # License: MIT 3 | # Authors: Benjamin Pryke, Mark Higgins 4 | 5 | """Variable Annuity model.""" 6 | 7 | import logging 8 | import time 9 | 10 | import numpy as np 11 | import tensorflow as tf 12 | 13 | from trellis.models.base import Model, HyperparamsBase 14 | from trellis.models.variable_annuity import analytics 15 | from trellis.models.utils import depends_on 16 | from trellis.random_processes import gbm 17 | from trellis.utils import calc_expected_shortfall, get_duration_desc 18 | 19 | log = logging.getLogger(__name__) 20 | log.setLevel(logging.INFO) 21 | 22 | 23 | class Hyperparams(HyperparamsBase): 24 | n_layers = 2 25 | n_hidden = 50 # Number of nodes per hidden layer 26 | w_std = 0.05 # Initialisation std of the weights 27 | b_std = 0.05 # Initialisation std of the biases 28 | 29 | learning_rate = 5e-3 30 | batch_size = 100 # Number of MC paths per batch 31 | epoch_size = 100 # Number of batches per epoch 32 | n_epochs = 100 # Number of epochs to train for 33 | n_val_paths = 50_000 # Number of paths to validate against 34 | n_test_paths = 100_000 # Number of paths to test against 35 | 36 | S0 = 1.0 # initial spot price 37 | mu = 0.0 # Expected upward spot drift, in years 38 | vol = 0.2 # Volatility 39 | 40 | texp = 5.0 # Fixed tenor to expiration, in years 41 | principal = 100.0 # Initial investment lump sum 42 | gmdb_frac = 1.0 43 | gmdb = gmdb_frac * principal # Guaranteed minimum death benefit, floored at principal investment 44 | lam = 0.01 # (constant) probability of death per year 45 | 46 | dt = 1 / 12 # Timesteps per year 47 | n_steps = int(texp / dt) # Number of time steps 48 | pctile = 70 # Percentile for expected shortfall 49 | 50 | @property 51 | @depends_on('texp', 'gmdb_frac', 'S0', 'vol', 'lam') 52 | def fee(self): 53 | """Annual fee percentage""" 54 | return analytics.calc_fair_fee(self.texp, self.gmdb_frac, self.S0, self.vol, self.lam) 55 | 56 | @property 57 | def critical_fields(self): 58 | """Tuple of parameters that uniquely define the model.""" 59 | return ( 60 | self.n_layers, 61 | self.n_hidden, 62 | self.w_std, 63 | self.b_std, 64 | self.learning_rate, 65 | self.batch_size, 66 | self.S0, 67 | self.mu, 68 | self.vol, 69 | self.texp, 70 | self.principal, 71 | self.lam, 72 | self.dt, 73 | self.pctile, 74 | ) 75 | 76 | 77 | class VariableAnnuity(Model, Hyperparams): 78 | def __init__(self, **kwargs): 79 | """Define our NN structure; we use the same nodes in each timestep. 80 | 81 | Network inputs are spot and the time to expiration. 82 | Network output is delta hedge notional. 83 | """ 84 | 85 | Hyperparams.__init__(self, **kwargs) 86 | Model.__init__(self) 87 | 88 | # Hidden layers 89 | for _ in range(self.n_layers): 90 | self.add( 91 | tf.keras.layers.Dense( 92 | units=self.n_hidden, 93 | activation='relu', 94 | kernel_initializer=tf.initializers.TruncatedNormal(stddev=self.w_std), 95 | bias_initializer=tf.initializers.TruncatedNormal(stddev=self.b_std), 96 | ) 97 | ) 98 | 99 | # Output 100 | # We have one output (notional of spot hedge) 101 | self.add( 102 | tf.keras.layers.Dense( 103 | units=1, 104 | activation='linear', 105 | kernel_initializer=tf.initializers.TruncatedNormal(stddev=self.w_std), 106 | bias_initializer=tf.initializers.TruncatedNormal(stddev=self.b_std), 107 | ) 108 | ) 109 | 110 | # Inputs 111 | # Our 2 inputs are spot price and time, which are mostly determined during the MC 112 | # simulation except for the initial spot at time 0 113 | self.build((None, 2)) 114 | 115 | @tf.function 116 | def compute_hedge_delta(self, x): 117 | """Returns the output of the neural network at any point in time. 118 | 119 | The delta size of the position required to hedge the option. 120 | """ 121 | return -self.call(x) ** 2 122 | 123 | @tf.function 124 | def compute_pnl(self, init_spot): 125 | """On each run of the training, we'll run a MC simulatiion to calculate the PNL distribution 126 | across the `batch_size` paths. PNL integrated along a given path is the sum of the 127 | option payoff at the end and the realized PNL from the hedges; that is, for a given path j, 128 | 129 | PNL[j] = Sum[delta_s[i-1,j] * (S[i,j] - S[i-1,j]), {i, 1, N}] + Payoff(S[N,j]) 130 | 131 | where 132 | delta_s[i,j] = spot hedge notional at time index i for path j 133 | S[i,j] = spot at time index i for path j 134 | N = total # of time steps from start to option expiration 135 | Payoff(S) = at-expiration option payoff (ie max(S-K,0) for a call option and max(K-S,0) for a put) 136 | 137 | We can define the PNL incrementally along a path like 138 | 139 | pnl[i,j] = pnl[i-1,j] + delta_s[i-1,j] * (S[i,j] - S[i-1,j]) 140 | 141 | where pnl[0,j] == 0 for every path. Then the integrated path PNL defined above is 142 | 143 | PNL[j] = pnl[N,j] + Payoff(S[N],j) 144 | 145 | So we build a tensorflow graph to calculate that integrated PNL for a given 146 | path. Then we'll define a loss function (given a set of path PNLs) equal to the 147 | expected shortfall of the integrated PNLs nfor each path in a batch. Make sure 148 | we're short the option so that (absent hedging) there's a -ve PNL. 149 | """ 150 | 151 | pnl = tf.zeros(self.batch_size, dtype=tf.float32) # Account values are not part of insurer pnl 152 | spots = gbm(init_spot, self.mu, self.vol, self.dt, self.n_steps, self.batch_size) # Defined in the real world measure 153 | account = tf.zeros(self.batch_size, dtype=tf.float32) + self.principal # Every path represents an infinite number of accounts 154 | 155 | # Run through the MC sim, generating path values for spots along the way 156 | for time_index in tf.range(self.n_steps): 157 | # 1. Compute updates at start of interval 158 | t = tf.cast(time_index, dtype=tf.float32) * self.dt 159 | spot = spots[time_index] 160 | 161 | # Retrieve the neural network output, treating it as the delta hedge notional 162 | # at the start of the timestep. In the risk neutral limit, Black-Scholes is equivallent 163 | # to the minimising expected shortfall. Therefore, by minimising expected shortfall as 164 | # our loss function, the output of the network is trained to approximate Black-Scholes delta. 165 | input_time = tf.fill([self.batch_size], t) 166 | inputs = tf.stack([spot, input_time], 1) 167 | delta = self.compute_hedge_delta(inputs)[:, 0] 168 | delta *= tf.minimum(tf.math.exp(-0.01 * delta), 1.0) 169 | delta *= (1 - tf.math.exp(-self.lam * (self.texp - t))) * self.principal 170 | 171 | account = self.principal * spot / self.S0 * tf.math.exp(-self.fee * t) 172 | fee = self.fee * self.dt * account * tf.math.exp(-self.lam * t) 173 | payout = self.lam * self.dt * tf.maximum(self.gmdb - account, 0) * tf.math.exp(-self.lam * t) 174 | inc_pnl = fee - payout 175 | 176 | # 2. Compute updates at end of interval 177 | # Delta hedge and record the incremental pnl changes over the interval 178 | spot_change = spots[time_index + 1] - spot 179 | pnl += inc_pnl + delta * spot_change 180 | 181 | return pnl 182 | 183 | @tf.function 184 | def compute_loss(self, init_spot): 185 | """Use expected shortfall for the appropriate percentile as the loss function. 186 | 187 | Note that we do *not* expect this to minimize to zero. 188 | """ 189 | # TODO move to losses module as ExpectedShortfall? 190 | 191 | pnl = self.compute_pnl(init_spot) 192 | n_pct = int((100 - self.pctile) / 100 * self.batch_size) 193 | pnl_past_cutoff = tf.nn.top_k(-pnl, n_pct)[0] 194 | return tf.reduce_mean(pnl_past_cutoff) 195 | 196 | @tf.function 197 | def compute_mean_pnl(self, init_spot): 198 | """Mean PNL for debugging purposes""" 199 | pnl = self.compute_pnl(init_spot) 200 | return tf.reduce_mean(pnl) 201 | 202 | @tf.function 203 | def generate_random_init_spot(self): 204 | # TODO does this belong here? 205 | r = tf.random.normal((1,), 0, 2.0 * self.vol * self.texp ** 0.5)[0] 206 | return self.S0 * tf.exp(-self.vol * self.vol * self.texp / 2.0 + r) 207 | 208 | def simulate(self, n_paths, *, verbose=1, write_to_tensorboard=False): 209 | """Simulate the trading strategy and return the PNLs. 210 | 211 | Parameters 212 | ---------- 213 | n_paths : int 214 | Number of paths to test against. 215 | verbose : int 216 | Verbosity, use 0 to turn off all logging. 217 | write_to_tensorboard : bool 218 | Whether to write to tensorboard or not. 219 | 220 | Returns 221 | ------- 222 | tuple of :obj:`numpy.array` 223 | (unhedged pnl, Black-Scholes hedged pnl, neural network hedged pnl) 224 | """ 225 | 226 | t0 = time.time() 227 | 228 | if write_to_tensorboard: 229 | writer = tf.summary.create_file_writer('logs/') 230 | 231 | spots = gbm(self.S0, self.mu, self.vol, self.dt, self.n_steps, n_paths).numpy() 232 | uh_pnls = np.zeros(n_paths, dtype=np.float32) 233 | nn_pnls = np.zeros(n_paths, dtype=np.float32) 234 | bs_pnls = np.zeros(n_paths, dtype=np.float32) 235 | account = np.zeros(n_paths, dtype=np.float32) + self.principal # Every path represents an infinite number of accounts 236 | 237 | # Run through the MC sim, generating path values for spots along the way. This is just like a regular MC 238 | # sim to price a derivative - except that the price is *not* the expected value - it's the loss function 239 | # value. That handles both the conversion from real world to "risk neutral" and unhedgeable risk due to 240 | # eg discrete hedging (which is the only unhedgeable risk in this example, but there could be anything generally). 241 | for time_index in range(self.n_steps): 242 | # 1. Compute updates at start of interval 243 | t = time_index * self.dt 244 | spot = spots[time_index] 245 | 246 | # Compute deltas 247 | input_time = tf.constant([t] * n_paths) 248 | nn_input = tf.stack([spot, input_time], 1) 249 | nn_delta = self.compute_hedge_delta(nn_input)[:, 0].numpy() 250 | nn_delta = np.minimum(nn_delta, 0) # pylint: disable=assignment-from-no-return 251 | nn_delta *= (1 - np.exp(-self.lam * (self.texp - t))) * self.principal 252 | 253 | bs_delta = analytics.calc_delta(self.texp, t, self.lam, self.vol, self.fee, self.gmdb, account, spot) 254 | 255 | # Compute step updates 256 | account = self.principal * spot / self.S0 * np.exp(-self.fee * t) 257 | fee = self.fee * self.dt * account * np.exp(-self.lam * t) 258 | payout = self.lam * self.dt * np.maximum(self.gmdb - account, 0) * np.exp(-self.lam * t) 259 | inc_pnl = fee - payout 260 | 261 | # 2. Compute updates at end of interval 262 | # Record incremental pnl changes over the interval 263 | spot_change = spots[time_index + 1] - spot 264 | uh_pnls += inc_pnl 265 | nn_pnls += inc_pnl + nn_delta * spot_change 266 | bs_pnls += inc_pnl + bs_delta * spot_change 267 | 268 | if verbose != 0: 269 | log.info( 270 | '%.4f years - delta: mean % .5f, std % .5f; spot: mean % .5f, std % .5f', 271 | t, 272 | nn_delta.mean(), 273 | nn_delta.std(), 274 | spot.mean(), 275 | spot.std(), 276 | ) 277 | 278 | if write_to_tensorboard: 279 | with writer.as_default(): 280 | tf.summary.histogram('nn_delta', nn_delta, step=time_index) 281 | tf.summary.histogram('bs_delta', bs_delta, step=time_index) 282 | tf.summary.histogram('uh_pnls', uh_pnls, step=time_index) 283 | tf.summary.histogram('nn_pnls', nn_pnls, step=time_index) 284 | tf.summary.histogram('bs_pnls', bs_pnls, step=time_index) 285 | tf.summary.histogram('spot', spot, step=time_index) 286 | tf.summary.histogram('fee', fee, step=time_index) 287 | tf.summary.histogram('payout', payout, step=time_index) 288 | tf.summary.histogram('inc_pnl', inc_pnl, step=time_index) 289 | 290 | if write_to_tensorboard: 291 | writer.flush() 292 | 293 | if verbose != 0: 294 | duration = get_duration_desc(t0) 295 | log.info('Simulation time: %s', duration) 296 | 297 | return uh_pnls, bs_pnls, nn_pnls 298 | -------------------------------------------------------------------------------- /trellis/plotting.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2020 Beacon Platform Inc. - All Rights Reserved. 2 | # License: MIT 3 | # Authors: Benjamin Pryke, Mark Higgins 4 | 5 | """Plotting library for models.""" 6 | 7 | from enum import Enum 8 | import logging 9 | import time 10 | 11 | import matplotlib.colors 12 | import matplotlib.pyplot as plt 13 | import numpy as np 14 | import scipy 15 | import seaborn as sns 16 | 17 | from trellis.utils import get_duration_desc 18 | 19 | sns.set(style='darkgrid', palette='deep') 20 | plt.rcParams['figure.figsize'] = (8, 6) 21 | log = logging.getLogger(__name__) 22 | log.setLevel(logging.INFO) 23 | 24 | 25 | class ResultTypes(Enum): 26 | """Result identifier with `label` and `colour` fields for each type. 27 | 28 | For consistency between different plots of data corresponding to the same 29 | unerlying models. 30 | """ 31 | 32 | def __new__(cls, label, colour_index): 33 | palette = sns.color_palette() 34 | value = len(cls.__members__) + 1 35 | obj = object.__new__(cls) 36 | obj._value_ = value 37 | obj.label = label 38 | obj.colour = palette[colour_index] 39 | return obj 40 | 41 | UNHEDGED = ('Unhedged', 2) 42 | BLACK_SCHOLES = ('Black-Scholes', 1) 43 | DEEP_HEDGING = ('Deep Hedging', 0) 44 | 45 | 46 | def calc_thist(data, n_bins=30): 47 | n_steps = len(data) 48 | x = np.array([np.arange(n_bins) for _ in range(n_steps)]).flatten() 49 | y = np.array([np.full(n_bins, i) for i in range(n_steps)]).flatten() 50 | z = np.zeros(n_bins * n_steps) 51 | dx = dy = np.zeros(n_bins * n_steps) + 0.5 52 | flat_data = [i for j in data for i in j] 53 | r = (min(flat_data), max(flat_data)) 54 | dz = np.array([np.histogram(d, n_bins, r)[0] for d in data])[::-1].flatten() 55 | return x, y, z, dx, dy, dz, r 56 | 57 | 58 | def plot_thist(data, n_bins=30): 59 | from mpl_toolkits.mplot3d import Axes3D 60 | 61 | n_ticks = 7 62 | fig = plt.figure() 63 | ax = fig.add_subplot(111, projection='3d') 64 | *bar_data, r = calc_thist(data) 65 | ax.bar3d(*bar_data, linewidth=0.1) 66 | plt.title('Delta distribution over time') 67 | ax.set_xlabel('Delta') 68 | ax.set_xticks(np.arange(0, n_bins + 1, n_bins // (n_ticks - 1))) 69 | ax.set_xticklabels([f'{r[0] + i * abs(r[0] - r[1]) / (n_ticks - 1):.2f}' for i in range(n_ticks)]) 70 | ax.set_ylabel('Simulation Month') 71 | ax.set_yticks(np.arange(0, len(data) + 1, len(data) // (n_ticks - 1))) 72 | ax.set_yticklabels([f'{(n_ticks - i) * 10:d}' for i in range(1, n_ticks + 1)]) 73 | ax.set_zlabel('Frequency') 74 | plt.show() 75 | 76 | 77 | def plot_loss(losses, *, smoothing_windows=(5, 25), min_points=10): 78 | """Plot loss against number of epochs, maybe adding smoothed averages""" 79 | 80 | curves = [losses] 81 | labels = ['Loss'] 82 | 83 | for window in smoothing_windows: 84 | if len(curves) > window * min_points: 85 | smoothed = np.convolve(losses, np.ones((window,)) / window, mode='valid') 86 | curves.append(smoothed) 87 | labels.append(f'Mean of {window}') 88 | 89 | for curve in curves: 90 | plt.plot(range(1, len(losses) + 1), curve) 91 | 92 | plt.title('Loss over time') 93 | plt.xlabel('Epoch') 94 | plt.ylabel('Loss') 95 | plt.legend(labels=labels) 96 | plt.tight_layout() 97 | plt.gca().set_xlim([1, len(losses)]) 98 | plt.show() 99 | 100 | 101 | def plot_deltas(model, compute_nn_delta, compute_bs_delta, *, verbose=1): 102 | """Plot out delta vs spot for a range of calendar times 103 | 104 | Calculated against the known closed-form BS delta. 105 | """ 106 | 107 | f, axes = plt.subplots(2, 2, sharey=True, sharex=True) 108 | f.suptitle('Delta hedge vs spot vs time to maturity') 109 | axes = axes.flatten() 110 | spot_fact = np.exp(3 * model.vol * model.texp ** 0.5) 111 | ts = [0.0, model.texp * 0.25, model.texp * 0.5, model.texp * 0.95] 112 | n_spots = 1000 113 | 114 | for t, ax in zip(ts, axes): 115 | spot_min = model.S0 / spot_fact 116 | spot_max = model.S0 * spot_fact 117 | test_spot = np.linspace(spot_min, spot_max, n_spots).astype(np.float32) 118 | test_delta = compute_nn_delta(model, t, test_spot) 119 | est_delta = compute_bs_delta(model, t, test_spot) 120 | 121 | if verbose != 0: 122 | log.info('Delta: mean = % .5f, std = % .5f', test_delta.mean(), test_delta.std()) 123 | 124 | # Add a subsplot 125 | ax.set_title('Calendar time {:.2f} years'.format(t)) 126 | ax.set_xlim([spot_min, spot_max]) 127 | (bs_plot,) = ax.plot(test_spot, est_delta, color=ResultTypes.BLACK_SCHOLES.colour) 128 | (nn_plot,) = ax.plot(test_spot, test_delta, color=ResultTypes.DEEP_HEDGING.colour) 129 | 130 | ax.legend([bs_plot, nn_plot], [ResultTypes.BLACK_SCHOLES.label, ResultTypes.DEEP_HEDGING.label]) 131 | f.text(0.5, 0.04, 'Spot', ha='center') 132 | f.text(0.04, 0.5, 'Delta', ha='center', rotation='vertical') 133 | plt.tight_layout(rect=[0.04, 0.04, 1, 0.95]) 134 | plt.show() 135 | 136 | 137 | def plot_deltas_heatrate( 138 | filename, model, compute_nn_delta, compute_bs_delta, delta_type, spot_type, *, verbose=1, 139 | ): 140 | """Plot out four plots of delta (power/gas) vs spot (power/gas) for a range of calendar times""" 141 | 142 | f, axes = plt.subplots(2, 2, sharey=True, sharex=True) 143 | f.suptitle('Delta hedge ' + delta_type + ' vs spot ' + spot_type + ' vs time to maturity') 144 | axes = axes.flatten() 145 | ts = [0.0, model.texp * 0.25, model.texp * 0.5, model.texp * 0.95] 146 | n_spots = 1000 147 | 148 | for t, ax in zip(ts, axes): 149 | if spot_type == 'power': 150 | spot_min_power = model.SP0 - 10 151 | spot_max_power = model.SP0 + 10 152 | spot_min_gas = spot_max_gas = model.SG0 153 | else: 154 | spot_min_gas = model.SG0 - 10 155 | spot_max_gas = model.SG0 + 10 156 | spot_min_power = spot_max_power = model.SP0 157 | 158 | test_spot_power = np.linspace(spot_min_power, spot_max_power, n_spots).astype(np.float32) 159 | test_spot_gas = np.linspace(spot_min_gas, spot_max_gas, n_spots).astype(np.float32) 160 | 161 | test_delta = compute_nn_delta(model, t, test_spot_power, test_spot_gas, delta_type) 162 | est_delta = compute_bs_delta(model, t, test_spot_power, test_spot_gas, delta_type) 163 | 164 | if verbose != 0: 165 | log.info('Delta: mean = % .5f, std = % .5f', test_delta.mean(), test_delta.std()) 166 | 167 | # Add a subsplot 168 | ax.set_title('Calendar time {:.2f} years'.format(t)) 169 | 170 | if spot_type == 'power': 171 | ax.set_xlim([spot_min_power, spot_max_power]) 172 | (bs_plot,) = ax.plot(test_spot_power, est_delta, color=ResultTypes.BLACK_SCHOLES.colour) 173 | (nn_plot,) = ax.plot(test_spot_power, test_delta, color=ResultTypes.DEEP_HEDGING.colour) 174 | else: 175 | ax.set_xlim([spot_min_gas, spot_max_gas]) 176 | (bs_plot,) = ax.plot(test_spot_gas, est_delta, color=ResultTypes.BLACK_SCHOLES.colour) 177 | (nn_plot,) = ax.plot(test_spot_gas, test_delta, color=ResultTypes.DEEP_HEDGING.colour) 178 | 179 | ax.legend([bs_plot, nn_plot], [ResultTypes.BLACK_SCHOLES.label, ResultTypes.DEEP_HEDGING.label]) 180 | f.text(0.5, 0.04, 'Spot ' + spot_type, ha='center') 181 | f.text(0.04, 0.5, 'Delta ' + delta_type, ha='center', rotation='vertical') 182 | plt.tight_layout(rect=[0.04, 0.04, 1, 0.95]) 183 | plt.savefig(filename) 184 | 185 | 186 | def plot_pnls(pnls, types, *, trim_tails=0): 187 | """Plot histogram comparing pnls 188 | 189 | pnls : list of :obj:`numpy.array` 190 | Pnls to plot 191 | types : list of `ResultTypes` 192 | Type for the pnl data at each corresponding index in `pnls` 193 | trim_tails : int 194 | Percentile to trim from each tail when plotting 195 | """ 196 | 197 | _ = plt.figure() 198 | hist_range = (np.percentile(pnls, trim_tails), np.percentile(pnls, 100 - trim_tails)) 199 | 200 | for pnl, rtype in zip(pnls, types): 201 | face_color = matplotlib.colors.to_rgba(rtype.colour, 0.7) 202 | plt.hist(pnl, range=hist_range, bins=200, facecolor=face_color, edgecolor=(1, 1, 1, 0.01), linewidth=0.5) 203 | 204 | plt.title('Post-simulation PNL histograms') 205 | plt.xlabel('PNL') 206 | plt.ylabel('Frequency') 207 | plt.legend([rtype.label for rtype in types]) 208 | plt.tight_layout() 209 | plt.show() 210 | 211 | 212 | def compute_heatmap(model, title, xparam, xvals, yparam, yvals, *, repeats=3, get_callbacks=None, **kwargs): 213 | """Run the model""" 214 | 215 | t0 = time.time() 216 | log.info('Hedging a variable annuity') 217 | 218 | errors = np.zeros((len(yvals), len(xvals))) 219 | 220 | for i, y in enumerate(yvals): 221 | for j, x in enumerate(xvals): 222 | log.info('Training with (y=%f, x=%f) over %d repeats', y, x, repeats) 223 | hparams = dict({xparam: x, yparam: y}, **kwargs) 224 | 225 | for _ in range(repeats): 226 | mdl = model(**hparams) 227 | callbacks = get_callbacks(mdl) if get_callbacks is not None else None 228 | mdl.train(callbacks=callbacks) 229 | errors[i, j] += mdl.test() 230 | 231 | errors[i, j] /= repeats 232 | log.info('Test error for (y=%f, x=%f): %.5f', y, x, errors[i, j]) 233 | 234 | log.info('Heatmap:') 235 | log.info(np.array2string(errors, separator=',')) 236 | log.info('Total running time: %s', get_duration_desc(t0)) 237 | 238 | return errors 239 | 240 | 241 | def plot_heatmap(model, title, xparam, xlabel, xvals, yparam, ylabel, yvals, *, repeats=3, **kwargs): 242 | errors = compute_heatmap(model, title, xparam, xvals, yparam, yvals, repeats=repeats, **kwargs) 243 | sns.heatmap(data=errors[::-1], annot=True, fmt=".4f", xticklabels=xvals, yticklabels=yvals[::-1]) 244 | plt.title(title) 245 | plt.xlabel(xlabel) 246 | plt.ylabel(ylabel) 247 | plt.tight_layout() 248 | plt.show() 249 | 250 | 251 | def plot_paths(paths): 252 | """Plot many paths to visualise Monte Carlo simulation over time.""" 253 | 254 | n_paths = len(paths) - 1 255 | plt.plot(paths) 256 | plt.title('Monte Carlo simulation of spot prices over time') 257 | plt.xlabel('Time') 258 | plt.ylabel('Spot') 259 | plt.gca().set_xlim([0, n_paths]) 260 | plt.show() 261 | 262 | 263 | def plot_spot_hist(paths, time_index): 264 | """Plot histogram of spot prices at a given index in the simulation.""" 265 | 266 | plt.hist(paths[time_index, :], bins=50) 267 | plt.title('Histogram of spot prices at simulation step {}'.format(time_index)) 268 | plt.xlabel('Spot') 269 | plt.ylabel('Frequency') 270 | plt.tight_layout() 271 | plt.show() 272 | -------------------------------------------------------------------------------- /trellis/random_processes.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2020 Beacon Platform Inc. - All Rights Reserved. 2 | # License: MIT 3 | # Authors: Benjamin Pryke, Mark Higgins 4 | 5 | """Stochastic processes.""" 6 | 7 | import tensorflow as tf 8 | 9 | 10 | @tf.function 11 | def gbm(S0, mu, sigma, dt, n_steps, n_paths=1): 12 | """Simulates geometric Brownian motion (GBM). 13 | 14 | Parameters 15 | ---------- 16 | S0 : float 17 | Initial value 18 | mu : float 19 | Expected upward drift per year, e.g. 0.08 = 8% per year 20 | sigma : float 21 | Volatility 22 | dt : float 23 | Length of each timestep in years, e.g. 1/12 = monthly 24 | n_steps : int 25 | Number of timesteps to simulate 26 | n_paths : int 27 | Number of paths to simulate 28 | 29 | Returns 30 | ------- 31 | :obj:`~tensorflow.Tensor` 32 | 2D tensor of paths, with shape `(n_steps + 1, n_paths)`, each starting at `S0` 33 | """ 34 | 35 | S0 = tf.fill((1, n_paths), S0) 36 | w = tf.random.normal((n_steps, n_paths), 0, dt ** 0.5) 37 | log_spots = tf.math.cumsum((mu - sigma * sigma / 2.0) * dt + sigma * w) 38 | spots = S0 * tf.math.exp(log_spots) 39 | return tf.concat((S0, spots), axis=0) 40 | 41 | 42 | @tf.function 43 | def gbm2(S0, mu, sigma, dt, rho, n_steps, n_paths=1): 44 | """Simulates correlated geometric Brownian motion (GBM) for 2 paths `n_paths` times. 45 | 46 | Parameters 47 | ---------- 48 | S0 : float, list, tuple, :obj:`~numpy.array`, or :obj:`~tensorflow.Tensor` 49 | Initial values, of length 2. 50 | Passing an iterable of length 2 will give each pair separate initial values. 51 | mu : float, list, tuple, :obj:`~numpy.array`, or :obj:`~tensorflow.Tensor` 52 | Expected upward drift per year, e.g. 0.08 = 8% per year. 53 | Passing an iterable of length 2 will give each pair separate drifts. 54 | sigma : float, list, tuple, :obj:`~numpy.array`, or :obj:`~tensorflow.Tensor` 55 | Volatility. 56 | Passing an iterable of length 2 will give each pair separate vols. 57 | dt : float 58 | Length of each timestep in years, e.g. 1/12 = monthly 59 | n_steps : int 60 | Number of timesteps to simulate 61 | rho : int 62 | Correlation coefficient between the two paths 63 | 64 | Returns 65 | ------- 66 | :obj:`~tensorflow.Tensor` 67 | 3D tensor of paths, with shape `(n_steps + 1, n_paths, 2)`, starting at `S0` 68 | """ 69 | 70 | S0 = tf.broadcast_to(S0, (1, n_paths, 2)) 71 | mu = tf.broadcast_to(mu, (n_steps, n_paths, 2)) 72 | sigma = tf.broadcast_to(sigma, (n_steps, n_paths, 2)) 73 | 74 | z = tf.random.normal((n_steps, n_paths, 2), 0, dt ** 0.5) 75 | w1 = z[:, :, 0] 76 | w2 = rho * w1 + tf.sqrt(1.0 - rho * rho) * z[:, :, 1] 77 | w = tf.stack((w1, w2), axis=2) 78 | log_spots = tf.math.cumsum((mu - sigma * sigma / 2.0) * dt + sigma * w) 79 | spots = S0 * tf.math.exp(log_spots) 80 | return tf.concat((S0, spots), axis=0) 81 | -------------------------------------------------------------------------------- /trellis/utils.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2020 Beacon Platform Inc. - All Rights Reserved. 2 | # License: MIT 3 | # Authors: Benjamin Pryke, Mark Higgins 4 | 5 | """Utilities for the deep hedging scripts.""" 6 | 7 | import logging 8 | import time 9 | import os 10 | import sys 11 | 12 | # Note: must not import `tensorflow` here as it is required that we do not by `disable_gpu` 13 | import numpy as np 14 | 15 | logging.basicConfig(level=logging.INFO, format='%(message)s') 16 | log = logging.getLogger(__name__) 17 | 18 | 19 | def disable_gpu(): 20 | """Disables GPU in TensorFlow. Must be called before initialising TensorFlow.""" 21 | os.environ['CUDA_VISIBLE_DEVICES'] = '-1' 22 | 23 | 24 | def calc_expected_shortfall(pnls, pctile): 25 | """Calculate the expected shortfall across a number of paths pnls. 26 | 27 | Note: Conventionally, expected shortfall is often reported as a positive number, so 28 | here we switch the sign. 29 | 30 | Parameters 31 | ---------- 32 | pnls : :obj:`numpy.array` 33 | Array of pnls for a number of paths. 34 | """ 35 | 36 | n_pct = int((100 - pctile) / 100 * len(pnls)) 37 | pnls = np.sort(pnls) 38 | price = -pnls[:n_pct].mean() 39 | 40 | return price 41 | 42 | 43 | def get_progressive_min(array): 44 | """Returns an array representing the closest to zero so far in the given array. 45 | 46 | Specifically, output value at index i will equal `min(abs(array[:i+1]))`. 47 | 48 | Parameters 49 | ---------- 50 | array : list of :obj:`~numbers.Number` or :obj:`numpy.array` 51 | Input 52 | 53 | Returns 54 | ------- 55 | list 56 | Progressively "best so far" minimum values from the input array 57 | """ 58 | 59 | result = [0] * len(array) 60 | best = abs(array[0]) 61 | 62 | for i, value in enumerate(array): 63 | if abs(value) < abs(best): 64 | best = value 65 | 66 | result[i] = best 67 | 68 | return result 69 | 70 | 71 | def get_duration_desc(start): 72 | """Returns a string {min}:{sec} describing the duration since `start` 73 | 74 | Parameters 75 | ---------- 76 | start : int or float 77 | Timestamp 78 | """ 79 | 80 | end = time.time() 81 | duration = round(end - start) 82 | minutes, seconds = divmod(duration, 60) 83 | return '{:02d}:{:02d}'.format(minutes, seconds) 84 | --------------------------------------------------------------------------------